Add the LattePanda IOTA setup kit and headless camera profile export
This commit is contained in:
parent
08a6f7c1fa
commit
2c7c62ace1
|
|
@ -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:
|
||||
|
||||
```
|
||||
<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 |
|
||||
|
|
@ -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.<name>] 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.<name>] 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@<tower>:~/ # 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 <<EOF
|
||||
; hardware.ini - what this machine IS. Generated by hardware/$board/install.sh
|
||||
; on $(date -Is) from what the machine reports, plus the assembly
|
||||
; facts given on the command line. See config/hardware.example.ini for the full
|
||||
; reference, and docs/hardware-config-plan.md for why this file exists.
|
||||
|
||||
[Board]
|
||||
model = $board
|
||||
firmware_env = $fw_env
|
||||
|
||||
[Hardware]
|
||||
; DECLARED, not probed: these are what the tower was built with, so that a part
|
||||
; that stops answering is a fault and not a silent reconfiguration.
|
||||
axes = $AXES
|
||||
imu = $IMU
|
||||
env = $ENV_SENSOR
|
||||
cameras = $CAMERAS
|
||||
|
||||
[Serial]
|
||||
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
|
||||
; enumerates first.
|
||||
device = ${mcu:-NOT_FOUND_SET_THIS_BY_HAND}
|
||||
baud = 115200
|
||||
EOF
|
||||
|
||||
if [[ $IMU != none ]]; then
|
||||
cat <<'EOF'
|
||||
|
||||
[IMU]
|
||||
; On the IOTA the only PCH serial on the header is COM1. Confirm it enumerates
|
||||
; and at what levels before trusting this path.
|
||||
device = /dev/ttyS0
|
||||
EOF
|
||||
fi
|
||||
if [[ $ENV_SENSOR != none ]]; then
|
||||
cat <<'EOF'
|
||||
|
||||
[Env]
|
||||
; MUST be a real I2C adapter, not SMBus-only: check with `i2cdetect -F <n>`.
|
||||
; 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 <<EOF
|
||||
|
||||
[Camera.$c]
|
||||
; The Vimba DEV_ id of the physical camera fitted to this tower. Not the USB
|
||||
; serial: read it with VimbaX's ListCameras, which is what this probe does.
|
||||
id = ${cam:-NOT_FOUND_SET_THIS_BY_HAND}
|
||||
EOF
|
||||
done
|
||||
}
|
||||
|
||||
do_hardware_ini() {
|
||||
if [[ $TO_STDOUT -eq 1 ]]; then emit_hardware_ini; return; fi
|
||||
need_root "--hardware-ini"
|
||||
mkdir -p "$ETC_DIR"
|
||||
if [[ -f $HW_FILE && $FORCE -ne 1 ]]; then
|
||||
warn "$HW_FILE exists - leaving it alone (--force to replace)"
|
||||
warn "the file it would write instead:"
|
||||
emit_hardware_ini | sed 's/^/ /' >&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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 <profile>.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())
|
||||
Loading…
Reference in New Issue