Compare commits

...

10 Commits

27 changed files with 2309 additions and 35 deletions

View File

@ -63,6 +63,7 @@ add_library(fgc_core STATIC
src/core/SingleInstance.cpp src/core/SingleInstance.cpp
src/core/Logger.cpp src/core/Logger.cpp
src/core/Geometry.cpp src/core/Geometry.cpp
src/core/TmcCurrent.cpp
src/core/ScanGrid.cpp src/core/ScanGrid.cpp
src/core/TelemetryParser.cpp src/core/TelemetryParser.cpp
src/core/CaptureScheduler.cpp src/core/CaptureScheduler.cpp

View File

@ -168,6 +168,8 @@ period_ms = 2000
; Travel limits are NOT configured here - they are derived from the firmware's ; Travel limits are NOT configured here - they are derived from the firmware's
; homed soft limits after every home, the only place they can be correct. ; homed soft limits after every home, the only place they can be correct.
; ;
; Mechanical travel between the hard endstops now belongs in hardware.ini as
; [Motor.<axis>] travel_deg, where homing uses it to derive counts_per_deg below.
; Mechanical travel of this gimbal (between hard endstops, found at homing): ; Mechanical travel of this gimbal (between hard endstops, found at homing):
; Yaw ~320 deg (NOT a full revolution: there is an unreachable arc) ; Yaw ~320 deg (NOT a full revolution: there is an unreachable arc)
; Pitch 40 deg ; Pitch 40 deg
@ -190,6 +192,10 @@ pitch_angle_at_center = 10.0
pitch_soft_margin_counts = 5000 pitch_soft_margin_counts = 5000
; Live encoder-error WARN threshold in counts (0 = off). ; Live encoder-error WARN threshold in counts (0 = off).
enc_error_warn_counts = 400 enc_error_warn_counts = 400
; How long a full two-end homing may take before the host calls it failed. A
; property of the drivetrain: a high gear ratio searching at a gentle speed takes
; minutes, and too tight a value reports a healthy axis as broken.
homing_timeout_s = 65
[Scan] [Scan]
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through). ; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).

View File

@ -73,6 +73,38 @@ device = /dev/ttyS0
i2c_device = /dev/i2c-2 i2c_device = /dev/i2c-2
i2c_addr = 0x44 i2c_addr = 0x44
; --- What it is made of ---------------------------------------------------
; The drivetrain, one block per axis. A different motor, gearbox or driver board
; changes these; nothing an operator tunes belongs here.
[Motor.yaw]
; Sense resistor fitted to THIS axis's TMC5160 board. It is what makes the
; milliamp figures below mean anything, which is why the host - not the firmware -
; converts them into GLOBALSCALER/IRUN register values and sends those.
r_sense_ohm = 0.075
; Milliamps RMS per phase. run_current_ma is normal motion; homing_current_ma
; applies ONLY while hunting for an endstop.
;
; The two differ when the stop cannot survive full torque. On the IOTA tower the
; endstop is a screw head meeting another screw head, and the high gear ratio let
; the motor shear one past the other - it ran 1.8 turns past the stop without the
; encoder ever falling behind. At 189 mA it homes against the same screw cleanly.
;
; The startup log prints what the registers ACTUALLY deliver, which is not
; always what you asked for; believe the log, not this file.
run_current_ma = 756
homing_current_ma = 200
; Mechanical angle between the hard endstops. A NAMEPLATE FACT: homing measures
; the travel in counts, and dividing by this yields [Motor] yaw_counts_per_deg in
; config.ini, offered for saving at the y/n prompt.
;
; Deliberately a different key, in a different file, from counts_per_deg itself:
; hardware.ini is merged OVER config.ini and wins, so sharing a key would
; silently discard every calibration the save path writes back.
;
; ROUGH until somebody measures it. Every bearing inherits its error
; proportionally, until 'gimbal heading setup' supersedes it with a landmark fit.
travel_deg = 340
[Camera.rgb] [Camera.rgb]
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a ; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
; GigE IP address. ; GigE IP address.

View File

@ -69,6 +69,10 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Motor` | `yaw_heading_at_center` | float | `0` | True bearing (0 = north) at that centre (**calibrate**) | | `Motor` | `yaw_heading_at_center` | float | `0` | True bearing (0 = north) at that centre (**calibrate**) |
| `Motor` | `pitch_angle_at_center` | float | `0` | Elevation above level at that centre (**calibrate**; not 0 on a real rig) | | `Motor` | `pitch_angle_at_center` | float | `0` | Elevation above level at that centre (**calibrate**; not 0 on a real rig) |
| `Motor` | `yaw_soft_margin_counts` / `pitch_soft_margin_counts` | int | `5000` | Clearance kept from each hard endstop; sent to the firmware, which enforces it | | `Motor` | `yaw_soft_margin_counts` / `pitch_soft_margin_counts` | int | `5000` | Clearance kept from each hard endstop; sent to the firmware, which enforces it |
| `Motor` | `homing_timeout_s` | int | `65` | How long a full two-end homing may take before the host calls it failed. A property of the drivetrain — a high gear ratio searching gently takes minutes |
| `Motor.<axis>` | `r_sense_ohm` | float | — | Sense resistor on that axis's TMC5160 board. **hardware.ini.** Makes the milliamp figures below mean something |
| `Motor.<axis>` | `run_current_ma` / `homing_current_ma` | float | — | Coil current (mA RMS) for normal motion, and while hunting an endstop. **hardware.ini** |
| `Motor.<axis>` | `travel_deg` | float | — | Mechanical angle between the hard endstops. **hardware.ini.** Homing divides the measured travel by it to derive `<axis>_counts_per_deg` |
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate | | `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate |
| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` | | `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` |
| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc | | `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc |
@ -588,10 +592,53 @@ The firmware speaks **full-word, newline-terminated** commands in absolute **enc
| `ENABLE Y` / `ENABLE P` | init | energize the axis coils | | `ENABLE Y` / `ENABLE P` | init | energize the axis coils |
| `HOME` | init | home all axes (firmware runs it non-blocking; watch `ST` state `R→H→A`) | | `HOME` | init | home all axes (firmware runs it non-blocking; watch `ST` state `R→H→A`) |
| `SPEED Y\|P <vel>` | init | set the production move speed (VMAX, counts/s) | | `SPEED Y\|P <vel>` | init | set the production move speed (VMAX, counts/s) |
| `CURRENT Y\|P <scaler> <irun> <irun_home>` | init, before `HOME` | motor current as TMC register fields, converted by the host from `[Motor.<axis>]` milliamps |
| `CENTER Y\|P <count>` | init, before `HOME` | the count homing anchors the travel midpoint to |
| `MARGIN Y\|P <counts>` | init, before `HOME` | clearance kept from each hard endstop |
| `MOVE <yaw>,<pitch>` | each capture point | drive both axes to absolute counts (combined form) | | `MOVE <yaw>,<pitch>` | each capture point | drive both axes to absolute counts (combined form) |
| `MOVE Y\|P <pos>` | — | single-axis absolute move (`set motorctl`) | | `MOVE Y\|P <pos>` | — | single-axis absolute move (`set motorctl`) |
| `STOP Y\|P\|ALL` | — | ramp to a controlled stop | | `STOP Y\|P\|ALL` | — | ramp to a controlled stop |
### Motor current, and why the host computes it
The TMC5160 expresses coil current as two dimensionless registers, and what a given pair
delivers depends on the sense resistor soldered to the board. Only `hardware.ini` knows that
resistor, so the **host** converts milliamps to registers and sends the result; the firmware
keeps compile-time defaults so a bench board still behaves.
`homing_current_ma` exists for one reason: **an endstop the motor can out-push is not an
endstop.** Homing detects a stop by driving into it until the shaft falls behind the
commanded position. On the IOTA tower, whose gear ratio was raised for wind stability, the
motor simply sheared one screw-head stop past the other — it ran 1.8 turns past the stop with
the encoder never falling behind. At 189 mA it homes against the same screw cleanly.
The startup log names what the registers **actually** deliver, which is not always what was
asked for:
```
yaw motor current: run 756 mA (GLOBALSCALER 80, IRUN 31), homing 189 mA (IRUN 7)
-- R_sense 0.075 ohm from hardware.ini [Motor.yaw]
```
Because both registers are write-only on the chip, the firmware echoes them in `DUMP` and the
host checks them **before** homing. If an axis declares a reduced homing current and the
firmware has not adopted it, the host **refuses to home** rather than driving into a stop at
full torque — the fire it is there to prevent.
### Deriving `counts_per_deg` from the homed travel
`travel_deg` is a nameplate fact: the mechanical angle between the stops. Homing measures the
same span in counts, so dividing gives the degree scale. On a successful home the host
compares the two and, when they disagree by more than a factor of two, adopts the derived
value and offers it at the usual `y`/`n` prompt, saving to `config.ini` with a `;prev` line.
Within a factor of two it only logs the comparison. That guard matters: a `gimbal heading
setup` landmark fit is a better measurement than this one, and a routine re-home must not
quietly overwrite it.
**The derived scale is only as good as `travel_deg`**, which is usually a rough figure — a 1 %
error in the angle is a 1 % error in every bearing. `gimbal heading setup` supersedes it.
Capture is a **move → settle → trigger** cycle: the scheduler issues a `MOVE`, waits until both Capture is a **move → settle → trigger** cycle: the scheduler issues a `MOVE`, waits until both
axes report standstill at the target, then triggers the cameras. **ControlCode 0** walks the scan axes report standstill at the target, then triggers the cameras. **ControlCode 0** walks the scan
grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as

View File

@ -297,6 +297,34 @@ commit `aab1925`.
|---|---|---| |---|---|---|
| 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and now measured on the board: neither works** (§2). First flash needs the MicroPython REPL or BOOTSEL; remote *update* is unsolved pending a `BOOTSEL` command | | 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and now measured on the board: neither works** (§2). First flash needs the MicroPython REPL or BOOTSEL; remote *update* is unsolved pending a `BOOTSEL` command |
| 2 | **Phase 2 gate: RP2040 + TMC5160-BOB at 3.3 V** | **PASSED** on tower 121, 2026-09-15: `IOIN Y=0x3000001E version=0x30`, encoder reading (`X_ENC`), one axis, `speed=3000` | | 2 | **Phase 2 gate: RP2040 + TMC5160-BOB at 3.3 V** | **PASSED** on tower 121, 2026-09-15: `IOIN Y=0x3000001E version=0x30`, encoder reading (`X_ENC`), one axis, `speed=3000` |
| 3 | Yaw homing on the real drivetrain | **BLOCKED — no endstop found.** See below |
### Phase 3, first attempt — 2026-09-16
Three homing runs at VMAX 120, 1200 and 6000. What came out of them:
| Established | Value |
|---|---|
| Speed model | **~0.73 counts/s per unit of VMAX** (87 c/s at 120, 880 at 1200, 4400 at 6000 — predictions held to within 0.5 %) |
| Geometry | **~3,400 counts/degree**, so ~1.23 M counts per turn |
| `steps_per_rev` | The inherited Delta value of 177,000 is **wrong by ~7x** for this drivetrain. It is not a search limit (`motor.cpp` uses it only as a minimum-travel check once both limits are found), so it slowed nothing — but it made the axis look broken, and the check is meaningless until corrected |
**The axis turned ~2,157,000 counts — about 630 degrees, 1.8 full turns — in one
direction without the encoder deviation ever exceeding ~100 counts** against the
1500 that marks a jam. The motor met no resistance at any speed.
Homing detects an endstop by the shaft falling behind the commanded position, so
it cannot work if nothing stops the shaft. Two candidates, indistinguishable
over the wire because the encoder sits on the motor side:
1. **No hard stop on this yaw** — it rotates continuously, and stall-based homing
is the wrong strategy for this rig.
2. **The output is not following the motor** — a slipping coupling or a loose
grub screw. The encoder would track the motor perfectly either way.
**Resolving it needs a physical observation**: did the gimbal itself rotate ~1.8
turns, or did the motor spin while the platform stayed put? Check any cabling
through that joint before moving the axis further.
| 2 | `src/platform.h` — reset cause and free RAM behind one interface per MCU family | **done** | | 2 | `src/platform.h` — reset cause and free RAM behind one interface per MCU family | **done** |
| 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** | | 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** |
| 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass | | 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass |

692
docs/tower-installation.md Normal file
View File

@ -0,0 +1,692 @@
# Installing a tower from scratch
How a new fire-watch tower host goes from a freshly imaged machine to a running,
calibrated `fire_gimbal_control`. Written while doing exactly that on the IOTA
tower at `10.11.12.121` (2026-09-15) and kept honest about which steps have
actually been exercised and which are still on paper.
> [deployment.md](deployment.md) is the *update* loop for a tower that already
> works. This is the *first* install, including the parts that only happen once:
> the board's preconditions, the Vimba X SDK, `hardware.ini`, and the motor
> firmware.
**Status legend** — each step says where it stands:
`[verified]` run end to end on a real tower and the result checked ·
`[partial]` run, with something still open · `[untested]` written from the
design, not yet exercised.
---
## What you need before starting
| | |
|---|---|
| The tower host | Powered, on the network, reachable by IP, with an OS installed and an `ssh` server running |
| An account on it | `ggs`, in the `sudo` and `dialout` groups |
| A dev box | This repository checked out, plus a copy of the Vimba X SDK (login-walled, so it cannot be fetched by a script) |
| The hardware | Camera plugged in, motor MCU plugged in, and a note of **what was assembled**: which axes, which IMU, which ambient sensor |
Two facts decide most of what follows, so establish them first:
```bash
ssh ggs@<tower-ip> 'cat /sys/class/dmi/id/product_name; . /etc/os-release && echo "$PRETTY_NAME"'
```
| Board | MCU | Setup kit | PlatformIO env |
|---|---|---|---|
| LattePanda 3 Delta / Sigma | ATmega32U4 (Leonardo), 5 V | *not written - the Delta fleet predates the kits* | `leonardo` |
| LattePanda IOTA | RP2040, 3.3 V | [`hardware/lattepanda-iota/`](../hardware/lattepanda-iota/) | `iota_rp2040` |
---
## 1. SSH by key `[verified]`
Everything after this is driven over ssh from the dev box, and `deploy.sh` runs
several ssh commands per invocation, so password auth is not workable.
```bash
# on the dev box
ssh-copy-id -i ~/.ssh/id_ed25519_ggs_pedro.pub ggs@<tower-ip>
```
Then give the host a block in `~/.ssh/config`, so every tool picks the right key
without being told:
```
Host 10.11.12.121
User ggs
IdentityFile ~/.ssh/id_ed25519_ggs_pedro
```
Verify with `ssh ggs@<tower-ip> hostname` - it must not prompt.
> **`ssh-copy-id` needs a terminal.** Run it in a real shell, not through a tool
> that gives it no tty: without one, ssh looks for a GUI `ssh-askpass`, and on a
> machine that has none it fails with `ssh_askpass: exec(...): No such file or
> directory` and then `Permission denied` - which looks like a rejected key
> rather than a prompt that never appeared.
## 2. Survey the machine before changing it `[verified]`
The setup kit can describe the machine without touching it, and this is the
cheapest moment to notice that the tower is not what you assumed:
```bash
rsync -a hardware/ ggs@<tower-ip>:~/projects/fwt_2a/software/hardware/
ssh ggs@<tower-ip> '~/projects/fwt_2a/software/hardware/lattepanda-iota/install.sh --probe'
```
```
board : lattepanda-iota
os : Ubuntu 26.04.1 LTS (7.0.0-31-generic)
motor MCU : /dev/serial/by-id/usb-MicroPython_Board_in_FS_mode_df61a863dfa02932-if00
camera DEV_ id : NOT FOUND (needs the Vimba X SDK installed)
alvium on usb : 1 device(s)
usbfs_memory_mb : 16 (want >= 1000)
camera lpm_permit: u1_u2 (want 0)
```
Read every line. On tower 121 this pass found that the MCU was still running the
MicroPython image it shipped with, and that both USB preconditions were at their
(wrong) defaults.
**Check what is already in `~/projects/`.** A machine cloned from another tower,
or one that has been used for development, can arrive with a `config.ini` that
belongs to a different rig. Tower 121 had a byte-identical copy of the dev box's
`config.ini` - `tower_name = dev`, broker `127.0.0.1` - which is worse than no
config at all, because **none of those values trip the placeholder guard in
`deploy.sh --run`**. That guard only catches `ExampleTower`, `CHANGE_ME` and
`DEV_0000000000`.
```bash
ssh ggs@<tower-ip> 'grep -E "tower_name|zkms_server_ip|^id" ~/projects/fwt_2a/software/config.ini'
```
## 3. Host preconditions and build dependencies `[verified]`
Both come from the board's setup kit, in one step, on the device:
```bash
ssh ggs@<tower-ip>
cd ~/projects/fwt_2a/software/hardware/lattepanda-iota
sudo ./install.sh --deps --preconditions
```
`--deps` installs [`packages.txt`](../hardware/lattepanda-iota/packages.txt): the
toolchain, OpenCV, Boost, libjxl, tmux, and the Python bits PlatformIO needs.
`--preconditions` installs two files and applies them immediately:
| File | Why |
|---|---|
| `/etc/udev/rules.d/99-fwt-alvium-usb.rules` | Disables USB3 hardware LPM on the camera's port, which the host otherwise re-enables on every enumeration. With U1/U2 on, the camera logs link errors and frame delivery stalls (known-issues #19) |
| `/etc/tmpfiles.d/10-fwt-usbfs.conf` | Raises `usbfs_memory_mb` from 16 to 1000 at every boot. A single full-resolution frame does not fit in 16 MB |
Both are settings that **revert on reboot** if nobody persists them, which is the
whole of known-issue #19. The script re-reads them after applying and says so:
```
>> usbfs_memory_mb = 1000
>> camera USB3 LPM disabled (2-1)
```
If it instead warns that a value did not take, stop and find out why - a camera
on a host with these wrong looks like a flaky camera.
Confirmed on tower 121 (2026-09-15): both went from wrong to right in one run,
`usbfs_memory_mb` 16 -> 1000 and `lpm_permit` `u1_u2` -> `0`. Re-check after the
first reboot, since surviving one is the entire point.
> **Neither step can be driven from a tool without a terminal.** `sudo` needs a
> tty to prompt, so this runs from a real terminal window:
>
> ```bash
> ssh -t ggs@<tower-ip> 'cd ~/projects/fwt_2a/software/hardware/lattepanda-iota && sudo ./install.sh --deps --preconditions'
> ```
> **The Delta fleet does not have these applied.** Checked on `10.11.12.111`
> 2026-09-15: `usbfs_memory_mb` is 16 and the kernel cmdline is stock. The
> preconditions were documented in known-issues but never persisted anywhere.
## 4. Vimba X SDK `[verified]`
Proprietary and login-walled, so it is staged from a machine that already has it
rather than downloaded on the tower:
```bash
# dev box
rsync -az ~/Programs/VimbaX_2025-3 ggs@<tower-ip>:~/
# tower
sudo ./install.sh --vimba ~ggs/VimbaX_2025-3
```
That copies it to `/opt/VimbaX_<version>`, points `/opt/VimbaX` at it, runs the
SDK's `Install_GenTL_Path.sh` (which registers `GENICAM_GENTL64_PATH` in
`/etc/profile.d` and writes the `1ab2` udev rule), registers `api/lib` with the
dynamic loader, and then asks the SDK to list cameras as a check.
**The SDK asks for a reboot before the transport layers are usable.** If it
finds nothing, reboot and try again before suspecting the camera. On tower 121
no reboot was needed - the camera answered immediately.
Two things about that check, both found the hard way here:
- **The binary is `ListCameras_VmbC`**, in `/opt/VimbaX/bin/`. There is no plain
`ListCameras`.
- **Run it with `GENICAM_GENTL64_PATH` set**, or it fails with `Could not start
system. Error code: -16` on a perfectly good installation - that is "no
transport layer found", not "no camera":
```bash
GENICAM_GENTL64_PATH=/opt/VimbaX/cti /opt/VimbaX/bin/ListCameras_VmbC
```
> **The SDK ships a camera simulator, and it is on by default.** On tower 121
> `ListCameras_VmbC` reported **four** cameras: the real Alvium plus three
> synthetic ones from `VimbaCameraSimulatorTL.cti`, with ids like `DEV_Cam1`. A
> tower configured with one of those would open its "camera" happily and capture
> synthetic images forever. Take the id only from a block whose transport layer
> path is `VimbaUSBTL` or `VimbaGigETL` - which is what `install.sh --probe`
> does, after this bit me.
> Non-interactive ssh shells do not source `/etc/profile.d`, which is why
> `deploy.sh` resolves `GENICAM_GENTL64_PATH` itself before launching the app.
> Getting this wrong shows up as `VmbStartup()` failing with *"Could not start
> Vimba X API"* even though the viewer works when you are logged in.
## 5. `hardware.ini` - what this machine IS `[verified]`
```bash
sudo ./install.sh --hardware-ini --axes yaw --imu none --env none --cameras rgb
```
This writes `/etc/fire_gimbal_control/hardware.ini`, **outside** the directory
`deploy.sh` rsyncs, so no deploy can overwrite or delete it.
The probed half - board model, firmware env, the MCU's stable `by-id` path, the
camera's `DEV_` id - the script fills in. The declared half is the flags, and
they are not a formality: the program degrades gracefully around missing
hardware, so **a part that was never fitted and a part that has failed look
identical** unless the file says which was expected. Declare what was assembled.
Check the result, especially the serial path:
```bash
ssh ggs@<tower-ip> 'cat /etc/fire_gimbal_control/hardware.ini'
```
Then prove the program finds it **with nothing in the environment** - no
`FGC_HARDWARE`, no `--hardware` - because that is how the tower starts itself:
```
[INFO ] hardware: /etc/fire_gimbal_control/hardware.ini board lattepanda-iota axes yaw
```
> **Do not leave it anywhere under `/tmp`.** That is tempting during bring-up
> because it needs no root, and it works right up until the first reboot, which
> clears `/tmp` and leaves the program with `Config error: Camera.rgb.id is
> required` - a message that points at the camera rather than at the file that
> disappeared. Verified on tower 121 by rebooting it.
> `[Serial] device` must be a `/dev/serial/by-id/` path, never `/dev/ttyACM0`.
> Tower 121 has a 5G modem that claims three `ttyUSB` nodes, and enumeration
> order is not stable across reboots.
>
> **The by-id path changes when you flash the MCU**, because the name comes from
> the running image: `usb-MicroPython_Board_in_FS_mode_*` before, a Pico/Arduino
> name after. Re-run `--probe` after step 6 and fix the file if it moved.
## 6. Motor firmware `[verified]`
The firmware is built and flashed **on the tower** - it is the machine wired to
the MCU - so PlatformIO is installed there, not on the dev box.
```bash
# tower: PlatformIO core, in its own venv under ~/.platformio. No sudo needed.
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o /tmp/get-platformio.py
python3 /tmp/get-platformio.py
~/.platformio/penv/bin/pio --version # check it actually runs
```
`[verified]` on tower 121: PlatformIO Core 6.2.0 on the system Python 3.14.4.
> The installer ends with a `FileNotFoundError` traceback out of
> `multiprocessing/util.py` about a missing `/tmp/.piocore-installer-*` path.
> It is a temp-directory cleanup finalizer running after the work is done -
> cosmetic. Judge the install by whether `pio --version` answers.
`deploy.sh` looks for `pio` in the usual places, but a non-interactive ssh shell
does not have `~/.platformio/penv/bin` on PATH, so name it explicitly:
```ini
REMOTE_PIO=/home/ggs/.platformio/penv/bin/pio
```
Then, from the dev box, in `firmware/`:
```ini
# .deploy.env
REMOTE_HOST=ggs@10.11.12.121
REMOTE_DIR=/home/ggs/projects/fwt_2a/firmware
PIO_ENV=iota_rp2040 # leonardo on a Delta
SERIAL_PORT=/dev/serial/by-id/usb-...
```
```bash
./deploy.sh --compile-only # build on the tower, flash nothing
./deploy.sh # build + upload
./deploy.sh --monitor # watch it boot
```
**Confirm the pin map before flashing a board that is wired to motors.** The map
lives in `src/platform_iota.h` and a chip-select on the wrong pin writes into a
driver that was never meant to hear it. The numbers there are the loom as built
on tower 121, which is **not** the map proposed in the porting plan.
### Getting a factory board to accept its first flash
`[verified]` on tower 121, and this is the step that will bite you. These boards
ship running MicroPython, and **the normal upload does not work against it**:
```
Forcing reset using 1200bps open/close on port /dev/ttyACM0
No new RPxxxx device found yet, waiting.. (x20, then it gives up)
```
| What | Factory board (MicroPython) | Our firmware |
|---|---|---|
| 1200-baud touch, i.e. a plain upload | no reset at all | resets, but **reboots into the application** |
| `picotool reboot -f -u` | refused: no reset interface | - |
| MicroPython REPL | **works** | n/a |
So drop into MicroPython's own REPL and tell it to enter the bootloader. Write
this to a file and run it with `~/.platformio/penv/bin/python` (it has pyserial):
```python
import serial, time
s = serial.Serial("/dev/serial/by-id/usb-MicroPython_Board_in_FS_mode_XXXX-if00", 115200, timeout=1)
s.write(b"\x03\x03"); time.sleep(0.3) # Ctrl-C: interrupt whatever is running
s.write(b"import machine\r\n"); time.sleep(0.3)
s.write(b"machine.bootloader()\r\n") # the port drops here - that is success
```
```bash
lsusb | grep 2e8a # expect: 2e8a:0003 Raspberry Pi RP2 Boot
```
Then run `./deploy.sh` and it flashes. A physical BOOTSEL press does the same
job if you are standing at the board.
**Every board needs exactly one physical BOOTSEL press in its life** - the one
that installs the first image carrying the `BOOTSEL` command (below). After
that, updates are remote.
**`sudo ./install.sh --preconditions` must have run first**, or picotool finds
the board and 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.
```
### Re-flashing later: the `BOOTSEL` command
`[verified]` on tower 121. Neither documented remote path works on this board -
the 1200-baud touch resets the firmware but returns it to the application, and
`picotool reboot -f -u` answers `Unable to locate reset interface on the
device`. So the firmware carries a command that does it deliberately:
```bash
# over the serial port, or from the app: gimbal raw "BOOTSEL"
BOOTSEL -> OK entering bootloader - reflash to return
lsusb | grep 2e8a # 2e8a:0003 Raspberry Pi RP2 Boot
./deploy.sh # flashes and reboots into the new firmware
```
The whole loop was run end to end from the dev box with nobody at the tower.
`BOOTSEL` disables every axis first, since the bootrom does not run the control
loop and unsupervised energised drivers would hold torque.
> **It takes the gimbal out of service and the firmware cannot undo it** - once
> in the bootrom there is no command loop left to change its mind. Recovery is
> `deploy.sh`, or a power cycle: entering the bootrom erases nothing, so the
> existing image is still in flash. Anything that can write to the serial port
> can send it, including `gimbal raw`. Near misses (`BOOT`, `BOOTSE`) are
> rejected as unknown commands.
### The identity changes when you flash
| | by-id path | USB |
|---|---|---|
| before | `usb-MicroPython_Board_in_FS_mode_<serial>-if00` | `2e8a:0005` |
| after | `usb-Raspberry_Pi_Pico_<SERIAL>-if00` | `2e8a:000a` |
So generate `hardware.ini` (step 5) **after** flashing, or re-run
`install.sh --hardware-ini --force` to pick up the new path.
### Verify the flash
Read the boot banner over the serial port:
```
BOOT build=aab1925-dirty
IOIN Y=0x3000001E version=0x30
ST Y:R,500000,18,80084000,0,8,8,Sd
```
`version=0x30` is the TMC5160 answering over SPI - the single most useful line
here, because it proves the pin map, the 3.3 V logic level and the driver board
all at once. `BOOT build=` must match the commit you flashed. One `Y` line and
no `P` line is a yaw-only build.
> **Flashing an RP2040 remotely is not the same as a Leonardo.** The documented
> flow is *hold BOOTSEL, tap RST, drag a `.uf2`* - a person at the board. The
> two remote paths (the 1200-baud touch that the Arduino tooling already uses,
> and `picotool reboot -f -u`) both need the **running** image to present a USB
> CDC port. That is why `setup()` brings `Serial` up before any hardware init:
> an image that hangs before USB comes up cannot be reflashed without a site
> visit, while an interrupted flash recovers by itself - the bootrom falls back
> to mass storage when flash holds no valid image.
## 7. Deploy the software `[verified]`
On the dev box, in `software/`:
```ini
# .deploy.env
REMOTE_HOST=ggs@10.11.12.121
REMOTE_DIR=/home/ggs/projects/fwt_2a/software
CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON"
RUN_ARGS="--start"
```
```bash
./deploy.sh --check-deps # must print "remote deps OK"
./deploy.sh # rsync + remote cmake + build
```
The first deploy seeds `config.ini` from `config/config.example.ini` **only if
the device has none** - see the warning in step 2 about a machine that arrived
with somebody else's config.
`[verified]` on tower 121: builds clean on GCC 15 / Ubuntu 26.04 with a single
`-Wformat-truncation` warning in `utcStampNow()`, which is a false positive (the
compiler cannot prove `tm_year` is four digits; the real output is 17 bytes into
a 32-byte buffer).
## 8. Camera profiles `[verified]`
**Without a profile the program does not start** - `no camera profiles (*.xml)
in config/camera_profiles/rgb` is a startup error, by design. A fresh tower has
an empty profile directory, so this step is not optional.
The documented way to make one is the VimbaX Viewer, which is a GUI - awkward on
a headless tower and impossible unattended. `scripts/export_camera_profile.py`
does the same job through the SDK's Python API:
```bash
# once per tower: the SDK ships the wheel
python3 -m venv ~/.venvs/vmbpy
~/.venvs/vmbpy/bin/pip install /opt/VimbaX/api/python/vmbpy-*.whl
cd ~/projects/fwt_2a/software
export GENICAM_GENTL64_PATH=/opt/VimbaX/cti
~/.venvs/vmbpy/bin/python scripts/export_camera_profile.py --profile standard --dry-run
~/.venvs/vmbpy/bin/python scripts/export_camera_profile.py --profile standard
~/.venvs/vmbpy/bin/python scripts/export_camera_profile.py --profile night
```
It writes each recipe value to the real camera **in dependency order**, lets the
camera validate it, and exports what was actually accepted. That preserves the
property the Viewer workflow exists to provide: GenICam validity is stateful, so
a hand-written XML can be individually plausible and collectively wrong. Here
the camera is the judge, and anything it refused is printed rather than assumed.
Read the summary line it prints - it is the number that decides whether the
profile can be delivered at all:
```
result : 2664x1520 RGB8 12.1 MB/frame 4.95 fps = 60 MB/s
```
### Profiles do not transfer between cameras
`[verified]` the hard way. The fleet runs several Alvium generations and their
feature vocabularies differ, so copying another tower's XML does not work:
| | Delta tower (1800 U-1240c) | IOTA tower (1800 U-1620c) |
|---|---|---|
| `SensorBitDepth` | `Bpp8` | **no `Bpp8`** - only `Adaptive` / `Bpp12` |
| `IntensityAutoPrecedence` | `Minimizeexposuretime` / `Minimizenoise` | **`MinimizeBlur` / `MinimizeNoise`** |
| Sensor | 12 MP | 16 MP, 5328x3040 |
### Binning is not optional, and the frame-rate ceiling is not what it looks like
Unbinned RGB8 on the 16 MP sensor is a **48.6 MB frame** - over the ~20 MB
ceiling in known-issues #19. 2x2 binning brings it to 12.1 MB.
The frame rate is then capped by `DeviceLinkThroughputLimit`, but the limit is
accounted against the **unbinned** payload, not the binned frame that crosses
the wire. Measured on tower 121:
| `DeviceLinkThroughputLimit` | max `AcquisitionFrameRate` | actual USB traffic |
|---|---|---|
| 250 MB/s (the fleet's value) | 4.95 fps | ~60 MB/s |
| 450 MB/s (this camera's max) | 8.79 fps | ~106 MB/s |
48.6 MB x 4.95 = 240 MB/s, which is the 250 limit - so the camera is budgeting
for data that binning discards. **The recipe ships the conservative 250 MB/s**,
giving ~5 fps rather than the Delta's 10, because the USB stalls behind
known-issues #19 are a host-side finding that has not been re-measured on the
N150 yet (porting plan phase 5). Raising it is a one-line change in the recipe
once that measurement exists.
> **Export `standard` after `night`, not the other way round, or use the current
> script.** A refused write is not a no-op: the first version of this script
> asked for 10 fps, the camera refused, and the 1 fps left over from the night
> profile stayed - producing a "standard" profile that ran at night speed. The
> script now clamps an out-of-range numeric value to the camera's limit and says
> `~ ... (clamped)`.
## 9. `config.ini` - what this tower DOES `[partial]`
Device-local, never overwritten by a deploy. Edit it on the tower:
```ini
[General]
tower_name = <this tower> ; drives every MQTT topic
[Network]
zkms_server_ip = <broker>
```
Credentials belong in `/etc/fire_gimbal_control/credentials.env`, not here.
Anything `hardware.ini` now owns - `[Serial] device`, `[IMU]`, `[Env]`, the
camera ids - should be **deleted** from `config.ini` rather than left to rot:
the program warns at startup about every key set in both.
> **`[Camera.<name>] id` is required even when the camera is disabled.** Config
> validation runs before any of the feature flags are consulted, so a tower with
> no camera fitted yet cannot start without one - and the obvious dummy,
> `DEV_0000000000`, is what `deploy.sh`'s placeholder guard refuses. On a tower
> that has its camera, the id lives in `hardware.ini` and this never comes up.
`[Features] enable_imu` and `enable_env` matter more than they look. Declaring
`imu = none` in `hardware.ini` does **not** yet switch them off - deriving them
is section 12 of the config plan and is not implemented. On tower 121 the first
clean-looking run did this:
```
[INFO ] MTi IMU started on /dev/ttyS0 @ 115200
[ERROR] I2C: failed to open /dev/i2c-2
[ERROR] SHT41: failed to open I2C bus; sensor disabled
```
It "started" an IMU on a tower that has none - `/dev/ttyS0` exists and accepts a
serial open whether or not anything is attached to it. Set both flags to `false`
by hand until reconciliation lands.
## 10. First run: bring the subsystems up one at a time `[partial]`
Do **not** start a new tower with everything switched on. Each phase below adds
exactly one subsystem, so that a failure names its own cause instead of arriving
as a wall of unrelated errors. It also keeps the motors - the only part that can
break itself - until you have proved the boring parts work.
Each phase says what good looks like and what noise to expect.
### Phase 0 - the host alone, no hardware `[verified]`
```bash
./build/fire_gimbal_control --mock-serial --mock-camera --no-mqtt --no-tui
```
Proves config resolution, `hardware.ini` layering, the single-instance lock, the
scan grid and the output directory - with nothing physical involved. Neither a
camera nor its profile XMLs need exist:
```
[INFO ] camera profiles not loaded: no real camera in this run
```
`[Features] enable_camera = false` does the same thing from the config file, for
a tower that has no camera fitted yet, and says so rather than pretending:
```
[WARN ] camera disabled ([Features] enable_camera = false); using a mock source
- no images come from hardware in this run
```
```
[INFO ] hardware: /etc/fire_gimbal_control/hardware.ini board lattepanda-iota axes yaw
[INFO ] Loaded config: config.ini
[INFO ] scan grid: 15 waypoints
```
Any `config.ini still sets X, which hardware.ini overrides` warning belongs here
and should be cleared before moving on (step 9).
### Phase 1 - motors `[partial]`
Real serial link, nothing else:
```bash
./build/fire_gimbal_control --mock-camera --no-mqtt --no-tui
```
What proves the link is a **live `DUMP` from the board**, not just an open port:
```
[INFO ] Serial controller started on /dev/serial/by-id/usb-Raspberry_Pi_Pico_...
DUMP BEGIN build=aab1925-dirty uptime=7925212 mcusr=0x3 free_ram=252712
DUMP Y state=1 ... speed=3000 center=500000 has_encoder=1
```
Check `build=` matches the firmware you flashed and `speed=` matches the
drivetrain values you intended. Then, and only then, move the thing:
```
gimbal home # the endstop-finding sequence - see the warning below
gimbal diag y # firmware self-test, reports PASS/FAIL
gimbal move 10,0 # a small deliberate move
```
**Expected noise in this phase, neither of which is a fault:**
| Message | Why |
|---|---|
| `Motor controller: ERR axis not present` | The host configures both axes at startup; a yaw-only firmware refuses the pitch half. Harmless until presence reconciliation lands (config plan section 10) |
| `encoder tracking error on yaw: 500366 counts (limit 400)` | Before homing, the firmware presets `XACTUAL` to the centre count while the encoder sits at its own zero, so they disagree by the whole range. Homing resyncs them. **If it persists after a successful home, that is real** |
> **On an unfamiliar drivetrain, homing is the dangerous step** - it finds the
> endstop by driving into it. Start from a deliberately slow `homing_speed` and
> walk it up, watching the free-running encoder gap that `ENC_DEVIATION_LIMIT` is
> measured against. Be at the rig with a hand on the power for the first one.
### Phase 2 - camera `[verified]`
Add the camera, still no broker:
```bash
./build/fire_gimbal_control --no-mqtt --no-tui
```
```
[INFO ] Opened camera 'rgb' DEV_1AB22C0B7D4E (1800 U-1620c)
[INFO ] camera profile 'standard' applied from .../standard.xml (17206 settings)
```
Then `start`, and confirm `.jxl` files appear under the configured output dir.
> **A camera-free phase needs no profile XMLs** - but this only became true on
> 2026-09-16. Before that, `[Features] enable_camera` was parsed and never used,
> and the profile directory was validated even for a mock camera, so a tower
> whose camera had not been configured yet could not start at all. If you are
> running an older binary, export the profiles (step 8) first or it will refuse
> with `no camera profiles (*.xml)`.
### Phase 3 - the broker
Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker.
Without one, the app says so and carries on:
```
[ERROR] MQTT connect failed (tcp://10.11.12.121:1883): TCP/TLS connect failure
[WARN ] Control channel not connected; continuing in degraded mode
```
**Degraded mode is not a failure to notice later** - the tower captures images
and reports to nobody. Confirm topics arrive at the broker before calling the
phase done.
Each tower runs its own RabbitMQ, in Docker, with the `rabbitmq_mqtt` and
`rabbitmq_shovel` plugins - the shovel is what forwards this tower's messages to
the ground station. That lives in `~/zkms-client/`, outside this repository.
### Phase 4 - IMU and ambient sensor
Only if fitted. `hardware.ini` should already say so, and `[Features]
enable_imu` / `enable_env` have to agree with it (see step 9 - declaring
`imu = none` does not yet switch the flag off by itself).
### Phase 5 - unattended
Only once every phase above is green:
```bash
scripts/fgc start --start # detached, inside tmux
scripts/fgc status # running? since when? which PID?
```
Close the ssh session, reconnect, and check it is still running. Then reboot the
tower and confirm the USB preconditions survived (`install.sh --probe`) and the
app comes back.
### Calibration
Per tower, and **cannot be inherited** - least of all from a tower with a
different gear ratio, where `yaw_counts_per_deg` is derived from a
`steps_per_rev` that no longer applies:
```
gimbal heading setup # ties yaw to true north by sighting landmarks; no IMU needed
gimbal pitch setup # needs an IMU; skip on a rig without one
```
## 11. Verification checklist
| Check | How | Tower 121 |
|---|---|---|
| Key-only ssh | `ssh ggs@<tower> hostname`, no prompt | ✅ |
| Preconditions survive a reboot | reboot, then `install.sh --probe`: `usbfs_memory_mb 1000`, `lpm_permit 0` | ✅ confirmed across a real reboot |
| `hardware.ini` found unaided | run with no `FGC_HARDWARE`: startup names `/etc/fire_gimbal_control/hardware.ini` | ✅ |
| Camera enumerates | `ListCameras_VmbC` reports the `DEV_` id in `hardware.ini` | ✅ `DEV_1AB22C0B7D4E` |
| Camera profile applies | startup logs `profile 'standard' applied ... (N settings)` | ✅ 17206 settings |
| Firmware identity | `gimbal dump` reports the `GIT_REV` you flashed | ✅ `build=aab1925-dirty` |
| Driver answers over SPI | boot banner shows `IOIN Y=0x… version=0x30` | ✅ |
| Remote re-flash | `BOOTSEL`, then `./deploy.sh` - no hands on the board | ✅ |
| Declared hardware matches | startup logs no presence warnings | ✅ no shadowed-key warnings |
| Homing repeatable | `gimbal home` several times, endstop counts within `home_spread_max_counts` | ❌ **not attempted** - unknown drivetrain |
| Images land | `.jxl` files appear under the configured output dir | ❌ needs a capture run |
| Survives a disconnect | `scripts/fgc start`, close ssh, reconnect, `scripts/fgc status` | ❌ not attempted |
| Reports to a broker | topics arrive at the configured broker | ❌ no broker on this tower yet |

29
hardware/README.md Normal file
View File

@ -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 |

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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'"

View File

@ -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"

View File

@ -56,6 +56,40 @@ struct HardwareConfig {
std::string axesLabel() const; std::string axesLabel() const;
}; };
// [Motor.<axis>] - what this tower's drivetrain IS, one block per axis. These
// change only when the motor, gearbox or driver board changes, so they belong in
// hardware.ini beside the rest of the assembly description.
//
// Currents are milliamps RMS per phase, NOT register values: the conversion needs
// the sense resistor fitted to this particular driver board, so the host owns it
// and sends the firmware the registers it computed (see TmcCurrent.h).
struct AxisDrivetrainConfig {
bool declared = false; // any [Motor.<axis>] key present at all
double r_sense_ohm = 0.0; // sense resistor on this axis's TMC board
double run_current_ma = 0.0; // 0 = undeclared -> nothing is sent
double homing_current_ma = 0.0; // defaults to run_current_ma
// Mechanical angle between the hard endstops. A nameplate fact, not a
// calibration: homing measures the travel in counts, and dividing by this
// yields [Motor] <axis>_counts_per_deg, which lives in config.ini and is
// saved with history. Keeping them in different files AND different keys is
// deliberate - hardware.ini is merged over config.ini and wins, so a shared
// key would silently discard every saved calibration.
double travel_deg = 0.0; // 0 = undeclared -> nothing is derived
// True when the homing current is genuinely lower than the run current, i.e.
// when the reduction is load-bearing and homing without it risks the rig.
bool reducesForHoming() const {
return run_current_ma > 0.0 && homing_current_ma > 0.0 &&
homing_current_ma < run_current_ma;
}
};
struct DrivetrainConfig {
AxisDrivetrainConfig yaw;
AxisDrivetrainConfig pitch;
};
struct NetworkConfig { struct NetworkConfig {
std::string broker_ip = "127.0.0.1"; // MQTT broker host/IP, or a full tcp://... URI std::string broker_ip = "127.0.0.1"; // MQTT broker host/IP, or a full tcp://... URI
int mqtt_port = 1883; // ignored when broker_ip carries its own port int mqtt_port = 1883; // ignored when broker_ip carries its own port
@ -220,6 +254,11 @@ struct AppConfig {
LoggingConfig logging; LoggingConfig logging;
UiConfig ui; // [UI] terminal dashboard toggle UiConfig ui; // [UI] terminal dashboard toggle
Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch) Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch)
DrivetrainConfig drivetrain; // [Motor.<axis>] from hardware.ini
// [Motor] how long a full two-end homing may take before the host calls it
// failed. A property of the drivetrain, not of this program: a high gear
// ratio searching at a gentle speed takes minutes.
int homing_timeout_s = 65;
// [Motor] clearance kept from each hard endstop, in counts. Sent to the // [Motor] clearance kept from each hard endstop, in counts. Sent to the
// firmware (MARGIN), which enforces it; kept in counts rather than degrees // firmware (MARGIN), which enforces it; kept in counts rather than degrees
// because it must apply at homing time, before any degree map is trusted. // because it must apply at homing time, before any degree map is trusted.

View File

@ -21,6 +21,16 @@ struct DumpAxis {
long center_count = 0; // count the firmware anchored the homed midpoint to long center_count = 0; // count the firmware anchored the homed midpoint to
long hold_target = 0; long hold_target = 0;
long speed = 0; long speed = 0;
// Motor current, as the firmware believes it applied it. GLOBALSCALER and
// IHOLD_IRUN are write-only on the TMC5160, so this echo is the only way the
// host can confirm a CURRENT command landed.
//
// -1 means the field was absent, i.e. the firmware predates the command -
// which is a different thing from "applied a current of zero" and has to stay
// distinguishable, because one is a version skew and the other is a fault.
long global_scaler = -1;
long run_irun = -1;
long homing_irun = -1;
// Encoder-tracking health accumulated by the firmware hold corrector. // Encoder-tracking health accumulated by the firmware hold corrector.
long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET
long hold_peak_dev = 0; // largest deviation seen while holding (counts) long hold_peak_dev = 0; // largest deviation seen while holding (counts)

50
include/fgc/TmcCurrent.h Normal file
View File

@ -0,0 +1,50 @@
#pragma once
//
// TmcCurrent — milliamps to TMC5160 register fields.
//
// The driver expresses coil current as two dimensionless knobs: GLOBALSCALER
// (0x0B) scales the whole bridge, and IRUN (a field of IHOLD_IRUN, 0x10) scales
// within that. What a given pair actually delivers depends on the sense resistor
// soldered to the board, which is why this lives on the host: hardware.ini knows
// the resistor, the firmware does not.
//
// I_rms = (GLOBALSCALER/256) * ((IRUN+1)/32) * (V_FS / (R_SENSE + 0.02)) / sqrt(2)
//
// V_FS is 0.325 V for the DRVCONF the firmware writes, and the 0.02 ohm is the
// TMC5160's internal contribution in series with the external resistor.
//
#include <string>
#include <vector>
namespace fgc {
// RMS phase current in milliamps for a given register pair. Returns 0 for a
// non-positive sense resistor rather than dividing by something meaningless.
double tmcRmsCurrentMa(int global_scaler, int irun, double r_sense_ohm,
double v_fs = 0.325);
struct TmcCurrentPlan {
bool ok = false;
int global_scaler = 0; // 32..255. Never 0: on the TMC that means 256, i.e.
// FULL SCALE, so a defaulted or truncated zero would
// silently mean maximum current.
int run_irun = 0; // 0..31, normal motion
int homing_irun = 0; // 0..31, endstop search only
// What the chosen registers actually deliver — always recomputed, never the
// requested value echoed back, so the log cannot flatter the result.
double run_ma = 0.0;
double homing_ma = 0.0;
std::vector<std::string> warnings; // caller logs each at WARN
};
// One GLOBALSCALER serves both currents: it is chosen so the RUN current lands at
// IRUN = 31 (microstep resolution is best at the top of the IRUN range, and that
// is where the axis spends its working life), and the homing IRUN is then derived
// at that same scaler. The firmware therefore switches modes with a single
// register write, and never re-scales a moving motor.
TmcCurrentPlan planTmcCurrent(double r_sense_ohm, double run_ma, double homing_ma,
double v_fs = 0.325);
} // namespace fgc

View File

@ -289,11 +289,22 @@ struct CalibAxisView {
double r2 = 0; double r2 = 0;
int n = 0; int n = 0;
}; };
// The degrees<->counts map an axis is actually using right now: from config.ini
// at startup, replaced in memory by a fit or a homing derivation. Distinct from
// CalibAxisView, which describes the last FIT and only exists once one has run.
struct AxisMapView {
double counts_per_deg = 0;
double deg_at_center = 0;
};
struct CalibResultView { struct CalibResultView {
bool has = false; bool has = false; // has a fit run THIS SESSION?
long long ts_ms = 0; long long ts_ms = 0;
bool pitch_present = false; bool pitch_present = false;
CalibAxisView yaw, pitch; CalibAxisView yaw, pitch;
// Always populated. "No fit this session" is not the same as "uncalibrated":
// the working calibration usually comes from config.ini and predates the run.
AxisMapView live_yaw, live_pitch;
}; };
// Worst-case encoder tracking error from the last DIAG run, per axis, for the // Worst-case encoder tracking error from the last DIAG run, per axis, for the

199
scripts/export_camera_profile.py Executable file
View File

@ -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())

View File

@ -9,6 +9,7 @@
#include "fgc/HelpText.h" #include "fgc/HelpText.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include "fgc/CameraProfile.h" #include "fgc/CameraProfile.h"
#include "fgc/TmcCurrent.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h" #include "fgc/IEnvSensor.h"
@ -237,6 +238,9 @@ struct Application::Impl {
CalibResultView last_calib_view; CalibResultView last_calib_view;
std::vector<std::string> last_calib_summary; std::vector<std::string> last_calib_summary;
long long last_calib_ts = 0; // epoch ms long long last_calib_ts = 0; // epoch ms
// Motor-current registers computed once from hardware.ini, sent to the
// firmware before every home, and checked against the DUMP echo afterwards.
TmcCurrentPlan yaw_current_, pitch_current_;
bool calib_save_pending_ = false; // awaiting y/n to persist to config bool calib_save_pending_ = false; // awaiting y/n to persist to config
// Which command produced the pending values, recorded into the `;prev` // Which command produced the pending values, recorded into the `;prev`
// comment when they are saved so the file says what superseded what. // comment when they are saved so the file says what superseded what.
@ -903,6 +907,9 @@ struct Application::Impl {
// --- Activity strip + last calibration --- // --- Activity strip + last calibration ---
s.calib = last_calib_view; s.calib = last_calib_view;
s.calib.pitch_present = s.gimbal.pitch_present || s.calib.pitch_present; s.calib.pitch_present = s.gimbal.pitch_present || s.calib.pitch_present;
// The map in force right now, whether or not anything was fitted today.
s.calib.live_yaw = {cfg.geometry.yaw.counts_per_deg, cfg.geometry.yaw.deg_at_center};
s.calib.live_pitch = {cfg.geometry.pitch.counts_per_deg, cfg.geometry.pitch.deg_at_center};
s.diag = last_diag_view; s.diag = last_diag_view;
s.test = last_test_view; s.test = last_test_view;
fillActivity(s); fillActivity(s);
@ -987,7 +994,12 @@ struct Application::Impl {
// Pending yes/no: persist the just-applied calibration as the new default? // Pending yes/no: persist the just-applied calibration as the new default?
if (calib_save_pending_) if (calib_save_pending_)
a.prompt = "Save the new [Motor] calibration to config as the default? (y / n)"; // Name the source: a rough derivation and a landmark fit both arrive
// here, and they do not deserve the same confidence.
a.prompt = "Save the new [Motor] calibration (" +
(calib_save_source_.empty() ? std::string("calibration")
: calib_save_source_) +
") to config as the default? (y / n)";
} }
void publishSnapshot() { void publishSnapshot() {
@ -1031,12 +1043,32 @@ struct Application::Impl {
} clear_homing{homing_active_}; } clear_homing{homing_active_};
motor->sendCommand("ENABLE Y"); motor->sendCommand("ENABLE Y");
motor->sendCommand("ENABLE P"); // Only address axes this tower actually has. A yaw-only rig answers
// "ERR axis not present" to every pitch command, which arrives in the log
// as a row of warnings at every startup - noise that trains an operator to
// ignore exactly the channel that reports real faults.
if (cfg.hardware.hasAxis("pitch")) motor->sendCommand("ENABLE P");
// Tell the firmware where to anchor the homed midpoint and how much // Tell the firmware where to anchor the homed midpoint and how much
// clearance to keep from each endstop, BEFORE homing: finishHoming() uses // clearance to keep from each endstop, BEFORE homing: finishHoming() uses
// the centre count to re-origin, so a late send would apply to the next // the centre count to re-origin, so a late send would apply to the next
// home, not this one. // home, not this one.
sendAnchorConfig(); sendAnchorConfig();
// Confirm the current landed BEFORE driving into anything. This is the
// only check standing between a stale firmware and an endstop that cannot
// survive full torque.
// Only when a reduction is actually depended on. An axis running at one
// current for everything has nothing to lose if the command went astray,
// and the check costs a DUMP round trip whose output floods the log.
if (cfg.drivetrain.yaw.reducesForHoming() || cfg.drivetrain.pitch.reducesForHoming()) {
if (auto dd = freshDump(std::chrono::seconds(3))) {
if (!verifyCurrentAdoption(*dd)) return;
} else {
LOG_WARN << "no firmware dump before homing; cannot confirm the motor "
"current was adopted";
}
}
motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking
auto allReady = [](const MotorTelemetry& t) { auto allReady = [](const MotorTelemetry& t) {
@ -1057,7 +1089,12 @@ struct Application::Impl {
// Wait for completion (both axes READY again) or the homing timeout. // Wait for completion (both axes READY again) or the homing timeout.
bool homed = false; bool homed = false;
const auto deadline = std::chrono::steady_clock::now() + 65s; // How long a full two-end search may take is a property of the drivetrain,
// not of this program: a high gear ratio homing at a deliberately gentle
// speed takes minutes, and a fixed 65 s ceiling reported those towers as
// failed while the firmware was still searching perfectly happily.
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(cfg.homing_timeout_s);
while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) { while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(250ms); std::this_thread::sleep_for(250ms);
drainAndDiscardCommands(); drainAndDiscardCommands();
@ -1079,23 +1116,124 @@ struct Application::Impl {
motor->sendCommand("STOP ALL"); motor->sendCommand("STOP ALL");
return; return;
} }
// Re-anchor the yaw zero to an endstop now that the homed limits are known. // Re-anchor the yaw zero to an endstop now that the homed limits are known,
if (homed) applyHomedTravel(); // and take the chance to check the degree scale against the travel we just
// measured. Derivation first: applyHomedTravel() converts the soft limits
// through the map, so it wants the corrected scale.
if (homed) {
using namespace std::chrono_literals;
if (auto dd = freshDump(3s)) {
deriveCountsPerDeg(*dd);
applyHomedTravel(&*dd);
} else {
LOG_WARN << "no firmware dump after homing; degree limits and the derived "
"counts_per_deg both skipped";
}
}
// Production move speeds for subsequent MOVE commands. // Production move speeds for subsequent MOVE commands.
motor->sendCommand("SPEED Y 50000"); motor->sendCommand("SPEED Y 50000");
motor->sendCommand("SPEED P 150000"); if (cfg.hardware.hasAxis("pitch")) motor->sendCommand("SPEED P 150000");
LOG_INFO << "Gimbal init finished"; LOG_INFO << "Gimbal init finished";
} }
// Convert this tower's declared milliamps into TMC register fields, once.
// The host owns this because hardware.ini is where the sense resistor lives;
// the firmware is told registers and keeps working compile-time defaults.
void planMotorCurrents() {
auto plan = [&](const char* name, const AxisDrivetrainConfig& d, TmcCurrentPlan& out) {
if (!d.declared || d.run_current_ma <= 0.0) return;
out = planTmcCurrent(d.r_sense_ohm, d.run_current_ma, d.homing_current_ma);
for (const auto& w : out.warnings) LOG_WARN << name << " motor current: " << w;
if (!out.ok) {
LOG_ERROR << name << " motor current could not be computed; the firmware "
"will keep its built-in defaults";
return;
}
// Log what the registers actually deliver, not what was asked for.
LOG_INFO << name << " motor current: run " << std::lround(out.run_ma)
<< " mA (GLOBALSCALER " << out.global_scaler << ", IRUN " << out.run_irun
<< "), homing " << std::lround(out.homing_ma) << " mA (IRUN "
<< out.homing_irun << ") -- R_sense " << d.r_sense_ohm
<< " ohm from hardware.ini [Motor." << name << ']';
};
plan("yaw", cfg.drivetrain.yaw, yaw_current_);
plan("pitch", cfg.drivetrain.pitch, pitch_current_);
}
// Did the firmware actually adopt the current we sent? GLOBALSCALER and
// IHOLD_IRUN are write-only on the TMC5160, so the DUMP echo is the only
// channel - and sendCommand() is fire-and-forget, so an 'ERR unknown command'
// from an older firmware arrives as an uncorrelated warning we cannot pin to
// this command. Returns false only when a reduction was REQUIRED and is
// demonstrably absent.
bool verifyCurrentAdoption(const DumpData& dd) {
bool safe = true;
auto check = [&](const char* name, char axis, const AxisDrivetrainConfig& d,
const TmcCurrentPlan& want) {
if (!want.ok) return;
const DumpAxis* ax = nullptr;
for (const auto& a : dd.axes)
if (a.axis == axis) ax = &a;
if (!ax) return;
const bool absent = (ax->run_irun < 0);
const bool wrong = !absent && (ax->global_scaler != want.global_scaler ||
ax->run_irun != want.run_irun ||
ax->homing_irun != want.homing_irun);
if (!absent && !wrong) return;
std::ostringstream detail;
if (absent)
detail << "the firmware does not report a motor current at all, so it "
"predates the CURRENT command";
else
detail << "the firmware reports GLOBALSCALER " << ax->global_scaler << ", IRUN "
<< ax->run_irun << '/' << ax->homing_irun << " but was sent "
<< want.global_scaler << ", IRUN " << want.run_irun << '/'
<< want.homing_irun;
if (d.reducesForHoming()) {
// The reduction is load-bearing: this tower's endstop yields to
// full torque. Homing anyway is how the stop gets destroyed.
LOG_ERROR << name << " motor current was NOT adopted -- " << detail.str()
<< ". Refusing to home: this axis declares a reduced homing "
"current because its endstop cannot survive full torque. "
"Flash the firmware, or remove homing_current_ma from "
"hardware.ini to accept the risk deliberately.";
safe = false;
} else {
LOG_WARN << name << " motor current was not adopted -- " << detail.str()
<< ". Continuing: this axis asks for no reduction, so homing is "
"unaffected.";
}
};
check("yaw", 'Y', cfg.drivetrain.yaw, yaw_current_);
check("pitch", 'P', cfg.drivetrain.pitch, pitch_current_);
return safe;
}
// Tell the firmware the anchor point and endstop clearance for each axis. // Tell the firmware the anchor point and endstop clearance for each axis.
// The centre count is what finishHoming() re-origins onto, so all counts stay // The centre count is what finishHoming() re-origins onto, so all counts stay
// positive and reproducible across power cycles; the margin is what // positive and reproducible across power cycles; the margin is what
// clampTarget() keeps clear of each hard limit. // clampTarget() keeps clear of each hard limit.
void sendAnchorConfig() { void sendAnchorConfig() {
motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count)); motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count));
motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count));
motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts)); motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts));
motor->sendCommand("MARGIN P " + std::to_string(cfg.pitch_soft_margin_counts)); if (cfg.hardware.hasAxis("pitch")) {
motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count));
motor->sendCommand("MARGIN P " + std::to_string(cfg.pitch_soft_margin_counts));
}
// Motor current belongs here too: it governs homing torque, so it has to
// land before the search, not after it.
auto sendCurrent = [&](char axis, const TmcCurrentPlan& p) {
if (!p.ok) return;
motor->sendCommand(std::string("CURRENT ") + axis + ' ' +
std::to_string(p.global_scaler) + ' ' +
std::to_string(p.run_irun) + ' ' +
std::to_string(p.homing_irun));
};
sendCurrent('Y', yaw_current_);
sendCurrent('P', pitch_current_);
} }
// Derive each axis's reachable degree window from the freshly homed soft // Derive each axis's reachable degree window from the freshly homed soft
@ -1109,24 +1247,34 @@ struct Application::Impl {
// not take effect - firmware predating it answers "ERR unknown command" and // not take effect - firmware predating it answers "ERR unknown command" and
// homes to its own compiled default, which would leave every published // homes to its own compiled default, which would leave every published
// bearing silently wrong. // bearing silently wrong.
void applyHomedTravel() { // Request a DUMP and wait briefly for a parseable one. Several callers need
// the firmware's own view of itself - the homed limits, and whether it adopted
// what we sent - so the round trip lives in one place.
std::optional<DumpData> freshDump(std::chrono::milliseconds wait) {
using namespace std::chrono_literals; using namespace std::chrono_literals;
// The homed limits live in the firmware DUMP; pull a fresh one and wait
// briefly for it to arrive.
motor->sendCommand("DUMP"); motor->sendCommand("DUMP");
DumpData dd; const auto deadline = std::chrono::steady_clock::now() + wait;
const auto deadline = std::chrono::steady_clock::now() + 3s;
while (running && std::chrono::steady_clock::now() < deadline) { while (running && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(100ms); std::this_thread::sleep_for(100ms);
publishSnapshot(); publishSnapshot();
dd = parseDump(motor->lastDump()); DumpData dd = parseDump(motor->lastDump());
if (dd.valid) break; if (dd.valid) return dd;
} }
if (!dd.valid) { return std::nullopt;
LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits"; }
return;
void applyHomedTravel(const DumpData* pre = nullptr) {
using namespace std::chrono_literals;
std::optional<DumpData> got;
if (!pre) {
got = freshDump(3s);
if (!got) {
LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits";
return;
}
} }
const DumpData& dd = pre ? *pre : *got;
bool any = false; bool any = false;
for (const auto& a : dd.axes) { for (const auto& a : dd.axes) {
@ -1386,6 +1534,71 @@ struct Application::Impl {
return static_cast<bool>(iss >> a >> b); return static_cast<bool>(iss >> a >> b);
} }
// Cross-check the degree scale against the travel homing just measured, and
// bootstrap it when it is obviously wrong.
//
// Called ONLY from the homing path. applyHomedTravel() runs after every
// heading fit and offset too, and deriving there would overwrite a landmark
// calibration with this rougher figure on every tweak.
void deriveCountsPerDeg(const DumpData& dd) {
struct AxisRef {
const char* name;
char axis;
const AxisDrivetrainConfig& drive;
AxisMap& map;
};
AxisRef axes[] = {
{"yaw", 'Y', cfg.drivetrain.yaw, cfg.geometry.yaw},
{"pitch", 'P', cfg.drivetrain.pitch, cfg.geometry.pitch},
};
bool changed = false;
for (auto& a : axes) {
if (a.drive.travel_deg <= 0.0) continue; // nothing declared to divide by
// Measured travel from THIS dump. Taking it from motor->lastDump()
// instead would read whatever dump happened to be cached - which,
// before applyHomedTravel() fetches a fresh one, is the pre-homing
// dump whose limits are still zero, so the derivation would skip in
// silence on every single home.
long counts = 0;
for (const auto& ax : dd.axes)
if (ax.axis == a.axis) counts = std::labs(ax.lim_pos - ax.lim_neg);
if (counts <= 0) continue; // that axis did not home
// The angle is unsigned; which way the axis counts is a property of
// the map, so carry the existing sign rather than inventing one.
const double derived =
std::copysign(counts / a.drive.travel_deg, a.map.counts_per_deg);
const double current = a.map.counts_per_deg;
const double ratio =
std::fabs(current) > 1e-9 ? std::fabs(derived / current) : 1e9;
// Only bootstrap a scale that is obviously wrong. Within a factor of
// two this is a health check, not a correction: a landmark fit is a
// better measurement than this one and must not be clobbered by a
// routine re-home.
if (ratio > 2.0 || ratio < 0.5) {
LOG_WARN << a.name << " counts_per_deg derived from homing: " << derived
<< " (was " << current << ") = " << counts << " counts / "
<< a.drive.travel_deg << " deg of travel declared in hardware.ini ["
<< "Motor." << a.name << "] travel_deg. That angle is a NAMEPLATE "
"figure: every bearing carries its error until it is measured. "
"Run 'gimbal heading setup' to supersede this with a landmark fit.";
a.map.counts_per_deg = derived;
changed = true;
} else {
LOG_INFO << a.name << " counts_per_deg " << current << " agrees with the homed "
<< "travel (" << counts << " counts / " << a.drive.travel_deg
<< " deg = " << derived << "); leaving the calibration alone";
}
}
if (changed && !opts.config_path.empty()) {
calib_save_source_ = "homed travel / hardware.ini travel_deg (nameplate)";
calib_save_pending_ = true;
}
}
// Homed physical travel (counts) for an axis: the endstop-to-endstop span from // Homed physical travel (counts) for an axis: the endstop-to-endstop span from
// the last firmware dump. Returns 0 if no dump has been captured yet (caller // the last firmware dump. Returns 0 if no dump has been captured yet (caller
// should request one). Deliberately does NOT fall back to the configured degree // should request one). Deliberately does NOT fall back to the configured degree
@ -1408,7 +1621,8 @@ struct Application::Impl {
while (iss >> t) tok.push_back(t); // tok[0] == "gimbal" while (iss >> t) tok.push_back(t); // tok[0] == "gimbal"
if (tok.size() < 2) { if (tok.size() < 2) {
LOG_WARN << "usage: gimbal <move|steps|nudge|jog|heading|pitch|home|stop|reset|" LOG_WARN << "usage: gimbal <move|steps|nudge|jog|heading|pitch|home|stop|reset|"
"enable|disable|speed|setpos|status|dump|raw> (self-test: 'test')"; "enable|disable|speed|current|setpos|status|dump|raw> "
"(self-test: 'test')";
return; return;
} }
std::string sub = tok[1]; std::string sub = tok[1];
@ -1505,7 +1719,8 @@ struct Application::Impl {
// home rather than a fire-and-forget HOME that skips the re-anchor. // home rather than a fire-and-forget HOME that skips the re-anchor.
runInitSequence(); runInitSequence();
} else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" || } else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" ||
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") { sub == "disable" || sub == "speed" || sub == "setpos" ||
sub == "status" || sub == "current") {
// Passthrough to the (case-insensitive) firmware verb + args. // Passthrough to the (case-insensitive) firmware verb + args.
std::string fw = sub; std::string fw = sub;
for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch)); for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch));
@ -2102,6 +2317,9 @@ struct Application::Impl {
// the wait is the longest across them, not the first one's. // the wait is the longest across them, not the first one's.
scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs()); scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
// Before the firmware is talked to: the registers are wanted by the very
// first sendAnchorConfig(), on both the --init and the no---init path.
planMotorCurrents();
motor->start(); motor->start();
if (imu) imu->start(); if (imu) imu->start();
if (env) env->start(); if (env) env->start();

View File

@ -419,6 +419,27 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.pitch_soft_margin_counts = getLong(kv, "Motor.pitch_soft_margin_counts", cfg.pitch_soft_margin_counts); cfg.pitch_soft_margin_counts = getLong(kv, "Motor.pitch_soft_margin_counts", cfg.pitch_soft_margin_counts);
cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts", cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts",
cfg.enc_error_warn_counts); cfg.enc_error_warn_counts);
cfg.homing_timeout_s = getInt(kv, "Motor.homing_timeout_s", cfg.homing_timeout_s);
// [Motor.<axis>]: this tower's drivetrain, normally from hardware.ini. Note
// the section is DOTTED - these are deliberately different keys from the flat
// [Motor] ones above, because hardware.ini is merged over config.ini and
// wins, so a shared key would silently discard the calibration that the save
// path writes back to config.ini.
auto axisDrivetrain = [&kv](const char* axis, AxisDrivetrainConfig& d) {
const std::string pre = std::string("Motor.") + axis + ".";
for (const auto& [k, v] : kv) {
(void)v;
if (k.rfind(pre, 0) == 0) { d.declared = true; break; }
}
d.r_sense_ohm = getDouble(kv, pre + "r_sense_ohm", d.r_sense_ohm);
d.run_current_ma = getDouble(kv, pre + "run_current_ma", d.run_current_ma);
// An axis that names only a run current is not asking for a reduction.
d.homing_current_ma = getDouble(kv, pre + "homing_current_ma", d.run_current_ma);
d.travel_deg = getDouble(kv, pre + "travel_deg", d.travel_deg);
};
axisDrivetrain("yaw", cfg.drivetrain.yaw);
axisDrivetrain("pitch", cfg.drivetrain.pitch);
// [Scan]: scan-grid source (explicit CSV file, else generated). // [Scan]: scan-grid source (explicit CSV file, else generated).
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
@ -450,6 +471,27 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
throw std::runtime_error("Motor.yaw_soft_margin_counts must be >= 0"); throw std::runtime_error("Motor.yaw_soft_margin_counts must be >= 0");
if (cfg.pitch_soft_margin_counts < 0) if (cfg.pitch_soft_margin_counts < 0)
throw std::runtime_error("Motor.pitch_soft_margin_counts must be >= 0"); throw std::runtime_error("Motor.pitch_soft_margin_counts must be >= 0");
if (cfg.homing_timeout_s <= 0)
throw std::runtime_error("Motor.homing_timeout_s must be > 0");
// [Motor.<axis>]. Currents are only meaningful alongside the sense resistor
// that scales them, and a current without one would be computed against a
// guess - so demand them together rather than inventing a default resistor.
auto checkDrivetrain = [](const char* axis, const AxisDrivetrainConfig& d) {
const std::string sec = std::string("Motor.") + axis;
if (d.run_current_ma > 0.0 && d.r_sense_ohm <= 0.0)
throw std::runtime_error(sec + ".run_current_ma needs " + sec +
".r_sense_ohm (the sense resistor on that driver "
"board) to mean anything");
if (d.run_current_ma < 0.0 || d.homing_current_ma < 0.0)
throw std::runtime_error(sec + " currents must be >= 0");
if (d.travel_deg < 0.0 || d.travel_deg > 720.0)
throw std::runtime_error(sec + ".travel_deg must be within (0, 720]; the "
"direction of travel is carried by the sign of "
"counts_per_deg, not by this angle");
};
checkDrivetrain("yaw", cfg.drivetrain.yaw);
checkDrivetrain("pitch", cfg.drivetrain.pitch);
// Keys from the pre-centre-anchoring layout. Silently ignoring them would // Keys from the pre-centre-anchoring layout. Silently ignoring them would
// leave an operator convinced they had configured a heading that is in fact // leave an operator convinced they had configured a heading that is in fact
@ -458,15 +500,22 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
"Motor.yaw_zero_count", "Motor.pitch_zero_count", "Motor.yaw_min_deg", "Motor.yaw_zero_count", "Motor.pitch_zero_count", "Motor.yaw_min_deg",
"Motor.yaw_max_deg", "Motor.pitch_min_deg", "Motor.pitch_max_deg", "Motor.yaw_max_deg", "Motor.pitch_min_deg", "Motor.pitch_max_deg",
"Motor.yaw_home_zero", "Motor.yaw_home_zero",
// Drivetrain keys written under [Motor] instead of [Motor.<axis>]. They
// flatten to a name nothing reads, so without this they would be ignored
// in total silence - and an operator would believe they had reduced the
// homing current when they had not.
"Motor.r_sense_ohm", "Motor.run_current_ma",
"Motor.homing_current_ma", "Motor.travel_deg",
}; };
std::string stale; std::string stale;
for (const char* k : kRemovedMotorKeys) for (const char* k : kRemovedMotorKeys)
if (kv.count(k)) stale += (stale.empty() ? "" : ", ") + std::string(k + 6); if (kv.count(k)) stale += (stale.empty() ? "" : ", ") + std::string(k + 6);
if (!stale.empty()) if (!stale.empty())
LOG_WARN << "[Motor] ignoring keys removed in the centre-anchored layout: " << stale LOG_WARN << "[Motor] ignoring keys nothing reads: " << stale
<< " -- travel limits are now derived from homing, and the heading " << " -- travel limits are derived from homing, the heading origin "
"origin comes from yaw_heading_at_center (set it with " "comes from yaw_heading_at_center (set it with 'gimbal heading "
"'gimbal heading setup')"; "setup'), and drivetrain keys belong under [Motor.yaw] / "
"[Motor.pitch], not [Motor]";
return cfg; return cfg;
} }
@ -559,6 +608,34 @@ const std::string kPrevMarker = ";prev ";
// INI_INLINE_COMMENT_PREFIXES=";"). Mirroring that rule matters here: "did this // INI_INLINE_COMMENT_PREFIXES=";"). Mirroring that rule matters here: "did this
// value change?" has to compare what the program read, not the raw line, or a // value change?" has to compare what the program read, not the raw line, or a
// key carrying a trailing comment would look changed on every save. // key carrying a trailing comment would look changed on every save.
// Do these two INI values denote the same NUMBER, however they are spelled?
//
// The history filter below compares what is in the file against what is about to
// be written, and the writer formats doubles with "%.10g" - so a hand-written
// "0.0" is rewritten as "0", and a plain string comparison calls that a change.
// It then records a ";prev" line whose "previous" value is the value it still
// has, stamped with the provenance of whatever save happened to be running.
//
// That is worse than cosmetic noise: ";prev" deliberately keeps exactly ONE
// generation, so a spurious entry DISPLACES the real previous value it exists to
// preserve, and attributes an untouched key to a calibration that never wrote it.
//
// Falls back to comparing text whenever either side is not a plain number, which
// is what keys like a device path need.
bool sameIniValue(const std::string& a, const std::string& b) {
if (a == b) return true;
auto asNumber = [](const std::string& s, double& out) {
if (s.empty()) return false;
char* end = nullptr;
out = std::strtod(s.c_str(), &end);
if (end == s.c_str()) return false; // nothing numeric at all
while (*end && std::isspace(static_cast<unsigned char>(*end))) ++end;
return *end == '\0'; // trailing junk: not a number
};
double x = 0.0, y = 0.0;
return asNumber(a, x) && asNumber(b, y) && x == y;
}
std::string iniValueOf(const std::string& rhs) { std::string iniValueOf(const std::string& rhs) {
bool was_space = false; bool was_space = false;
for (size_t i = 0; i < rhs.size(); ++i) { for (size_t i = 0; i < rhs.size(); ++i) {
@ -612,7 +689,7 @@ std::string updateIniSectionKeys(const std::string& contents, const std::string&
if (!seen[i] && key == kv[i].first) { if (!seen[i] && key == kv[i].first) {
seen[i] = true; seen[i] = true;
old_value[i] = iniValueOf(line.substr(eq + 1)); old_value[i] = iniValueOf(line.substr(eq + 1));
changed[i] = old_value[i] != kv[i].second; changed[i] = !sameIniValue(old_value[i], kv[i].second);
break; break;
} }
} }

View File

@ -162,6 +162,9 @@ DumpData parseDump(const std::string& block) {
ax.center_count = asInt(get(m, "center", "0")); ax.center_count = asInt(get(m, "center", "0"));
ax.hold_target = asInt(get(m, "hold_target", "0")); ax.hold_target = asInt(get(m, "hold_target", "0"));
ax.speed = asInt(get(m, "speed", "0")); ax.speed = asInt(get(m, "speed", "0"));
ax.global_scaler = asInt(get(m, "gs", "-1"));
ax.run_irun = asInt(get(m, "irun", "-1"));
ax.homing_irun = asInt(get(m, "irun_home", "-1"));
ax.hold_corrections = asInt(get(m, "hold_corr", "0")); ax.hold_corrections = asInt(get(m, "hold_corr", "0"));
ax.hold_peak_dev = asInt(get(m, "hold_peak", "0")); ax.hold_peak_dev = asInt(get(m, "hold_peak", "0"));
ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0")); ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0"));

View File

@ -74,6 +74,12 @@ const std::vector<HelpSection>& helpCatalog() {
"the whole 'gimbal' family is ignored while one is in progress."}}, "the whole 'gimbal' family is ignored while one is in progress."}},
{"gimbal speed <y|p> <vel>", {"gimbal speed <y|p> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}}, "Set the max slew speed (counts/s) for an axis.", {}},
{"gimbal current <y|p> <scaler> <irun> <irun_home>",
"Set motor current, as raw TMC register fields.",
{"Normally sent automatically from hardware.ini [Motor.<axis>], which states",
"the current in milliamps and lets the host convert it using this board's",
"sense resistor. Setting it by hand does not stay secret: the next home",
"checks the firmware's reported current against the configured one."}},
{"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>", {"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>",
"Other firmware motor controls.", {}}, "Other firmware motor controls.", {}},
}}, }},

118
src/core/TmcCurrent.cpp Normal file
View File

@ -0,0 +1,118 @@
#include "fgc/TmcCurrent.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace fgc {
namespace {
// Formatting helper: milliamps to one decimal, for warning text that has to name
// both what was asked for and what the hardware can actually do.
std::string ma(double v) {
std::ostringstream os;
os.setf(std::ios::fixed);
os.precision(1);
os << v;
return os.str();
}
constexpr int kGsMin = 32; // below this the datasheet disallows GLOBALSCALER
constexpr int kGsMax = 255; // 0 would mean 256 (full scale) - never emitted
constexpr int kIrunMax = 31;
} // namespace
double tmcRmsCurrentMa(int global_scaler, int irun, double r_sense_ohm, double v_fs) {
if (r_sense_ohm <= 0.0) return 0.0;
const double gs = (global_scaler <= 0) ? 256.0 : static_cast<double>(global_scaler);
return (gs / 256.0) * ((irun + 1) / 32.0) * (v_fs / (r_sense_ohm + 0.02)) /
std::sqrt(2.0) * 1000.0;
}
TmcCurrentPlan planTmcCurrent(double r_sense_ohm, double run_ma, double homing_ma,
double v_fs) {
TmcCurrentPlan p;
if (r_sense_ohm <= 0.0) {
p.warnings.push_back("sense resistor must be positive, got " +
ma(r_sense_ohm) + " ohm");
return p;
}
if (run_ma <= 0.0) {
p.warnings.push_back("run current must be positive, got " + ma(run_ma) + " mA");
return p;
}
if (homing_ma <= 0.0) {
p.warnings.push_back("homing current must be positive, got " + ma(homing_ma) + " mA");
return p;
}
// The most the bridge can deliver: GLOBALSCALER 256, IRUN 31.
const double i_max = tmcRmsCurrentMa(0, kIrunMax, r_sense_ohm, v_fs);
// Pick GLOBALSCALER so the RUN current sits at IRUN 31.
double gs_ideal = 256.0 * run_ma / i_max;
if (gs_ideal > kGsMax) {
p.global_scaler = kGsMax;
p.warnings.push_back("run current " + ma(run_ma) + " mA is above the " + ma(i_max) +
" mA this driver can deliver with a " + ma(r_sense_ohm) +
" ohm sense resistor; using the maximum instead");
} else if (gs_ideal < kGsMin) {
// GLOBALSCALER 1..31 is forbidden, so a very small run current has to be
// reached by lowering IRUN instead, at some cost in microstep resolution.
p.global_scaler = kGsMin;
p.warnings.push_back("run current " + ma(run_ma) +
" mA needs GLOBALSCALER below the permitted minimum of 32; "
"holding it at 32 and reducing IRUN instead, which coarsens "
"microstepping");
} else {
p.global_scaler = static_cast<int>(std::lround(gs_ideal));
}
// Full-scale current at the chosen scaler, i.e. what IRUN 31 now means.
const double full = tmcRmsCurrentMa(p.global_scaler, kIrunMax, r_sense_ohm, v_fs);
auto irunFor = [&](double target) {
return std::clamp(static_cast<int>(std::lround(32.0 * target / full)) - 1, 0, kIrunMax);
};
p.run_irun = irunFor(run_ma);
if (homing_ma > run_ma) {
// Not a feature: a homing current above the run current defeats the point
// of having a separate one.
p.homing_irun = p.run_irun;
p.warnings.push_back("homing current " + ma(homing_ma) +
" mA is above the run current " + ma(run_ma) +
" mA; clamping the search to the run current");
} else {
p.homing_irun = irunFor(homing_ma);
if (p.homing_irun > p.run_irun) p.homing_irun = p.run_irun;
}
p.run_ma = tmcRmsCurrentMa(p.global_scaler, p.run_irun, r_sense_ohm, v_fs);
p.homing_ma = tmcRmsCurrentMa(p.global_scaler, p.homing_irun, r_sense_ohm, v_fs);
// The smallest non-zero step this scaler can express. Worth naming when a
// homing target lands under it, because the result is then noticeably
// stronger than asked for - the direction that matters for a yielding stop.
const double step = full / 32.0;
if (p.homing_irun == 0 && homing_ma < step * 0.5) {
p.warnings.push_back("homing current " + ma(homing_ma) +
" mA is below the smallest step this driver can express (" +
ma(step) + " mA); using " + ma(p.homing_ma) + " mA");
}
auto driftWarn = [&](const char* what, double want, double got) {
if (want > 0.0 && std::fabs(got - want) / want > 0.10)
p.warnings.push_back(std::string(what) + " current " + ma(want) +
" mA is not reachable; nearest is " + ma(got) + " mA");
};
driftWarn("run", run_ma, p.run_ma);
if (homing_ma <= run_ma) driftWarn("homing", homing_ma, p.homing_ma);
p.ok = true;
return p;
}
} // namespace fgc

View File

@ -909,7 +909,7 @@ Element axisDumpDetail(const DumpAxis& ax) {
// (incl. RAMP_STAT, the last one) is visible without scrolling. // (incl. RAMP_STAT, the last one) is visible without scrolling.
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d, Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
char letter, bool calib_has, const CalibAxisView& cal, char letter, bool calib_has, const CalibAxisView& cal,
const DiagAxisView* diag, ScrollState& st) { const AxisMapView& live_map, const DiagAxisView* diag, ScrollState& st) {
auto fmt = [](const char* f, double v) { auto fmt = [](const char* f, double v) {
char b[32]; char b[32];
std::snprintf(b, sizeof(b), f, v); std::snprintf(b, sizeof(b), f, v);
@ -957,13 +957,21 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
// Last calibration result for this axis. // Last calibration result for this axis.
col.push_back(separator()); col.push_back(separator());
col.push_back(text("CALIBRATION") | bold | color(Color::Magenta)); col.push_back(text("CALIBRATION") | bold | color(Color::Magenta));
// The values actually in use, always. They are what every bearing is computed
// from, so showing nothing here because no fit happened today told the
// operator the axis was uncalibrated when it was not.
col.push_back(kvRow("cnt/deg", fmt("%.3f", live_map.counts_per_deg)));
col.push_back(kvRow("deg@ctr", fmt("%.3f", live_map.deg_at_center)));
if (calib_has && cal.ok) { if (calib_has && cal.ok) {
col.push_back(kvRow("cnt/deg", fmt("%.3f", cal.counts_per_deg))); // A fit ran this session: add its quality, and flag if the live map has
col.push_back(kvRow("deg@ctr", fmt("%.3f", cal.deg_at_center))); // since moved away from it (a homing derivation, or a manual offset).
col.push_back(kvRow("R2", fmt("%.4f", cal.r2), cal.r2 >= 0.99 ? Color::Green : Color::Yellow)); col.push_back(kvRow("R2", fmt("%.4f", cal.r2), cal.r2 >= 0.99 ? Color::Green : Color::Yellow));
col.push_back(kvRow("points", std::to_string(cal.n))); col.push_back(kvRow("points", std::to_string(cal.n)));
col.push_back(text("from a fit this session") | dim);
} else if (calib_has) {
col.push_back(text("last fit this session FAILED") | color(Color::Yellow));
} else { } else {
col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim); col.push_back(text("from config, no fit this session") | dim);
} }
// Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the // Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the
@ -985,7 +993,7 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
std::string reset; std::string reset;
for (size_t i = 0; i < d.reset_flags.size(); ++i) for (size_t i = 0; i < d.reset_flags.size(); ++i)
reset += (i ? " " : "") + d.reset_flags[i]; reset += (i ? " " : "") + d.reset_flags[i];
std::string calib_note = "uncalibrated this session"; std::string calib_note = "calibration from config";
if (calib.has) { if (calib.has) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>( long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
@ -1002,10 +1010,11 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
}); });
std::vector<Element> cols; std::vector<Element> cols;
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y'), st[0])); cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, calib.live_yaw,
diagFor('Y'), st[0]));
if (g.pitch_present) if (g.pitch_present)
cols.push_back( cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch,
axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P'), st[1])); calib.live_pitch, diagFor('P'), st[1]));
return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]), return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]),
vbox({header, separator(), hbox(std::move(cols)) | flex})); vbox({header, separator(), hbox(std::move(cols)) | flex}));

View File

@ -22,6 +22,7 @@ add_executable(fgc_tests
test_paths.cpp test_paths.cpp
test_singleinstance.cpp test_singleinstance.cpp
test_config.cpp test_config.cpp
test_tmccurrent.cpp
test_cameraprofile.cpp test_cameraprofile.cpp
test_camerasettingsxml.cpp test_camerasettingsxml.cpp
test_hash.cpp test_hash.cpp

View File

@ -596,3 +596,98 @@ TEST_CASE("A mount angle that cannot be a rotation is rejected") {
CHECK_NOTHROW(with("IMU.z", "-360")); CHECK_NOTHROW(with("IMU.z", "-360"));
CHECK_NOTHROW(with("IMU.z", "360")); CHECK_NOTHROW(with("IMU.z", "360"));
} }
TEST_CASE("[Motor.<axis>] drivetrain keys are parsed per axis") {
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.r_sense_ohm", "0.075"},
{"Motor.yaw.run_current_ma", "756"},
{"Motor.yaw.homing_current_ma", "200"},
{"Motor.yaw.travel_deg", "340"},
});
CHECK(cfg.drivetrain.yaw.declared);
CHECK(cfg.drivetrain.yaw.r_sense_ohm == doctest::Approx(0.075));
CHECK(cfg.drivetrain.yaw.run_current_ma == doctest::Approx(756.0));
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(200.0));
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
CHECK(cfg.drivetrain.yaw.reducesForHoming());
// The other axis stays undeclared, so nothing is sent for it.
CHECK_FALSE(cfg.drivetrain.pitch.declared);
}
TEST_CASE("an axis that names only a run current is not asking for a reduction") {
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.r_sense_ohm", "0.075"},
{"Motor.yaw.run_current_ma", "756"},
});
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(756.0));
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
}
TEST_CASE("[Motor.yaw] travel_deg does not shadow [Motor] yaw_counts_per_deg") {
// The whole reason the travel angle is a separate key in a separate section:
// hardware.ini is merged OVER config.ini and wins, so if the derived
// calibration shared a key with the nameplate fact, every saved calibration
// would be silently discarded on the next start.
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw_counts_per_deg", "3561.1"}, // calibration, from config.ini
{"Motor.yaw.travel_deg", "340"}, // nameplate, from hardware.ini
});
CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(3561.1));
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
}
TEST_CASE("a drivetrain current without its sense resistor is refused") {
// Computing registers against a guessed resistor would silently deliver the
// wrong current, which on a rig with a yielding endstop is the whole hazard.
CHECK_THROWS(ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.run_current_ma", "756"},
}));
}
TEST_CASE("an undeclared drivetrain leaves every axis silent") {
auto cfg = ConfigLoader::fromMap({{"General.tower_name", "t"}});
CHECK_FALSE(cfg.drivetrain.yaw.declared);
CHECK_FALSE(cfg.drivetrain.pitch.declared);
CHECK(cfg.drivetrain.yaw.run_current_ma == 0.0);
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
}
TEST_CASE("a value that only changes spelling gets no history line") {
// The writer formats doubles with %.10g, so a hand-written "0.0" comes back
// as "0". That is the same number, and recording it as a change would spend
// the single ";prev" generation on a value that never moved - displacing the
// real previous value the mechanism exists to preserve.
const std::string before = "[Motor]\nyaw_heading_at_center = 0.0\n";
const std::string after =
updateIniSectionKeys(before, "Motor", {{"yaw_heading_at_center", "0"}}, "a save");
CHECK(after.find(";prev") == std::string::npos);
CHECK(after.find("yaw_heading_at_center = 0") != std::string::npos);
}
TEST_CASE("differently spelled equal numbers are not changes") {
for (auto [old_text, new_text] : std::vector<std::pair<std::string, std::string>>{
{"1e3", "1000"}, {"500.0", "500"}, {"0.50", "0.5"}, {" 12 ", "12"}}) {
const std::string out = updateIniSectionKeys("[Motor]\nk = " + old_text + "\n", "Motor",
{{"k", new_text}}, "a save");
CHECK(out.find(";prev") == std::string::npos);
}
}
TEST_CASE("a genuine numeric change still records its history") {
const std::string out = updateIniSectionKeys("[Motor]\nyaw_counts_per_deg = 500\n", "Motor",
{{"yaw_counts_per_deg", "3552.867647"}},
"homed travel");
CHECK(out.find(";prev yaw_counts_per_deg = 500") != std::string::npos);
CHECK(out.find("homed travel") != std::string::npos);
CHECK(out.find("yaw_counts_per_deg = 3552.867647") != std::string::npos);
}
TEST_CASE("non-numeric values still compare as text") {
const std::string out = updateIniSectionKeys("[Serial]\ndevice = /dev/ttyACM0\n", "Serial",
{{"device", "/dev/ttyUSB0"}}, "a save");
CHECK(out.find(";prev device = /dev/ttyACM0") != std::string::npos);
}

View File

@ -136,3 +136,32 @@ TEST_CASE("formatDump renders valid and invalid dumps") {
CHECK(joined.find("[P]") != std::string::npos); CHECK(joined.find("[P]") != std::string::npos);
CHECK(joined.find("RAMP_STAT") != std::string::npos); CHECK(joined.find("RAMP_STAT") != std::string::npos);
} }
TEST_CASE("a DUMP axis line carries the motor current the firmware applied") {
auto d = parseDump(
"DUMP BEGIN build=abc uptime=1 mcusr=0x3 free_ram=100\n"
"DUMP Y state=3 hsub=0 enabled=1 lim_neg=-105389 lim_pos=1105389 hold_target=0 "
"speed=3000 center=500000 soft_margin=5000 gs=80 irun=31 irun_home=7 "
"hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n");
REQUIRE(d.valid);
REQUIRE(d.axes.size() == 1);
CHECK(d.axes[0].global_scaler == 80);
CHECK(d.axes[0].run_irun == 31);
CHECK(d.axes[0].homing_irun == 7);
}
TEST_CASE("a DUMP from firmware without the current fields reports -1, not zero") {
// Zero is a legitimate IRUN; absent is a version skew. Conflating them would
// let an old firmware look like one configured to zero current.
auto d = parseDump(
"DUMP BEGIN build=abc uptime=1 mcusr=0x3 free_ram=100\n"
"DUMP Y state=3 hsub=0 enabled=1 lim_neg=0 lim_pos=0 hold_target=0 speed=3000 "
"center=500000 soft_margin=5000 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"
"DUMP END\n");
REQUIRE(d.valid);
REQUIRE(d.axes.size() == 1);
CHECK(d.axes[0].global_scaler == -1);
CHECK(d.axes[0].run_irun == -1);
CHECK(d.axes[0].homing_irun == -1);
}

105
tests/test_tmccurrent.cpp Normal file
View File

@ -0,0 +1,105 @@
#include "doctest/doctest.h"
#include "fgc/TmcCurrent.h"
#include <cmath>
using namespace fgc;
namespace {
// The IOTA tower's driver board.
constexpr double kR = 0.075;
} // namespace
TEST_CASE("the RMS current formula matches the datasheet") {
// Full scale: GLOBALSCALER 256 (encoded 0), IRUN 31.
CHECK(tmcRmsCurrentMa(0, 31, kR) == doctest::Approx(2419.0).epsilon(0.001));
// The fleet's setting.
CHECK(tmcRmsCurrentMa(80, 31, kR) == doctest::Approx(756.0).epsilon(0.002));
// A non-positive sense resistor is a config error, not a division to attempt.
CHECK(tmcRmsCurrentMa(80, 31, 0.0) == 0.0);
}
TEST_CASE("the fleet's run current lands on the values the firmware already ships") {
// 80 is what firmware src/config.h carries for yaw, arrived at independently.
// If this test fails, either the formula or that constant has drifted.
auto p = planTmcCurrent(kR, 756.0, 200.0);
REQUIRE(p.ok);
CHECK(p.global_scaler == 80);
CHECK(p.run_irun == 31);
CHECK(p.homing_irun == 7);
CHECK(p.run_ma == doctest::Approx(756.0).epsilon(0.002));
CHECK(p.homing_ma == doctest::Approx(189.0).epsilon(0.01));
CHECK(p.warnings.empty());
}
TEST_CASE("an achieved current is recomputed from the registers, never echoed back") {
// The log must not flatter the result: what is reported has to be what the
// chosen registers deliver.
for (double r : {0.05, 0.075, 0.11}) {
for (double run : {300.0, 756.0, 1500.0}) {
auto p = planTmcCurrent(r, run, run / 4.0);
REQUIRE(p.ok);
CHECK(p.run_ma == doctest::Approx(tmcRmsCurrentMa(p.global_scaler, p.run_irun, r)));
CHECK(p.homing_ma ==
doctest::Approx(tmcRmsCurrentMa(p.global_scaler, p.homing_irun, r)));
}
}
}
TEST_CASE("GLOBALSCALER never lands in the band the datasheet forbids") {
// 1..31 is disallowed, and 0 means 256 (full scale) - emitting either would
// mean silently maximum current on a rig whose endstop yields to full torque.
for (double r : {0.05, 0.075, 0.11, 0.22}) {
for (double run = 10.0; run <= 3000.0; run += 10.0) {
auto p = planTmcCurrent(r, run, run / 2.0);
REQUIRE(p.ok);
CHECK(p.global_scaler >= 32);
CHECK(p.global_scaler <= 255);
}
}
}
TEST_CASE("a run current the driver cannot reach is clamped and named") {
auto p = planTmcCurrent(kR, 5000.0, 200.0);
REQUIRE(p.ok);
CHECK(p.global_scaler == 255);
CHECK(p.run_ma < 2500.0);
REQUIRE(p.warnings.size() >= 1);
CHECK(p.warnings[0].find("5000") != std::string::npos);
}
TEST_CASE("a homing current above the run current is refused, not obeyed") {
auto p = planTmcCurrent(kR, 756.0, 900.0);
REQUIRE(p.ok);
CHECK(p.homing_irun == p.run_irun);
REQUIRE(p.warnings.size() >= 1);
CHECK(p.warnings[0].find("above the run current") != std::string::npos);
}
TEST_CASE("a homing current below the smallest expressible step says so") {
auto p = planTmcCurrent(kR, 756.0, 5.0);
REQUIRE(p.ok);
CHECK(p.homing_irun == 0);
CHECK(p.homing_ma > 5.0); // stronger than asked - the direction worth naming
bool named = false;
for (const auto& w : p.warnings)
if (w.find("smallest step") != std::string::npos) named = true;
CHECK(named);
}
TEST_CASE("the homing current never exceeds the run current after rounding") {
for (double r : {0.05, 0.075, 0.11}) {
for (double run = 50.0; run <= 2000.0; run += 50.0) {
auto p = planTmcCurrent(r, run, run * 0.99);
REQUIRE(p.ok);
CHECK(p.homing_irun <= p.run_irun);
}
}
}
TEST_CASE("non-positive inputs are rejected rather than computed") {
CHECK_FALSE(planTmcCurrent(0.0, 756.0, 200.0).ok);
CHECK_FALSE(planTmcCurrent(kR, 0.0, 200.0).ok);
CHECK_FALSE(planTmcCurrent(kR, 756.0, 0.0).ok);
CHECK(planTmcCurrent(0.0, 756.0, 200.0).warnings.size() == 1);
}