#!/usr/bin/env bash # ArfBotOS Raspberry Pi installer. # # Automates the Linux-side Software Installation steps from the wiki: # camera overlay, OpenCV, vision/bluetooth/animator web UI, DualSense service, and # CODESYS SysProcess AllowAll when the runtime is already present. # # Does NOT install: Raspberry Pi OS, the CODESYS Windows IDE/runtime, # CODESYS licenses, or Arduino/Teensy firmware. # # curl always fetches this script from main so installer fixes ship without a # release. The clone itself defaults to the latest GitHub release tag (same # tree as the release source zip). Override with --ref / ARFBOT_REF: # --ref main tip of main # --ref v2.2026.37.1-AlarmHistory pin a release # # PLC Pi (recommended): image 64-bit Lite, Tools > Update Raspberry Pi (64 SL), # download ArfBot.project, then: # curl -sSL https://raw.githubusercontent.com/dalethomas81/ArfBotOS/main/scripts/install-pi.sh | bash # curl -sSL https://raw.githubusercontent.com/dalethomas81/ArfBotOS/main/scripts/install-pi.sh | bash -s -- --plc-only # # Dedicated vision Pi (no CODESYS): # curl -sSL https://raw.githubusercontent.com/dalethomas81/ArfBotOS/main/scripts/install-pi.sh | bash -s -- --vision-only # # From an existing checkout: # sudo ./scripts/install-pi.sh # sudo ./scripts/install-pi.sh --plc-only # sudo ./scripts/install-pi.sh --vision-only # # Does not block if the CODESYS runtime is missing; SysProcess is patched # when the cfg files exist. Safe to re-run. Existing cal.yaml / roi.yaml # are left alone. set -euo pipefail DEFAULT_REPO_URL="https://github.com/dalethomas81/ArfBotOS.git" # Always curl install-pi.sh from main. The clone ref is resolved later: # empty / "latest" -> GitHub releases/latest tag. INSTALLER_SCRIPT_REF="main" REPO_URL="${ARFBOT_REPO:-${DEFAULT_REPO_URL}}" REPO_REF="${ARFBOT_REF:-}" APPLICATION_DST="/var/opt/codesys/PlcLogic/Application" VISION_DST="${APPLICATION_DST}/Vision" WEB_DST="${APPLICATION_DST}/Web" CONTROLLER_DST="${APPLICATION_DST}/Controller" RELEASE_FILE="${APPLICATION_DST}/RELEASE" VENV_DIR="/opt/arfbot/venv" UDEV_RULES_DST="/etc/udev/rules.d/70-ps5-controller.rules" SCRIPT_FILE="" SCRIPT_DIR="" REPO_ROOT="" UDEV_RULES_SRC="" VISION_ONLY=0 PLC_ONLY=0 SKIP_VISION=0 SKIP_CAMERA=0 SKIP_CONTROLLER=0 SKIP_CODESYS=0 NO_START=0 DRY_RUN=0 FORCE=0 CHECK_ONLY=0 RECREATE_VENV=0 NEED_REBOOT=0 log() { printf '==> %s\n' "$*"; } warn() { printf 'WARN: %s\n' "$*" >&2; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } usage() { cat < Update Raspberry Pi (Raspberry Pi 64 SL) 3. Multiple Download of ArfBot.project 4. curl -sSL https://raw.githubusercontent.com/dalethomas81/ArfBotOS/${INSTALLER_SCRIPT_REF}/scripts/install-pi.sh | bash PLC Pi without vision (same order, then): curl -sSL https://raw.githubusercontent.com/dalethomas81/ArfBotOS/${INSTALLER_SCRIPT_REF}/scripts/install-pi.sh | bash -s -- --plc-only Dedicated vision Pi (64-bit Lite, no CODESYS): curl -sSL https://raw.githubusercontent.com/dalethomas81/ArfBotOS/${INSTALLER_SCRIPT_REF}/scripts/install-pi.sh | bash -s -- --vision-only If you ran this installer before the CODESYS runtime was present, run it again afterward so it can set SysProcess Command=AllowAll. It will not redo the rest from scratch. EOF } is_true() { [[ "${1}" -eq 1 ]]; } run() { if is_true "${DRY_RUN}"; then printf 'DRY-RUN: %s\n' "$*" return 0 fi printf '+ %s\n' "$*" "$@" } as_root() { if [[ "${EUID}" -eq 0 ]]; then "$@" else sudo "$@" fi } need_arg() { local opt="$1" local value="${2:-}" if [[ -z "${value}" || "${value}" == --* ]]; then die "${opt} requires a value" fi } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --plc-only) PLC_ONLY=1; SKIP_VISION=1; SKIP_CAMERA=1 ;; --vision-only) VISION_ONLY=1; SKIP_CONTROLLER=1; SKIP_CODESYS=1 ;; --skip-camera) SKIP_CAMERA=1 ;; --skip-controller) SKIP_CONTROLLER=1 ;; --skip-codesys) SKIP_CODESYS=1 ;; --repo) need_arg "$1" "${2:-}" REPO_URL="$2" shift ;; --ref) need_arg "$1" "${2:-}" REPO_REF="$2" shift ;; --no-start) NO_START=1 ;; --recreate-venv) RECREATE_VENV=1 ;; --force) FORCE=1 ;; --dry-run) DRY_RUN=1 ;; --check) CHECK_ONLY=1 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac shift done if is_true "${PLC_ONLY}" && is_true "${VISION_ONLY}"; then die "--plc-only and --vision-only cannot be used together" fi } github_repo_slug() { local url="$1" url="${url%.git}" url="${url%/}" if [[ "${url}" =~ github.com[:/]+([^/]+)/([^/]+) ]]; then printf '%s/%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" return 0 fi return 1 } parse_tag_name_from_release_json() { local json="$1" local tag="" if command -v python3 >/dev/null 2>&1; then tag="$(printf '%s' "${json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' 2>/dev/null || true)" fi if [[ -z "${tag}" ]]; then tag="$(printf '%s' "${json}" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" fi if [[ -z "${tag}" ]]; then return 1 fi printf '%s\n' "${tag}" } fetch_latest_release_tag() { local slug="" local json="" local tag="" local api="" slug="$(github_repo_slug "${REPO_URL}")" \ || die "cannot resolve latest release for ${REPO_URL}; pass --ref TAG or --ref main" api="https://api.github.com/repos/${slug}/releases/latest" if ! command -v curl >/dev/null 2>&1; then die "curl is required to resolve the latest release; pass --ref TAG or --ref main" fi json="$(curl -fsSL -A "ArfBotOS-install-pi" -H "Accept: application/vnd.github+json" "${api}")" \ || die "could not query GitHub releases for ${slug}; pass --ref TAG or --ref main" tag="$(parse_tag_name_from_release_json "${json}")" \ || die "GitHub latest-release response had no tag_name; pass --ref TAG or --ref main" printf '%s\n' "${tag}" } resolve_repo_ref() { local slug="" if [[ -n "${REPO_REF}" && "${REPO_REF}" != "latest" ]]; then return 0 fi slug="$(github_repo_slug "${REPO_URL}" || true)" log "resolving latest GitHub release${slug:+ for ${slug}}" REPO_REF="$(fetch_latest_release_tag)" log "using latest release ${REPO_REF}" export ARFBOT_REF="${REPO_REF}" } resolve_script_paths() { local source="${BASH_SOURCE[0]:-$0}" SCRIPT_FILE="" SCRIPT_DIR="" REPO_ROOT="" UDEV_RULES_SRC="" if [[ -n "${source}" && "${source}" != "-" && "${source}" != "bash" && -f "${source}" ]]; then SCRIPT_DIR="$(cd "$(dirname "${source}")" && pwd)" SCRIPT_FILE="${SCRIPT_DIR}/$(basename "${source}")" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" UDEV_RULES_SRC="${SCRIPT_DIR}/pi/70-ps5-controller.rules" fi } is_repo_checkout() { [[ -n "${REPO_ROOT}" ]] \ && [[ -f "${REPO_ROOT}/Controller/DualSenseServer.py" ]] \ && [[ -f "${REPO_ROOT}/OpenCV/SocketServer/PyServer.py" ]] \ && [[ -f "${UDEV_RULES_SRC}" ]] } install_mode() { if is_true "${VISION_ONLY}"; then printf '%s\n' "vision-only" elif is_true "${PLC_ONLY}"; then printf '%s\n' "plc-only" else printf '%s\n' "full" fi } require_repo_files() { local missing=0 local required=() if ! is_true "${SKIP_VISION}"; then required+=( "${REPO_ROOT}/OpenCV/SocketServer/PyServer.py" "${REPO_ROOT}/OpenCV/ImageCapture/CaptureImage.py" "${REPO_ROOT}/OpenCV/FastTemplateMatching/FastTemplateMatching.py" "${REPO_ROOT}/OpenCV/FastTemplateMatching/roi.yaml" "${REPO_ROOT}/OpenCV/CameraCalibration/CalibrateCamera.py" "${REPO_ROOT}/OpenCV/CameraCalibration/cal.yaml" ) fi if needs_web; then required+=( "${REPO_ROOT}/Web/wsgi.py" "${REPO_ROOT}/Web/config.py" "${REPO_ROOT}/Web/app/__init__.py" "${REPO_ROOT}/Web/app/vision.py" "${REPO_ROOT}/Web/app/bluetooth.py" "${REPO_ROOT}/Web/app/bluetoothctl_wrapper.py" "${REPO_ROOT}/Web/app/animator.py" "${REPO_ROOT}/Web/app/static/css/arfbot-night.css" "${REPO_ROOT}/Web/app/static/js/animator-page.js" "${REPO_ROOT}/Web/app/templates/base.html" "${REPO_ROOT}/Web/app/templates/bluetooth/index.html" "${REPO_ROOT}/Web/app/templates/vision/template.html" "${REPO_ROOT}/Web/app/templates/animator/index.html" "${REPO_ROOT}/Codesys/Html5Controls/RobotAnimator/ElementWrapper.js" ) fi if ! is_true "${SKIP_CONTROLLER}"; then required+=( "${UDEV_RULES_SRC}" "${REPO_ROOT}/Controller/DualSenseServer.py" "${REPO_ROOT}/Controller/DualSenseClient.py" ) fi if [[ ${#required[@]} -eq 0 ]]; then return 0 fi local f for f in "${required[@]}"; do if [[ ! -f "${f}" ]]; then warn "missing ${f}" missing=1 fi done if [[ "${missing}" -ne 0 ]]; then die "this does not look like a complete ArfBotOS checkout (REPO_ROOT=${REPO_ROOT})" fi } invoking_user() { local user="${SUDO_USER:-${USER:-}}" if [[ -z "${user}" || "${user}" == "root" ]]; then return 1 fi printf '%s\n' "${user}" } user_home() { local user="" local home="" user="$(invoking_user 2>/dev/null || true)" if [[ -n "${user}" ]] && command -v getent >/dev/null 2>&1; then home="$(getent passwd "${user}" | awk -F: '{print $6}')" fi if [[ -n "${home}" ]]; then printf '%s\n' "${home}" return 0 fi printf '%s\n' "${HOME}" } run_as_invoking_user() { local user="" user="$(invoking_user 2>/dev/null || true)" if [[ "${EUID}" -eq 0 && -n "${user}" ]]; then sudo -u "${user}" -H -- "$@" else "$@" fi } chown_to_invoking_user() { local path="$1" local user="" user="$(invoking_user 2>/dev/null || true)" if [[ -z "${user}" || ! -e "${path}" ]]; then return 0 fi if is_true "${DRY_RUN}"; then printf 'DRY-RUN: chown -R %s:%s %s\n' "${user}" "${user}" "${path}" return 0 fi chown -R "${user}:${user}" "${path}" || warn "could not chown ${path} to ${user}" } ensure_git() { if command -v git >/dev/null 2>&1; then return 0 fi log "installing git" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: apt-get install -y git\n' return 0 fi as_root apt-get update as_root apt-get install -y git } bootstrap_from_git() { local dest dest="$(user_home)/ArfBotOS" resolve_repo_ref export ARFBOT_REF="${REPO_REF}" log "not running from an ArfBotOS checkout; cloning ${REPO_URL} (${REPO_REF}) into ${dest}" ensure_git if is_true "${DRY_RUN}"; then printf 'DRY-RUN: git clone --depth 1 --branch %s %s %s\n' "${REPO_REF}" "${REPO_URL}" "${dest}" printf 'DRY-RUN: exec %s/scripts/install-pi.sh\n' "${dest}" die "dry-run from a pipe stops after the clone plan; omit --dry-run or run from a checkout" fi if is_true "${CHECK_ONLY}"; then die "--check needs a local checkout; clone the repo first or omit --check" fi if [[ -d "${dest}/.git" ]]; then log "updating existing clone ${dest}" chown_to_invoking_user "${dest}" run_as_invoking_user git -C "${dest}" fetch --depth 1 origin "${REPO_REF}" run_as_invoking_user git -C "${dest}" checkout -B "${REPO_REF}" FETCH_HEAD elif [[ -e "${dest}" ]]; then die "${dest} exists and is not an ArfBotOS git clone" else run_as_invoking_user git clone --depth 1 --branch "${REPO_REF}" "${REPO_URL}" "${dest}" fi chown_to_invoking_user "${dest}" local next="${dest}/scripts/install-pi.sh" if [[ ! -f "${next}" ]]; then die "clone is missing ${next} (check --ref ${REPO_REF})" fi chmod +x "${next}" || true # Write before exec so an older cloned installer still leaves the tag file. write_release_file log "re-executing ${next}" exec bash "${next}" "$@" } is_raspberry_pi() { local model="" if [[ -r /proc/device-tree/model ]]; then model="$(tr -d '\0' /dev/null 2>&1; then tag="$(git -C "${REPO_ROOT}" describe --tags --always --dirty 2>/dev/null || true)" fi if [[ -z "${tag}" ]]; then tag="unknown" fi printf '%s\n' "${tag}" } write_release_file() { local tag="" tag="$(release_tag)" tag="${tag%$'\n'}" log "writing release tag ${tag} to ${RELEASE_FILE}" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: printf %%s\\n %s > %s\n' "${tag}" "${RELEASE_FILE}" return 0 fi as_root mkdir -p "${APPLICATION_DST}" printf '%s\n' "${tag}" | as_root tee "${RELEASE_FILE}" >/dev/null as_root chmod 644 "${RELEASE_FILE}" chown_to_invoking_user "${RELEASE_FILE}" } copy_file() { local src="$1" local dst="$2" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: cp %s %s\n' "${src}" "${dst}" return 0 fi mkdir -p "$(dirname "${dst}")" cp -f "${src}" "${dst}" } copy_unless_exists() { local src="$1" local dst="$2" if [[ -e "${dst}" ]]; then log "keeping existing ${dst}" return 0 fi copy_file "${src}" "${dst}" } copy_dir_contents() { local src="$1" local dst="$2" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: copy dir %s -> %s\n' "${src}" "${dst}" return 0 fi mkdir -p "${dst}" local item local name for item in "${src}"/*; do [[ -e "${item}" ]] || continue name="$(basename "${item}")" case "${name}" in __pycache__|*.pyc|app.db|README.md|tests) continue ;; esac if [[ -d "${item}" ]]; then rm -rf "${dst}/${name}" cp -R "${item}" "${dst}/${name}" else cp -f "${item}" "${dst}/" fi done } backup_once() { local file="$1" [[ -f "${file}" ]] || return 0 if [[ -f "${file}.arfbot.bak" ]]; then return 0 fi run cp -f "${file}" "${file}.arfbot.bak" } boot_config_path() { if [[ -f /boot/firmware/config.txt ]]; then printf '%s\n' /boot/firmware/config.txt elif [[ -f /boot/config.txt ]]; then printf '%s\n' /boot/config.txt fi } configure_camera() { if is_true "${SKIP_CAMERA}"; then log "skipping camera overlay (--skip-camera/--plc-only)" return 0 fi local cfg cfg="$(boot_config_path || true)" if [[ -z "${cfg}" ]]; then warn "no /boot/firmware/config.txt or /boot/config.txt found; skipping camera overlay" return 0 fi log "configuring IMX477 overlay in ${cfg}" backup_once "${cfg}" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: set camera_auto_detect=0 and dtoverlay=imx477 in %s\n' "${cfg}" NEED_REBOOT=1 return 0 fi local result result="$(python3 - "${cfg}" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() original = text lines = text.splitlines() out = [] in_all = False saw_all = False auto_written = False overlay_present = any(l.strip().startswith("dtoverlay=imx477") for l in lines) def is_section(line: str) -> bool: s = line.strip() return s.startswith("[") and s.endswith("]") for line in lines: stripped = line.strip() if is_section(stripped): if in_all and not auto_written: out.append("camera_auto_detect=0") auto_written = True if not overlay_present and in_all: out.append("dtoverlay=imx477") overlay_present = True in_all = stripped.lower() == "[all]" if in_all: saw_all = True out.append(line) continue if stripped.startswith("camera_auto_detect="): out.append("camera_auto_detect=0") auto_written = True continue out.append(line) if in_all and not auto_written: out.append("camera_auto_detect=0") auto_written = True if in_all and not overlay_present: out.append("dtoverlay=imx477") overlay_present = True if not saw_all: if out and out[-1].strip() != "": out.append("") out.append("[all]") out.append("camera_auto_detect=0") if not overlay_present: out.append("dtoverlay=imx477") new = "\n".join(out) + "\n" if new != original: path.write_text(new) print("updated") else: print("unchanged") PY )" log "camera overlay ${result}" if [[ "${result}" == "updated" ]]; then NEED_REBOOT=1 fi } enable_i2c() { if is_true "${SKIP_CAMERA}"; then return 0 fi if command -v raspi-config >/dev/null 2>&1; then log "enabling I2C" run raspi-config nonint do_i2c 0 || warn "raspi-config failed to enable I2C" else warn "raspi-config not found; not enabling I2C via raspi-config" fi } apt_install_available() { local pkg local to_install=() if is_true "${DRY_RUN}"; then printf 'DRY-RUN: apt-get install -y %s\n' "$*" return 0 fi for pkg in "$@"; do if apt-cache show "${pkg}" >/dev/null 2>&1; then to_install+=("${pkg}") else warn "apt package not available: ${pkg}" fi done if [[ ${#to_install[@]} -eq 0 ]]; then warn "no apt packages from this group were available" return 0 fi run apt-get install -y "${to_install[@]}" } install_packages() { log "installing apt packages" export DEBIAN_FRONTEND=noninteractive run apt-get update apt_install_available python3 python3-venv python3-pip git python-is-python3 if ! is_true "${SKIP_VISION}"; then # Distro OpenCV, not pip opencv-python. The PLC launches vision with # `sudo python .../FastTemplateMatching.py`, which is system Python. # FastTemplateMatching needs OpenCV 4.5+ (TM_CCOEFF_NORMED, warpAffine, # pyrDown). Bookworm ships 4.6; Trixie ships 4.10. apt_install_available python3-opencv python3-numpy if ! is_true "${SKIP_CAMERA}"; then apt_install_available python3-picamera2 python3-libcamera i2c-tools fi fi # PLC vision commands are `sudo python ...`. Bookworm/Trixie provide this # via python-is-python3; keep a fallback if that package is missing. if ! command -v python >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then log "creating /usr/local/bin/python -> python3 for PLC SysProcess" if ! is_true "${DRY_RUN}"; then ln -sfn "$(command -v python3)" /usr/local/bin/python fi fi if ! is_true "${SKIP_CONTROLLER}"; then apt_install_available libhidapi-dev libhidapi-hidraw0 libhidapi-libusb0 rfkill bluez bluetooth fi } needs_web() { ! is_true "${SKIP_VISION}" || ! is_true "${SKIP_CONTROLLER}" } needs_venv() { needs_web } ensure_venv() { if ! needs_venv; then log "skipping Python venv (no vision or controller components requested)" return 0 fi if is_true "${RECREATE_VENV}" && [[ -d "${VENV_DIR}" ]]; then log "removing existing venv (${VENV_DIR})" run rm -rf "${VENV_DIR}" fi if [[ -d "${VENV_DIR}" ]]; then if [[ -f "${VENV_DIR}/pyvenv.cfg" ]] && grep -q 'include-system-site-packages = false' "${VENV_DIR}/pyvenv.cfg"; then warn "${VENV_DIR} was created without system site packages; cv2/picamera2 may fail. Re-run with --recreate-venv" else log "using existing venv ${VENV_DIR}" fi else log "creating venv with system site packages at ${VENV_DIR}" run python3 -m venv --system-site-packages "${VENV_DIR}" fi local pip="${VENV_DIR}/bin/pip" if is_true "${DRY_RUN}"; then if needs_web; then printf 'DRY-RUN: %s install flask\n' "${pip}" fi if ! is_true "${SKIP_CONTROLLER}"; then printf 'DRY-RUN: %s install git+https://github.com/flok/pydualsense.git\n' "${pip}" fi return 0 fi if needs_web; then "${pip}" install flask fi # Never pip-install opencv-python here. It shadows apt python3-opencv and # is the wrong install for `sudo python` on the Pi. if ! is_true "${SKIP_CONTROLLER}"; then if ! "${pip}" install "git+https://github.com/flok/pydualsense.git"; then warn "pydualsense from GitHub failed; falling back to PyPI" "${pip}" install pydualsense fi fi } deploy_vision_files() { if is_true "${SKIP_VISION}"; then log "skipping vision files (--plc-only)" return 0 fi log "deploying vision files to ${VISION_DST}" if ! is_true "${DRY_RUN}"; then mkdir -p "${VISION_DST}/Templates" else printf 'DRY-RUN: mkdir -p %s/Templates\n' "${VISION_DST}" fi copy_file "${REPO_ROOT}/OpenCV/SocketServer/PyServer.py" "${VISION_DST}/PyServer.py" copy_file "${REPO_ROOT}/OpenCV/ImageCapture/CaptureImage.py" "${VISION_DST}/CaptureImage.py" copy_file "${REPO_ROOT}/OpenCV/FastTemplateMatching/FastTemplateMatching.py" "${VISION_DST}/FastTemplateMatching.py" copy_file "${REPO_ROOT}/OpenCV/CameraCalibration/CalibrateCamera.py" "${VISION_DST}/CalibrateCamera.py" copy_unless_exists "${REPO_ROOT}/OpenCV/CameraCalibration/cal.yaml" "${VISION_DST}/cal.yaml" copy_unless_exists "${REPO_ROOT}/OpenCV/FastTemplateMatching/roi.yaml" "${VISION_DST}/roi.yaml" if ! is_true "${DRY_RUN}"; then : > "${VISION_DST}/VisionLog.txt" : > "${VISION_DST}/VisionErrorLog.txt" chmod 644 "${VISION_DST}/"*".py" "${VISION_DST}/cal.yaml" "${VISION_DST}/roi.yaml" 2>/dev/null || true fi } deploy_web_files() { if ! needs_web; then log "skipping web files (no vision or bluetooth components requested)" return 0 fi log "deploying web files to ${WEB_DST}" if ! is_true "${DRY_RUN}"; then mkdir -p "${WEB_DST}" else printf 'DRY-RUN: mkdir -p %s\n' "${WEB_DST}" fi copy_dir_contents "${REPO_ROOT}/Web" "${WEB_DST}" copy_file \ "${REPO_ROOT}/Codesys/Html5Controls/RobotAnimator/ElementWrapper.js" \ "${WEB_DST}/app/static/js/ElementWrapper.js" } deploy_controller_files() { if is_true "${SKIP_CONTROLLER}"; then log "skipping DualSense controller (--skip-controller/--vision-only)" return 0 fi log "deploying controller files to ${CONTROLLER_DST}" if ! is_true "${DRY_RUN}"; then mkdir -p "${CONTROLLER_DST}" : > "${CONTROLLER_DST}/ControllerLog.txt" : > "${CONTROLLER_DST}/ControllerErrorLog.txt" fi copy_file "${REPO_ROOT}/Controller/DualSenseServer.py" "${CONTROLLER_DST}/DualSenseServer.py" copy_file "${REPO_ROOT}/Controller/DualSenseClient.py" "${CONTROLLER_DST}/DualSenseClient.py" copy_file "${UDEV_RULES_SRC}" "${UDEV_RULES_DST}" if ! is_true "${DRY_RUN}"; then mkdir -p /etc/modprobe.d cat > /etc/modprobe.d/bluetooth-arfbot.conf <<'EOF' # DualSense/DualShock HID connections fail with ERTM on many BlueZ/Linux stacks. options bluetooth disable_ertm=Y EOF if [[ -w /sys/module/bluetooth/parameters/disable_ertm ]]; then echo Y > /sys/module/bluetooth/parameters/disable_ertm || warn "could not set disable_ertm at runtime" fi fi if command -v udevadm >/dev/null 2>&1; then run udevadm control --reload-rules || warn "udevadm reload failed" run udevadm trigger || warn "udevadm trigger failed" fi if command -v rfkill >/dev/null 2>&1; then log "unblocking Bluetooth (Lite images often rfkill-block hci0)" run rfkill unblock bluetooth || warn "rfkill unblock bluetooth failed" fi } write_unit() { local dest="$1" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: write systemd unit %s\n' "${dest}" return 0 fi cat > "${dest}" } install_systemd_units() { local python_bin="${VENV_DIR}/bin/python" local flask_bin="${VENV_DIR}/bin/flask" local units=() log "writing systemd units" if ! is_true "${SKIP_VISION}"; then write_unit /etc/systemd/system/PyServer.service </dev/null 2>&1; then run systemctl daemon-reload run systemctl enable "${units[@]}" if ! is_true "${NO_START}"; then run systemctl restart "${units[@]}" || warn "one or more services failed to start (see systemctl status)" else log "services enabled but not started (--no-start)" fi else warn "systemctl not found; systemd units were written but not enabled" fi } upsert_ini() { local file="$1" local section="$2" local key="$3" local value="$4" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: set [%s] %s=%s in %s\n' "${section}" "${key}" "${value}" "${file}" return 0 fi python3 - "${file}" "${section}" "${key}" "${value}" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) section, key, value = sys.argv[2], sys.argv[3], sys.argv[4] lines = path.read_text().splitlines() if path.exists() else [] out = [] in_section = False section_found = False key_written = False header = f"[{section}]" def is_section(line: str) -> bool: s = line.strip() return s.startswith("[") and s.endswith("]") for line in lines: stripped = line.strip() if is_section(stripped): if in_section and not key_written: out.append(f"{key}={value}") key_written = True in_section = stripped.lower() == header.lower() if in_section: section_found = True out.append(line) continue if in_section and stripped.lower().startswith(key.lower() + "="): out.append(f"{key}={value}") key_written = True continue out.append(line) if in_section and not key_written: out.append(f"{key}={value}") key_written = True if not section_found: if out and out[-1].strip() != "": out.append("") out.append(header) out.append(f"{key}={value}") path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(out) + "\n") PY } configure_codesys() { if is_true "${SKIP_CODESYS}"; then log "skipping CODESYS SysProcess config" return 0 fi local candidates=( /etc/CODESYSControl.cfg /etc/CODESYSControl_User.cfg /etc/codesyscontrol/CODESYSControl.cfg /etc/codesyscontrol/CODESYSControl_User.cfg ) local found=0 local cfg for cfg in "${candidates[@]}"; do if [[ -f "${cfg}" ]]; then found=1 log "setting SysProcess Command=AllowAll in ${cfg}" backup_once "${cfg}" upsert_ini "${cfg}" SysProcess Command AllowAll fi done if [[ "${found}" -eq 0 ]]; then warn "CODESYS runtime config not found. Recommended: Tools > Update Raspberry Pi first, then this installer, so SysProcess is set in one pass. If you already ran this script, run it again after the runtime is present — it will only add that setting." fi } chown_deploy_tree() { local home="" if [[ -e "${RELEASE_FILE}" ]]; then chown_to_invoking_user "${RELEASE_FILE}" fi if ! is_true "${SKIP_VISION}"; then chown_to_invoking_user "${VISION_DST}" fi if needs_web; then chown_to_invoking_user "${WEB_DST}" fi if ! is_true "${SKIP_CONTROLLER}"; then chown_to_invoking_user "${CONTROLLER_DST}" fi home="$(user_home)" if [[ -n "${REPO_ROOT}" && -n "${home}" && "${REPO_ROOT}" == "${home}"* ]]; then chown_to_invoking_user "${REPO_ROOT}" fi } venv_python() { if [[ -x "${VENV_DIR}/bin/python" ]]; then printf '%s\n' "${VENV_DIR}/bin/python" else printf '%s\n' python3 fi } verify_import() { local py="$1" local expr="$2" local label="$3" if is_true "${DRY_RUN}"; then printf 'DRY-RUN: %s -c %s\n' "${py}" "${expr}" return 0 fi if "${py}" -c "${expr}"; then log "ok: ${label}" else warn "failed: ${label}" return 1 fi } print_service_status() { local unit="$1" if ! command -v systemctl >/dev/null 2>&1; then return 0 fi if is_true "${DRY_RUN}"; then printf 'DRY-RUN: systemctl status %s\n' "${unit}" return 0 fi systemctl --no-pager --full status "${unit}" || true } verify_install() { log "verifying installation" local py py="$(venv_python)" local failed=0 if ! is_true "${SKIP_VISION}"; then local opencv_check opencv_check="$(cat <<'PY' import cv2, numpy ver = tuple(int(x) for x in cv2.__version__.split('.')[:2]) if ver < (4, 5): raise SystemExit('OpenCV %s is too old; need 4.5+' % cv2.__version__) if not hasattr(cv2, 'TM_CCOEFF_NORMED'): raise SystemExit('cv2.TM_CCOEFF_NORMED missing') img = numpy.zeros((32, 32), dtype=numpy.uint8) templ = numpy.zeros((8, 8), dtype=numpy.uint8) res = cv2.matchTemplate(img, templ, cv2.TM_CCOEFF_NORMED) print('cv2', cv2.__version__, 'numpy', numpy.__version__, 'match', res.shape) PY )" verify_import "${py}" "${opencv_check}" "OpenCV (venv)" || failed=1 # PLC SysProcess uses `sudo python`, not the venv. local sys_py="python3" if command -v python >/dev/null 2>&1; then sys_py="python" fi verify_import "${sys_py}" "${opencv_check}" "OpenCV (system ${sys_py}, PLC path)" || failed=1 if ! is_true "${SKIP_CAMERA}"; then verify_import "${py}" "from picamera2 import Picamera2; print('picamera2 ok')" "picamera2" || failed=1 verify_import "${sys_py}" "from picamera2 import Picamera2; print('picamera2 ok')" "picamera2 (system ${sys_py})" || failed=1 fi verify_import "${py}" "import flask; print('flask ok')" "Flask" || failed=1 local cv2_path="" cv2_path="$("${py}" -c "import cv2; print(getattr(cv2, '__file__', ''))" 2>/dev/null || true)" if printf '%s\n' "${cv2_path}" | grep -qi 'site-packages'; then warn "cv2 is coming from a pip wheel (${cv2_path}), not apt python3-opencv. Uninstall opencv-python and keep the distro package." fi fi if is_true "${SKIP_VISION}" && ! is_true "${SKIP_CONTROLLER}"; then verify_import "${py}" "import flask; print('flask ok')" "Flask" || failed=1 fi if ! is_true "${SKIP_CONTROLLER}"; then verify_import "${py}" "from pydualsense import pydualsense; print('pydualsense ok')" "pydualsense" || failed=1 fi if is_true "${DRY_RUN}"; then log "dry-run: skipping destination file checks" elif [[ -s "${RELEASE_FILE}" ]]; then log "ok: ${RELEASE_FILE} ($(tr -d '\n' < "${RELEASE_FILE}"))" else warn "missing ${RELEASE_FILE}" failed=1 fi if is_true "${DRY_RUN}"; then : elif ! is_true "${SKIP_VISION}"; then local required_files=( "${VISION_DST}/PyServer.py" "${VISION_DST}/CaptureImage.py" "${VISION_DST}/FastTemplateMatching.py" "${VISION_DST}/CalibrateCamera.py" "${VISION_DST}/cal.yaml" "${VISION_DST}/roi.yaml" ) local f for f in "${required_files[@]}"; do if [[ -f "${f}" ]]; then log "ok: ${f}" else warn "missing ${f}" failed=1 fi done fi if needs_web && ! is_true "${DRY_RUN}"; then if [[ -f "${WEB_DST}/wsgi.py" ]]; then log "ok: ${WEB_DST}/wsgi.py" else warn "missing ${WEB_DST}/wsgi.py" failed=1 fi if [[ -f "${WEB_DST}/app/static/js/ElementWrapper.js" ]]; then log "ok: ${WEB_DST}/app/static/js/ElementWrapper.js" else warn "missing ${WEB_DST}/app/static/js/ElementWrapper.js" failed=1 fi if [[ -f "${WEB_DST}/app/animator.py" ]]; then log "ok: ${WEB_DST}/app/animator.py" else warn "missing ${WEB_DST}/app/animator.py" failed=1 fi fi if command -v systemctl >/dev/null 2>&1 && ! is_true "${DRY_RUN}"; then if ! is_true "${SKIP_VISION}"; then print_service_status PyServer.service fi if needs_web; then print_service_status VisionWeb.service fi if ! is_true "${SKIP_CONTROLLER}"; then print_service_status DualSenseController.service fi fi return "${failed}" } print_next_steps() { local ip="" ip="$(hostname -I 2>/dev/null | awk '{print $1}')" || true cat <}:5000/vision EOF fi if needs_web; then cat <}:5000/animator EOF fi if ! is_true "${SKIP_CONTROLLER}"; then cat <}:5000/bluetooth EOF fi if is_true "${NEED_REBOOT}"; then cat <<'EOF' camera overlay: changed — reboot before using the camera: sudo reboot EOF fi if ! is_true "${SKIP_CODESYS}" && [[ ! -f /etc/CODESYSControl.cfg && ! -f /etc/codesyscontrol/CODESYSControl.cfg ]]; then cat < Update Raspberry Pi so it can set SysProcess Command=AllowAll. EOF fi if is_true "${VISION_ONLY}"; then cat <<'EOF' Vision-only Pi: set this host's IP on the Programming HMI vision settings. EOF fi cat <<'EOF' ---------------------------------------------------------------------- EOF } print_check_plan() { cat <