diff --git a/hardware/README.md b/hardware/README.md new file mode 100644 index 0000000..794653d --- /dev/null +++ b/hardware/README.md @@ -0,0 +1,29 @@ +# Board setup kits + +One directory per board the fleet runs on. A kit holds everything that turns a +freshly imaged machine into a tower host, and nothing that differs between two +towers built on the same board: + +``` +/ + packages.txt apt packages the build and the runtime need + preconditions/ udev rules, tmpfiles.d - the settings that must survive a reboot + install.sh apply the preconditions, install the packages, probe the + machine and emit a starting hardware.ini + NOTES.md quirks found on this board, and when they were last verified +``` + +`install.sh` fills in what a machine can be *asked* - board model, the motor +MCU's by-id path, the camera's `DEV_` id - and leaves to a human what no probe +can distinguish from a fault: which axes the gimbal has, whether an IMU or an +ambient sensor was fitted, and the drivetrain figures. See +[docs/hardware-config-plan.md](../docs/hardware-config-plan.md) §8 and §10. + +The file it writes is `/etc/fire_gimbal_control/hardware.ini`, deliberately +outside the directory `deploy.sh` rsyncs: a deploy cannot overwrite what it never +touches. + +| Kit | Board | State | +|-----|-------|-------| +| [lattepanda-iota/](lattepanda-iota/) | LattePanda IOTA (N150, RP2040 MCU) | in use on FWT tower 10.11.12.121 | +| lattepanda-3-delta/ | LattePanda 3 Delta (N5105, ATmega32U4 MCU) | not written - the Delta fleet predates the kits | diff --git a/hardware/lattepanda-iota/install.sh b/hardware/lattepanda-iota/install.sh new file mode 100755 index 0000000..2547a00 --- /dev/null +++ b/hardware/lattepanda-iota/install.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +# +# install.sh - turn a freshly imaged LattePanda IOTA into a fire-watch tower host. +# +# Run it ON the device: +# sudo ./install.sh # everything: deps, preconditions, hardware.ini +# sudo ./install.sh --deps # apt packages only +# sudo ./install.sh --preconditions # udev + tmpfiles only, applied immediately +# ./install.sh --probe # print what this machine looks like, change nothing +# sudo ./install.sh --vimba ~/VimbaX_2025-3 # install a staged Vimba X SDK +# ./install.sh --stdout # print the hardware.ini it would write +# +# What it can work out for itself: the board, the motor MCU's stable by-id path, +# the camera's DEV_ id, whether the USB preconditions took. What it cannot, and +# takes from flags, is what was ASSEMBLED - a missing pitch axis and a broken +# pitch axis look identical to a probe: +# +# --axes yaw (default: yaw,pitch) which axes the gimbal has +# --imu none (default: none) IMU model, or none +# --env none (default: none) ambient sensor model, or none +# --cameras rgb (default: rgb) expected [Camera.] blocks +# +# Other flags: +# --force overwrite an existing hardware.ini (backs it up first) +# --no-apt-update skip 'apt-get update' +# +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ETC_DIR=/etc/fire_gimbal_control +HW_FILE="$ETC_DIR/hardware.ini" + +AXES="yaw,pitch" +IMU="none" +ENV_SENSOR="none" +CAMERAS="rgb" +DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1 +VIMBA_SRC="" +EXPLICIT=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --deps) DO_DEPS=1; EXPLICIT=1 ;; + --preconditions) DO_PRE=1; EXPLICIT=1 ;; + --hardware-ini) DO_HW=1; EXPLICIT=1 ;; + --probe) PROBE_ONLY=1; EXPLICIT=1 ;; + --stdout) TO_STDOUT=1; DO_HW=1; EXPLICIT=1 ;; + --axes) AXES="$2"; shift ;; + --imu) IMU="$2"; shift ;; + --env) ENV_SENSOR="$2"; shift ;; + --cameras) CAMERAS="$2"; shift ;; + --vimba) VIMBA_SRC="$2"; DO_VIMBA=1; EXPLICIT=1; shift ;; + --force) FORCE=1 ;; + --no-apt-update) APT_UPDATE=0 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 1 ;; + esac + shift +done +if [[ $EXPLICIT -eq 0 ]]; then DO_DEPS=1; DO_PRE=1; DO_HW=1; fi + +say() { printf '>> %s\n' "$*"; } +warn() { printf '!! %s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +need_root() { [[ $EUID -eq 0 ]] || die "$1 needs root - re-run with sudo"; } + +# ---------------------------------------------------------------- probing --- +# Board, from DMI. A kit applied to the wrong board is a real mistake, not a +# nuisance: the pin map and the device paths below are IOTA-specific. +probe_board() { + local dmi=/sys/class/dmi/id/product_name + [[ -r $dmi ]] || { echo "unknown"; return; } + case "$(tr -d '\0' < "$dmi")" in + *IOTA*|*Iota*|*iota*) echo "lattepanda-iota" ;; + *"3 Delta"*) echo "lattepanda-3-delta" ;; + *Sigma*) echo "lattepanda-sigma" ;; + *) echo "unknown" ;; + esac +} + +# The motor MCU's stable path. /dev/ttyACM0 is enumeration order and moves when +# any other USB serial device appears - and this tower has a 5G modem that +# claims three ttyUSB nodes. Match on what the MCU calls itself: +# RP2040 running the gimbal firmware (arduino-pico) : "Raspberry_Pi_Pico" +# RP2040 as shipped, still on MicroPython : "MicroPython_Board" +# ATmega32U4 (Delta fleet) : "Arduino_LLC_Arduino_Leonardo" +probe_mcu() { + local d found=() + for d in /dev/serial/by-id/*; do + [[ -e $d ]] || continue + case "$d" in + *Raspberry_Pi_Pico*|*MicroPython_Board*|*Arduino_LLC_Arduino*|*Pico*) found+=("$d") ;; + esac + done + (( ${#found[@]} == 1 )) && { echo "${found[0]}"; return; } + (( ${#found[@]} == 0 )) && { echo ""; return; } + printf '%s\n' "${found[@]}" +} + +# The camera's DEV_ id, which is what [Camera.] id takes. Only Vimba knows +# it - it is NOT the USB serial number - so this needs the SDK installed. +# +# Two traps, both hit on tower 121: +# - The example is called ListCameras_VmbC, not ListCameras. +# - The SDK ships a Camera Simulator transport layer that is enabled by +# default and reports three cameras that do not exist (DEV_Cam1..3). Ids are +# therefore taken only from a block whose transport layer is the USB or GigE +# TL. A tower configured with DEV_Cam1 would "find its camera" and capture +# synthetic images forever. +probe_camera_id() { + local tool + for tool in /opt/VimbaX*/bin/ListCameras_VmbC /opt/VimbaX*/bin/ListCameras; do + [[ -x $tool ]] || continue + # Non-interactive shells do not source /etc/profile.d, so the GenTL path + # has to be set here or VmbStartup() fails with error -16 (no transport + # layer) on a perfectly good installation. + local cti + for cti in "${GENICAM_GENTL64_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do + [[ -d $cti ]] && { export GENICAM_GENTL64_PATH="$cti"; break; } + done + "$tool" 2>/dev/null | awk ' + /Camera ID/ { id = $NF } + /Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) { print id; exit } } + ' + return + done + echo "" +} + +probe_report() { + local board mcu cam + board="$(probe_board)"; mcu="$(probe_mcu)"; cam="$(probe_camera_id)" + echo "board : $board" + echo "os : $(. /etc/os-release && echo "$PRETTY_NAME") ($(uname -r))" + echo "motor MCU : ${mcu:-NOT FOUND}" + echo "camera DEV_ id : ${cam:-NOT FOUND (needs the Vimba X SDK installed)}" + echo "alvium on usb : $(lsusb 2>/dev/null | grep -c '1ab2:' || true) device(s)" + echo "usbfs_memory_mb : $(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?') (want >= 1000)" + local p + for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do + [[ -r $p ]] || continue + local dev; dev="$(basename "$(dirname "$(dirname "$p")")")" + local vid; vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)" + [[ $vid == 1ab2 ]] && echo "camera lpm_permit: $(cat "$p") (want 0)" + done + echo "i2c buses : $(ls /dev/i2c-* 2>/dev/null | tr '\n' ' ')" + echo "ttyS : $(ls /dev/ttyS[0-9] 2>/dev/null | tr '\n' ' ')" +} + +# ------------------------------------------------------------------- deps --- +do_deps() { + need_root "--deps" + local pkgs=() + while read -r line; do + line="${line%%#*}"; line="$(echo "$line" | tr -d '[:space:]')" + [[ -n $line ]] && pkgs+=("$line") + done < "$HERE/packages.txt" + say "installing ${#pkgs[@]} packages" + [[ $APT_UPDATE -eq 1 ]] && apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkgs[@]}" + say "packages done" +} + +# ---------------------------------------------------------- preconditions --- +do_preconditions() { + need_root "--preconditions" + install -m 0644 "$HERE/preconditions/99-fwt-alvium-usb.rules" /etc/udev/rules.d/ + install -m 0644 "$HERE/preconditions/99-fwt-rp2040.rules" /etc/udev/rules.d/ + install -m 0644 "$HERE/preconditions/10-fwt-usbfs.conf" /etc/tmpfiles.d/ + say "installed 2 udev rules + tmpfiles.d entry" + + udevadm control --reload + udevadm trigger --subsystem-match=usb --action=add + systemd-tmpfiles --create /etc/tmpfiles.d/10-fwt-usbfs.conf + say "applied to the running system" + + # Report rather than assume: a rule that did not take is the whole point of + # known-issues #19, and it fails quietly. + local now; now="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')" + [[ $now == 1000 ]] && say "usbfs_memory_mb = $now" || warn "usbfs_memory_mb is $now, expected 1000" + local p any=0 + for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do + [[ -r $p ]] || continue + local dev vid; dev="$(basename "$(dirname "$(dirname "$p")")")" + vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)" + [[ $vid == 1ab2 ]] || continue + any=1 + [[ "$(cat "$p")" == 0 ]] && say "camera USB3 LPM disabled ($dev)" \ + || warn "camera USB3 LPM still $(cat "$p") on $dev" + done + [[ $any -eq 0 ]] && warn "no Alvium camera on the bus - LPM rule untested until one is plugged in" + + # The MCU rule is what makes the firmware flashable over ssh, so check it the + # same way rather than trusting that the rule took. + local mcu_dev + mcu_dev="$(grep -l '^2e8a$' /sys/bus/usb/devices/*/idVendor 2>/dev/null | head -1)" + if [[ -n $mcu_dev ]]; then + local devnum busnum node + busnum="$(cat "$(dirname "$mcu_dev")/busnum" 2>/dev/null)" + devnum="$(cat "$(dirname "$mcu_dev")/devnum" 2>/dev/null)" + node="$(printf '/dev/bus/usb/%03d/%03d' "$busnum" "$devnum")" + if [[ -w $node ]]; then + say "MCU (2e8a) writable at $node - picotool can flash it" + else + warn "MCU (2e8a) at $node is not writable; flashing will need sudo" + ls -l "$node" >&2 + fi + else + warn "no 2e8a MCU on the bus - the flashing rule is untested until one is plugged in" + fi + return 0 +} + +# ------------------------------------------------------------------ vimba --- +# The Vimba X SDK is proprietary and login-walled, so it cannot be fetched by a +# script. Stage it on the device first (rsync it from a machine that has it), +# then point this at the staged directory: +# +# rsync -a ~/Programs/VimbaX_2025-3 ggs@:~/ # from the dev box +# sudo ./install.sh --vimba ~ggs/VimbaX_2025-3 # on the tower +# +do_vimba() { + need_root "--vimba" + [[ -d $VIMBA_SRC ]] || die "--vimba: no such directory: $VIMBA_SRC" + [[ -x $VIMBA_SRC/cti/Install_GenTL_Path.sh ]] || die "--vimba: $VIMBA_SRC does not look like a Vimba X SDK (no cti/Install_GenTL_Path.sh)" + + local name dest + name="$(basename "$VIMBA_SRC")" + dest="/opt/$name" + if [[ -d $dest && $FORCE -ne 1 ]]; then + say "$dest already present - not re-copying (--force to replace)" + else + say "installing $name to $dest" + rm -rf "$dest.new" + cp -a "$VIMBA_SRC" "$dest.new" + rm -rf "$dest" + mv "$dest.new" "$dest" + fi + # cp -a preserves the staging copy's ownership, which is the ggs user. The + # SDK is read-only system software and belongs to root. + chown -R root:root "$dest" + ln -sfn "$dest" /opt/VimbaX + say "/opt/VimbaX -> $dest" + + # GENICAM_GENTL64_PATH, or VmbStartup() fails with "Could not start Vimba X + # API". The SDK's own installer writes /etc/profile.d + the 1ab2 udev rule. + "$dest/cti/Install_GenTL_Path.sh" + + # Non-interactive ssh shells do not source /etc/profile.d, which is why + # deploy.sh resolves the path itself - but the loader needs the libraries + # findable regardless of how the app was started. + echo "$dest/api/lib" > /etc/ld.so.conf.d/vimbax.conf + ldconfig + say "registered $dest/api/lib with the dynamic loader" + + udevadm control --reload && udevadm trigger --subsystem-match=usb --action=add + + local id + id="$(probe_camera_id)" + if [[ -n $id ]]; then + say "camera found: $id" + else + warn "no camera reported by ListCameras yet" + warn "the SDK asks for a reboot before the transport layers are usable" + fi + return 0 +} + +# ------------------------------------------------------------ hardware.ini --- +emit_hardware_ini() { + local board mcu cam fw_env + board="$(probe_board)" + mcu="$(probe_mcu)" + cam="$(probe_camera_id)" + case "$board" in + lattepanda-iota) fw_env="iota_rp2040" ;; + lattepanda-3-delta|lattepanda-sigma) fw_env="leonardo" ;; + *) fw_env="UNKNOWN" ;; + esac + if [[ $(wc -l <<< "$mcu") -gt 1 ]]; then + warn "several candidate MCU ports found; pick one by hand:" + warn "$mcu" + mcu="CHOOSE_ONE" + fi + + cat <`. +; The IOTA exposes no PCH-side I2C on the header, so this is normally a USB-I2C +; bridge (MCP2221A), whose bus number depends on enumeration order. +i2c_device = SET_THIS_BY_HAND +i2c_addr = 0x44 +EOF + fi + + local c + for c in ${CAMERAS//,/ }; do + c="$(echo "$c" | tr -d '[:space:]')" + cat <&2 + return + fi + if [[ -f $HW_FILE ]]; then + cp -p "$HW_FILE" "$HW_FILE.bak-$(date +%Y%m%d-%H%M%S)" + say "backed the previous hardware.ini up" + fi + emit_hardware_ini > "$HW_FILE" + chmod 0644 "$HW_FILE" + say "wrote $HW_FILE" + grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" >/dev/null 2>&1 && { + warn "it still has placeholders - edit them before starting the app:" + grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" | sed 's/^/ /' >&2 + } + return 0 +} + +# ------------------------------------------------------------------- main --- +board="$(probe_board)" +if [[ $board != lattepanda-iota ]]; then + warn "this kit is for the LattePanda IOTA; this machine reports '$board'" + [[ $PROBE_ONLY -eq 1 ]] || die "refusing to apply IOTA preconditions to a $board" +fi + +if [[ $PROBE_ONLY -eq 1 ]]; then probe_report; exit 0; fi + +# --stdout has to emit the file and nothing else: its whole purpose is being +# redirected into one. The banner belongs on stderr in that case, or it lands in +# the middle of the hardware.ini somebody just generated. +if [[ $TO_STDOUT -eq 1 ]]; then + do_hardware_ini + exit 0 +fi +say "LattePanda IOTA setup kit" +probe_report | sed 's/^/ /' +# if/fi rather than '[[ ]] && cmd': under `set -e` a false test as the last +# command of an AND list exits the script. +if [[ $DO_DEPS -eq 1 ]]; then do_deps; fi +if [[ $DO_VIMBA -eq 1 ]]; then do_vimba; fi +if [[ $DO_PRE -eq 1 ]]; then do_preconditions; fi +if [[ $DO_HW -eq 1 ]]; then do_hardware_ini; fi +say "done" diff --git a/hardware/lattepanda-iota/packages.txt b/hardware/lattepanda-iota/packages.txt new file mode 100644 index 0000000..e66c8ba --- /dev/null +++ b/hardware/lattepanda-iota/packages.txt @@ -0,0 +1,30 @@ +# apt packages for a LattePanda IOTA tower host. One per line, '#' comments. +# Consumed by install.sh --deps. + +# --- build toolchain ----------------------------------------------------- +build-essential +cmake +git +pkg-config + +# --- fire_gimbal_control build deps -------------------------------------- +# Paho MQTT is fetched by cmake; OpenCV, Boost and libjxl are not. +libopencv-dev +libboost-program-options-dev +libjxl-dev +libssl-dev + +# --- runtime ------------------------------------------------------------- +# tmux: scripts/fgc runs the app detached inside a session. +tmux +rsync + +# --- firmware build (PlatformIO runs ON the tower, see NOTES.md) --------- +python3-venv +python3-pip + +# --- diagnostics --------------------------------------------------------- +# i2c-tools: `i2cdetect -F` proves a bus is real I2C and not SMBus-only. +# usbutils: lsusb, for camera and MCU enumeration. +i2c-tools +usbutils diff --git a/hardware/lattepanda-iota/preconditions/10-fwt-usbfs.conf b/hardware/lattepanda-iota/preconditions/10-fwt-usbfs.conf new file mode 100644 index 0000000..8703caf --- /dev/null +++ b/hardware/lattepanda-iota/preconditions/10-fwt-usbfs.conf @@ -0,0 +1,12 @@ +# systemd-tmpfiles: raise the USB filesystem buffer for large camera frames. +# +# WHY: known-issues #19. usbcore.usbfs_memory_mb defaults to 16 MB, far below a +# single full-resolution frame, and reverts on every reboot. 1000 is the value +# the Allied Vision documentation asks for. +# +# 'w' writes the value if the file exists and never creates it, so this is inert +# on a machine without usbcore. Applied at boot by systemd-tmpfiles-setup. +# The kernel-cmdline alternative (usbcore.usbfs_memory_mb=1000 in GRUB) does the +# same job earlier; this one is chosen because it needs no bootloader edit and is +# undone by deleting one file. +w /sys/module/usbcore/parameters/usbfs_memory_mb - - - - 1000 diff --git a/hardware/lattepanda-iota/preconditions/99-fwt-alvium-usb.rules b/hardware/lattepanda-iota/preconditions/99-fwt-alvium-usb.rules new file mode 100644 index 0000000..2e224d5 --- /dev/null +++ b/hardware/lattepanda-iota/preconditions/99-fwt-alvium-usb.rules @@ -0,0 +1,16 @@ +# Alvium USB3 camera preconditions - fire-watch tower hosts. +# +# WHY: known-issues #19. On the camera's USB3 link the host re-enables hardware +# LPM (U1/U2) on every enumeration; the camera then logs link errors and frame +# delivery stalls. The setting lives on the PORT, not on the device, which is +# why this writes through the device's 'port' symlink rather than using ATTR{}. +# +# Verified on the IOTA 2026-09-15: /sys/bus/usb/devices/2-1/port -> +# ../2-0:1.0/usb2-port1, usb3_lpm_permit was 'u1_u2' out of the box. +# +# The MODE line duplicates what the Vimba X transport-layer installer writes to +# 99-AVTUSBTL.rules. Kept here so a host that has not run that installer yet +# still gets a camera it can open, and harmless where it has. + +ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1ab2", MODE="0666" +ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1ab2", RUN+="/bin/sh -c 'echo 0 > /sys%p/port/usb3_lpm_permit 2>/dev/null || true'" diff --git a/hardware/lattepanda-iota/preconditions/99-fwt-rp2040.rules b/hardware/lattepanda-iota/preconditions/99-fwt-rp2040.rules new file mode 100644 index 0000000..f1bab3d --- /dev/null +++ b/hardware/lattepanda-iota/preconditions/99-fwt-rp2040.rules @@ -0,0 +1,18 @@ +# RP2040 motor-controller MCU - fire-watch tower hosts (LattePanda IOTA). +# +# WHY: flashing the firmware needs write access to the board's USB device, and +# the default mode is root-only. Without this, picotool finds the board and then +# refuses it: +# +# RP2040 device at bus 1, address 4 appears to be in BOOTSEL mode, but +# picotool was unable to connect. Maybe try 'sudo' or check your permissions. +# +# Matching on the vendor alone covers every mode the board appears in, which is +# the point - it changes identity as it is flashed: +# 2e8a:0003 RP2 Boot BOOTSEL / mass storage, what picotool writes to +# 2e8a:0005 MicroPython the image these boards ship with +# 2e8a:000a Pico SDK CDC the gimbal firmware, once flashed +# +# uaccess covers a logged-in user; the 0666 mode covers a plain ssh session, +# which is how a tower is actually reached. +SUBSYSTEM=="usb", ATTRS{idVendor}=="2e8a", MODE="0666", TAG+="uaccess" diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py new file mode 100755 index 0000000..12f4f84 --- /dev/null +++ b/scripts/export_camera_profile.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +export_camera_profile.py - build a camera profile XML on the tower, headlessly. + +The documented way to make a profile is the VimbaX Viewer: configure the camera +in a GUI, save the settings XML. On a tower that is awkward - the machine is +headless and reached over ssh - and it is impossible during an unattended +install. This does the same job through the SDK's Python API: + + apply a recipe feature by feature, let the CAMERA validate each write, + then export what it actually accepted. + +That keeps the property the Viewer workflow is there to provide. GenICam +validity is stateful - AcquisitionFrameRate is only writable once +AcquisitionFrameRateEnable is set, Width's maximum depends on binning - so a +hand-written XML can be individually plausible and collectively wrong. Here +every value is written to the real camera in dependency order and read back, and +anything the camera refused is reported rather than silently kept. + +Usage, on the tower: + + GENICAM_GENTL64_PATH=/opt/VimbaX/cti ~/.venvs/vmbpy/bin/python \ + scripts/export_camera_profile.py --profile standard --out config/camera_profiles/rgb + + ... --dry-run report what would change, write nothing + ... --camera DEV_xxx pick a camera explicitly + +Needs vmbpy, which ships with the SDK: + + python3 -m venv ~/.venvs/vmbpy + ~/.venvs/vmbpy/bin/pip install /opt/VimbaX/api/python/vmbpy-*.whl +""" +import argparse +import os +import sys + +try: + import vmbpy +except ImportError: + sys.exit("vmbpy not installed - see the header of this file") + +# Recipes are ordered: binning changes the sensor geometry, and the throughput +# limit caps the frame rate, so both come before the rate is set. A feature the +# camera does not have is reported and skipped, not fatal: the fleet runs several +# Alvium generations and their feature vocabularies differ. +RECIPES = { + # Daylight. 2x2 binning is not optional at 10 Hz: unbinned RGB8 on a 16 MP + # sensor is a 48 MB frame, which is both over the USB ceiling in + # known-issues #19 and far too slow to reach 10 fps under any throughput + # limit this camera offers. + "standard": [ + ("BinningHorizontal", 2), + ("BinningVertical", 2), + ("PixelFormat", "RGB8"), + ("DeviceLinkThroughputLimit", 250000000), + ("AcquisitionFrameRateEnable", True), + ("AcquisitionFrameRate", 10.0), + ("ExposureAuto", "Continuous"), + ("ExposureAutoMin", 200.0), + ("ExposureAutoMax", 20000.0), + ("GainAuto", "Continuous"), + ("GainAutoMax", 24.0), + ("IntensityAutoPrecedence", "MinimizeBlur"), + ("DeviceIndicatorMode", "Active"), + ("DeviceIndicatorLuminance", 10), + ], + # Night: 1 Hz, long exposures, more gain allowed, noise over motion blur, + # and the status LED off so it cannot contaminate a long exposure. + "night": [ + ("BinningHorizontal", 2), + ("BinningVertical", 2), + ("PixelFormat", "RGB8"), + ("DeviceLinkThroughputLimit", 250000000), + ("AcquisitionFrameRateEnable", True), + ("AcquisitionFrameRate", 1.0), + ("ExposureAuto", "Continuous"), + ("ExposureAutoMin", 1000.0), + ("ExposureAutoMax", 500000.0), + ("GainAuto", "Continuous"), + ("GainAutoMax", 48.0), + ("IntensityAutoPrecedence", "MinimizeNoise"), + ("DeviceIndicatorMode", "Inactive"), + ("DeviceIndicatorLuminance", 0), + ], +} + + +def apply_feature(cam, name, value, dry_run): + try: + feat = cam.get_feature_by_name(name) + except Exception: + return ("missing", name, None, value) + try: + before = feat.get() + except Exception: + before = None + + # Clamp a numeric value the camera cannot reach, rather than letting the + # write fail and leaving whatever was there before. Without this, exporting + # 'standard' right after 'night' wrote a standard profile running at the + # night rate: the 10 fps write was refused and 1 fps simply stayed. A + # refused write is not a no-op, it is a silently inherited value. + clamped = False + if isinstance(value, (int, float)) and not isinstance(value, bool): + try: + lo, hi = feat.get_range() + if value > hi: + value, clamped = (type(value)(hi) if not isinstance(value, int) else int(hi)), True + elif value < lo: + value, clamped = (type(value)(lo) if not isinstance(value, int) else int(lo)), True + except Exception: + pass + + if dry_run: + return ("would-clamp" if clamped else "would-set", name, before, value) + try: + feat.set(value) + except Exception as exc: + return ("refused", name, before, f"{value} ({type(exc).__name__}: {exc})") + try: + after = feat.get() + except Exception: + after = value + return ("clamped" if clamped else "set", name, before, after) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--profile", default="standard", choices=sorted(RECIPES)) + ap.add_argument("--out", default="config/camera_profiles/rgb", + help="directory to write .xml into") + ap.add_argument("--camera", default=None, help="camera id (default: the first real one)") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + if not os.environ.get("GENICAM_GENTL64_PATH"): + for cti in ("/opt/VimbaX/cti",): + if os.path.isdir(cti): + os.environ["GENICAM_GENTL64_PATH"] = cti + break + + with vmbpy.VmbSystem.get_instance() as vmb: + cams = vmb.get_all_cameras() + if args.camera: + cams = [c for c in cams if c.get_id() == args.camera] + else: + # The SDK's camera simulator transport layer is enabled by default + # and offers DEV_Cam1..3. Exporting a profile from one of those + # would produce a file that configures nothing real. + cams = [c for c in cams if not c.get_id().startswith("DEV_Cam")] + if not cams: + sys.exit("no real camera found") + cam = cams[0] + + with cam: + print(f"camera : {cam.get_id()} {cam.get_model()}") + print(f"profile : {args.profile}{' (dry run)' if args.dry_run else ''}") + refused = [] + for name, value in RECIPES[args.profile]: + state, n, before, after = apply_feature(cam, name, value, args.dry_run) + mark = {"set": " ", "would-set": " ?", "missing": " !", "refused": " X", + "clamped": " ~", "would-clamp": " ~"}[state] + note = " (clamped: the camera cannot reach the recipe's value)" \ + if state in ("clamped", "would-clamp") else "" + print(f"{mark} {n:28} {before} -> {after}{note}") + if state in ("missing", "refused"): + refused.append(n) + + # What the settings actually cost per frame - the number that decides + # whether this profile can be delivered over USB at all. + try: + w = cam.get_feature_by_name("Width").get() + h = cam.get_feature_by_name("Height").get() + fmt = cam.get_feature_by_name("PixelFormat").get() + bpp = 3 if str(fmt) in ("RGB8", "BGR8") else 1 + rate = cam.get_feature_by_name("AcquisitionFrameRate").get() + mb = w * h * bpp / 1e6 + print(f"\nresult : {w}x{h} {fmt} {mb:.1f} MB/frame " + f"{rate:.2f} fps = {mb * rate:.0f} MB/s") + if mb > 20: + print("WARNING : over the ~20 MB/frame ceiling in known-issues #19") + except Exception: + pass + + if args.dry_run: + return 0 + + os.makedirs(args.out, exist_ok=True) + path = os.path.join(args.out, f"{args.profile}.xml") + cam.save_settings(path, vmbpy.PersistType.All) + print(f"\nwrote : {path}") + if refused: + print(f"NOTE : not applied: {', '.join(refused)}") + print(" the XML records what the camera accepted, not the recipe") + return 0 + + +if __name__ == "__main__": + sys.exit(main())