1173 lines
53 KiB
Markdown
1173 lines
53 KiB
Markdown
# Installing a tower
|
|
|
|
How a fire-watch tower host goes from a freshly imaged machine - or from an
|
|
older, unmigrated setup - to a running, calibrated `fire_gimbal_control`.
|
|
[deployment.md](deployment.md) is what comes after: updating and operating a
|
|
tower that already works.
|
|
|
|
**`scripts/install-tower` does the whole of this.** The short way is the next
|
|
section. Everything after it, sections 1 to 11, is the reference for what each
|
|
step does and why, written while doing each one by hand, and still the way to
|
|
do one step on its own.
|
|
|
|
**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.
|
|
|
|
---
|
|
|
|
## The short way: `scripts/install-tower`
|
|
|
|
### Before starting
|
|
|
|
| | |
|
|
|---|---|
|
|
| The tower host | Powered, on the network, an OS installed, `ssh` running, reachable by key (section 1) |
|
|
| An account | `ggs`, in the `sudo` and `dialout` groups |
|
|
| Both repositories on it | `~/projects/fwt_2a/software` and `~/projects/fwt_2a/firmware`. `scripts/bootstrap-tower` sets that up from one copied file (below), or each repo's `deploy.sh` syncs them from a dev box |
|
|
| The Vimba X SDK, staged on it | Login-walled, so it cannot be fetched by a script: copy it on (USB stick, `scp`) and give its directory when asked. `none` for a tower with no camera yet |
|
|
| The facts of the build | Which axes, which IMU, which ambient sensor, how the driver boards are wired, and - per axis - the drivetrain: sense resistor, currents, travel, `steps_per_rev`, `sw_mode`, `enc_deviation`, encoder or not. Anything not yet measured can be answered `unknown` |
|
|
|
|
The installer takes the board from DMI and picks its kit:
|
|
|
|
| Board | MCU | Setup kit | PlatformIO env |
|
|
|---|---|---|---|
|
|
| LattePanda 3 Delta | ATmega32U4 (Leonardo), 5 V | [`hardware/lattepanda-3-delta/`](../hardware/lattepanda-3-delta/) | `leonardo` |
|
|
| LattePanda IOTA | RP2040, 3.3 V | [`hardware/lattepanda-iota/`](../hardware/lattepanda-iota/) | `iota_rp2040` |
|
|
|
|
### Getting the code onto a tower: `scripts/bootstrap-tower`
|
|
|
|
One file to copy; it fetches the rest. It needs the Gitea deploy key (tower
|
|
111's `~/.ssh/id_ed25519_deploy_gitea`) in the tower's `~/.ssh/`:
|
|
|
|
```bash
|
|
scp scripts/bootstrap-tower ggs@<tower>:
|
|
scp -3 ggs@10.11.12.111:.ssh/id_ed25519_deploy_gitea ggs@<tower>:.ssh/ # -3: via this machine
|
|
ssh -t ggs@<tower> bash bootstrap-tower # any install-tower options pass through
|
|
```
|
|
|
|
What it does:
|
|
- adds `Host gitea` (port 2222) to `~/.ssh/config` and records the server's
|
|
host key;
|
|
- clones both repositories side by side, or brings existing clones up to date
|
|
(fast-forward only);
|
|
- starts `install-tower`.
|
|
|
|
It stops, rather than overwrite, when it finds a checkout with local changes or
|
|
a directory that isn't a clone.
|
|
|
|
### A legacy tower: read it first, with `scripts/survey-legacy-tower`
|
|
|
|
The legacy towers run an older host program and a TMC-API Arduino sketch.
|
|
Before migrating one, record what it runs, so its `hardware.ini` comes from its
|
|
own values:
|
|
|
|
```bash
|
|
bash survey-legacy-tower --firmware # pauses the old service ~20 s; changes nothing on disk
|
|
```
|
|
|
|
**What it records:**
|
|
- the machine: board, OS, groups, disk, whether Gitea, GitHub, PyPI and
|
|
PlatformIO are reachable;
|
|
- the camera and the Vimba install;
|
|
- the old service and its `config.ini`;
|
|
- with `--firmware`, from the old firmware:
|
|
- the endstop travel (`?`);
|
|
- its stored north calibration (`y`);
|
|
- its TMC registers (`i`), with the run current worked out for the usual
|
|
sense resistors.
|
|
- the old firmware's source, from the newest build in the Arduino cache:
|
|
its register table (encoder scale `R3A`, current `R0B`/`R10`, encoder limit
|
|
`R3D`) and homing speed. The running firmware cannot report these: the
|
|
registers are write-only, and it answers `i` for them with 0.
|
|
|
|
The report is saved as `~/legacy-survey-<date>.txt`.
|
|
|
|
### Running it
|
|
|
|
On the tower, as `ggs`:
|
|
|
|
```bash
|
|
cd ~/projects/fwt_2a/software
|
|
scripts/install-tower --dry-run # survey + questions + the plan; changes nothing
|
|
scripts/install-tower # the same, then does it after one yes
|
|
scripts/install-tower --verify # the checks only; changes nothing
|
|
```
|
|
|
|
It surveys the machine, asks only what it cannot work out, prints a plan with
|
|
each step marked `skip`, `create`, `change` or `blocked`, and applies it after
|
|
one yes. In order:
|
|
|
|
1. packages, then the USB preconditions (section 3), then the Vimba X SDK (4)
|
|
2. builds the host
|
|
3. writes `hardware.ini` (5) - or, when one exists, only fills its gaps
|
|
4. seeds `config.ini` (9) and `credentials.env`, or edits only what is out of date.
|
|
This is where it asks what the tower does as it starts - `[Startup] home` and
|
|
`mode` - and where a tower whose boot service still passes the old `--init` /
|
|
`--start` has them moved into `config.ini`
|
|
5. PlatformIO, then the camera profiles that are missing (8)
|
|
6. builds and flashes the firmware (6) - **blocked** while any drivetrain value
|
|
is still a `; TODO`, because that axis would refuse to move
|
|
7. `test hardware`
|
|
8. the boot service ([deployment.md](deployment.md) section 4), if wanted. It
|
|
passes no startup flags; a new tower gets `[Startup] home = true`,
|
|
`mode = manual`: it homes at every boot, then **waits in manual and captures
|
|
nothing** until someone chooses auto
|
|
9. asks whether someone is at the rig; only on a yes does it home the gimbal and
|
|
take **one** picture (a snapshot), then leave it in manual - through the
|
|
service, when `[Startup] home` has the service home it, and otherwise with
|
|
`gimbal home`, leaving `config.ini` as it is
|
|
|
|
**Nothing starts capturing by itself.** Choose auto yourself: key `a` on the
|
|
dashboard, or `mode auto` over MQTT. Once the tower is proven, make it boot into
|
|
auto, as 111 does:
|
|
|
|
```bash
|
|
scripts/install-tower --answer startup_mode=auto
|
|
```
|
|
|
|
That writes `[Startup] mode = auto` into `config.ini`; editing the file by hand
|
|
does the same (with the program stopped - see the warning at the top of the
|
|
file). `--answer startup_home=y|n` sets homing the same way. `service_args` now
|
|
holds only other flags, and refuses `--init` / `--start`.
|
|
|
|
Every question has a key, so any can be answered up front - `--answer
|
|
tower_name=north`, `--answer yaw.r_sense_ohm=0.075` - or from a file of
|
|
`key=value` lines with `--answers FILE`. `--non-interactive` stops instead of
|
|
prompting. **It never invents a drivetrain value**: a wrong sense resistor cooks a
|
|
driver and a wrong homing current shears an endstop, and both have happened on
|
|
this project. Answer `unknown` and it writes a commented `; TODO` naming what to
|
|
measure.
|
|
|
|
Re-running it is safe, and is how a configured tower is repaired: an existing
|
|
`hardware.ini` or `config.ini` is never regenerated, only filled in; an existing
|
|
camera profile is never overwritten; a firmware already running the declared
|
|
revision and pins is not reflashed; and if it fails after stopping the program,
|
|
it puts the program back the way it was.
|
|
|
|
| Where | Result |
|
|
|---|---|
|
|
| Tower 121 (IOTA), 2026-09-23 | `[verified]` re-run on a configured tower: changed only what was out of date, `--verify` green, boot service installed and proven across a reboot |
|
|
| Tower 111 (Delta), 2026-09-23/24 | `[verified]` migration of a tower that predated all of this: host first, firmware once the drivetrain was known, `test hardware` green on every leaf (section 10b) |
|
|
| A freshly imaged board | `[untested]` - rehearsed with `--dry-run` and typed answers only. The first flash of a factory MicroPython RP2040 is automated but has not met a factory board |
|
|
|
|
---
|
|
|
|
## 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" ~/.config/fire_gimbal_control/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 kit carries the same two files** (`hardware/lattepanda-3-delta/`,
|
|
> as symlinks to these). Tower 111 had neither - `usbfs_memory_mb` 16, camera
|
|
> LPM `u1_u2`, checked 2026-09-15 and again 2026-09-23 - and got both on
|
|
> 2026-09-23, reading 1000 and `0` afterwards - and still after a reboot on
|
|
> 2026-09-24.
|
|
|
|
## 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.
|
|
|
|
### The drivetrain block
|
|
|
|
**`install.sh` now writes this block**, one `[Motor.<axis>]` per declared axis —
|
|
it has to, because the firmware refuses to move until it has been told these
|
|
(see the config handshake in [configuration.md](configuration.md)). What it still
|
|
cannot do is *probe* them: no reading of a board tells you its sense resistor.
|
|
|
|
So pass what you know and it writes the values; omit anything and it writes a
|
|
commented `; TODO` line naming what to measure:
|
|
|
|
```bash
|
|
./install.sh --stdout --axes yaw \
|
|
--r-sense 0.075 --run-current 756 --homing-current 200 \
|
|
--travel-deg 340 --steps-per-rev 1210778 --sw-mode 0
|
|
```
|
|
|
|
A `TODO` line is **commented, not a placeholder value**, and deliberately: these
|
|
keys are numeric, and a non-numeric value makes the program refuse to start with
|
|
a message about a config key. Left absent, it starts and names exactly which keys
|
|
that axis is missing and that it will not move — the useful failure.
|
|
|
|
The block it writes looks like this:
|
|
|
|
```ini
|
|
[Motor.yaw]
|
|
r_sense_ohm = 0.075 ; the resistor fitted to THIS driver board
|
|
run_current_ma = 756 ; normal motion
|
|
homing_current_ma = 200 ; the endstop search only
|
|
travel_deg = 340 ; mechanical angle between the hard stops
|
|
```
|
|
|
|
**`homing_current_ma` is a safety setting, not a tuning one.** Homing finds a
|
|
stop by driving into it until the shaft falls behind; that only works while the
|
|
stop is stronger than the motor. A high gear ratio can shear a screw-head stop
|
|
straight past its mate - on tower 121 the axis ran 1.8 turns past the stop with
|
|
the encoder never falling behind, at full torque. Declare a reduced search
|
|
current wherever the stop cannot survive one.
|
|
|
|
Getting it wrong in either direction is visible: too strong and the stop yields
|
|
again; too weak and the motor stalls on ordinary friction mid-sweep, trips the
|
|
same latch, and records a phantom endstop. The firmware's `steps_per_rev` guard
|
|
is what catches the second case, so it has to be set from the real travel.
|
|
|
|
> **The centre lives in `config.ini`, not here.** `install.sh` cannot write it —
|
|
> `hardware.ini` is merged *over* `config.ini` and wins, so a shared key would
|
|
> silently discard whatever `gimbal heading setup` had saved. What it does instead
|
|
> is work the requirement out from `steps_per_rev` and print it as a `NOTE` in the
|
|
> block, because the default is wrong for any geared-up rig - and
|
|
> `scripts/install-tower` then sets it in `config.ini` when it is too small:
|
|
>
|
|
> ```
|
|
> ; NOTE: half this travel is 605389 counts, so config.ini needs
|
|
> ; [Motor] yaw_center_count = 1000000 (or anything above 605389)
|
|
> ; The 500000 default would put the negative end at -105389.
|
|
> ```
|
|
>
|
|
> The range is centre ± half the travel, so a centre below half-travel puts the
|
|
> negative end under zero. On tower 121 the inherited `500000` homed to
|
|
> `-104749..1104749`; at `1000000` it homes to `395194..1604807`. A warning names
|
|
> it after every home if it is still too small.
|
|
|
|
**`travel_deg` is a nameplate fact**, and the host divides the measured homing
|
|
travel by it to derive `[Motor] <axis>_counts_per_deg` into `config.ini`. A rough
|
|
angle gives a proportionally rough degree scale - 1 % out here is 1 % out on every
|
|
bearing - until `gimbal heading setup` supersedes it with a landmark fit.
|
|
|
|
The startup log reports 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]
|
|
```
|
|
|
|
There is no leaving the block out any more. The firmware holds no drivetrain
|
|
values of its own: at boot every axis stops its driver, asks the host (`NEED`),
|
|
and refuses to move until every value it asked for has arrived. An axis whose
|
|
block is missing or incomplete is an axis that will not turn - by design, since a
|
|
guessed value is how drivers get cooked and endstops sheared. (For a bench board
|
|
with no host, the firmware's `USE DEFAULTS` command loads deliberately gentle
|
|
limp-home values instead; `test hardware motor` fails on a tower running them.)
|
|
|
|
The kit writes eight keys per axis - the four above plus `steps_per_rev`,
|
|
`sw_mode`, `enc_deviation` and `has_encoder` - each as a value or a `; TODO`;
|
|
`--motor AXIS.KEY=VALUE` gives any of them for any axis, where the older
|
|
`--r-sense`-style flags cover the first axis only. The three speeds
|
|
(`scan_speed_deg_s`, `homing_speed_deg_s`, `center_speed_deg_s`, all in degrees
|
|
per second) are written as gentle starting values: **on a tower that is being
|
|
migrated, set the homing speed to what its endstop detection is proven at**, as
|
|
was done for tower 111 (section 10b), or a stall-detected endstop may behave
|
|
differently.
|
|
|
|
> **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 message names the skew; the fix is to flash the firmware, not to override
|
|
> 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. `scripts/install-tower` re-probes after flashing and updates
|
|
> `[Serial] device` itself; by hand, re-run `--probe` after step 6 and fix the
|
|
> file if it moved.
|
|
|
|
### The wiring block: `[Pins]`
|
|
|
|
The firmware is **built** from this section - which MCU pin reaches which driver
|
|
board - by `firmware/tools/gen_wiring.py`, reading the tower's own
|
|
`/etc/fire_gimbal_control/hardware.ini` when the firmware is built on the tower.
|
|
There is no copy in the firmware and no fallback: without `[Pins]` the firmware
|
|
build fails, naming what to add. Each kit's reference loom, with the table saying
|
|
which wire goes where, is its `pins.ini`; `install.sh --loom standard` copies it
|
|
in, and `--pin KEY=N` gives pins one at a time for a tower wired differently.
|
|
|
|
The generator refuses what the board cannot do rather than building it: on the
|
|
RP2040, SPI pins outside SPI0's reach (SCK `{2,6,18,22}`, MOSI `{3,7,19,23}`,
|
|
MISO `{0,4,16,20}`); on the Leonardo, any `spi_*` key at all (its SPI is fixed on
|
|
the ICSP header) and the ICSP pins as chip-selects; duplicates; and a
|
|
`hardware.ini` written for the other board. The running firmware reports the
|
|
pins it was built with, and `test hardware motor` compares them with this
|
|
section - so a flash built from an older copy shows up there.
|
|
|
|
## 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
|
|
```
|
|
|
|
`deploy.sh` syncs the tree and then runs `firmware/tools/flash.sh` on the tower,
|
|
which is also what `scripts/install-tower` runs, locally: one implementation of
|
|
the board-specific bootloader step for both. **Stop the program first**; it holds
|
|
the serial port.
|
|
|
|
**Confirm the pin map before flashing a board that is wired to motors.** A
|
|
chip-select on the wrong pin writes into a driver that was never meant to hear
|
|
it. The build takes the pins from this tower's `hardware.ini` and fails without
|
|
them - see "The wiring block" in section 5. The IOTA's reference loom is the one
|
|
as built on tower 121, which is **not** the map proposed in the porting plan.
|
|
|
|
`[verified]` on both boards: the RP2040 on tower 121 (2026-09-23), the Leonardo on
|
|
tower 111 (2026-09-24) - the Leonardo through its own 1200-baud touch, which
|
|
`flash.sh` leaves alone.
|
|
|
|
### 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.
|
|
`firmware/tools/flash.sh` (which `deploy.sh` runs on the tower) now does this by
|
|
itself when the port's by-id name says `MicroPython_Board`: `./deploy.sh` with
|
|
`SERIAL_PORT` set to that path should flash a factory board directly. `[untested]`
|
|
- no factory board has been through that path yet; the manual way, verified on
|
|
tower 121, is below. 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 update `[Serial]
|
|
device` once the name has moved - `scripts/install-tower` does that itself,
|
|
re-probing after every flash.
|
|
|
|
### 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="" # extra flags only; homing and mode are config.ini [Startup]
|
|
```
|
|
|
|
```bash
|
|
./deploy.sh --check-deps # must print "remote deps OK"
|
|
./deploy.sh # rsync + remote cmake + build
|
|
```
|
|
|
|
The first deploy copies `config/config.example.ini` to `config.ini` **only if
|
|
the device has none** - see the warning in step 2 about a machine that arrived
|
|
with somebody else's config. That copy still carries the example's placeholders
|
|
and the keys `hardware.ini` owns; `scripts/install-tower` seeds it instead with
|
|
`build/fgc-config seed`, which fills in the answers, comments out every key
|
|
`hardware.ini` sets, and refuses to write a file the program would not load.
|
|
|
|
`[verified]` on tower 111: builds on GCC 11 / Ubuntu 22.04.
|
|
`[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/fire_gimbal_control/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 not be in `config.ini` as well: the program warns at startup
|
|
about every key set in both. `scripts/install-tower` comments them out (value and
|
|
all), and `build/fgc-config set config.ini --config config.ini --hardware
|
|
/etc/fire_gimbal_control/hardware.ini --drop Section.key=note` does one by hand.
|
|
Every edit through `fgc-config` is loaded the way the program loads it before it
|
|
is written, and keeps the previous value as a `;prev` comment.
|
|
|
|
> **`[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` are for towers **without** a
|
|
`hardware.ini`. Where `hardware.ini` declares `imu` / `env`, the declaration
|
|
decides, and a flag that contradicts it is warned about and ignored. Before that
|
|
was so, tower 121's first clean-looking run "started" an IMU it does not have -
|
|
`/dev/ttyS0` accepts a serial open whether or not anything is attached - which is
|
|
why a declared `none` has to be able to switch a sensor off by itself.
|
|
|
|
## 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 that the handshake
|
|
completed - the log says `firmware config: answered N keys for axis Y`, and the
|
|
axis leaves state 5 (`NEEDCFG`). The scripted form of all of this, which also
|
|
compares every value the firmware holds with `hardware.ini`, moves nothing:
|
|
|
|
```bash
|
|
./build/fire_gimbal_control --no-mqtt --test hardware # exit 0 PASS, 4 FAIL, 5 could not start
|
|
```
|
|
|
|
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 (a yaw-only tower: gimbal move 10)
|
|
```
|
|
|
|
**Expected noise in this phase, neither of which is a fault:**
|
|
|
|
| Message | Why |
|
|
|---|---|
|
|
| `Motor controller: ERR unknown command`, a burst at startup | Only on firmware older than the config handshake: it does not know `SET`. Seen on tower 111 before its flash; harmless there, and gone once the firmware is current |
|
|
| `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_deg_s` and walk it up, watching the free-running encoder gap that
|
|
> `enc_deviation` is measured against. Be at the rig with a hand on the power for
|
|
> the first one - which is why `scripts/install-tower` asks before it homes.
|
|
|
|
### 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 `mode auto`, 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.
|
|
|
|
### A legacy tower (DHT11 and fan on the MCU)
|
|
|
|
Answer the installer's ambient sensor question with `dht11` and the fan
|
|
question with yes, and choose `loom = legacy`. The firmware is then built with
|
|
the DHT11 and fan (see configuration.md, "The legacy towers").
|
|
|
|
**The encoder scale.** Take it from the survey's "old firmware's source":
|
|
`R3A` is `ENC_CONST`, the whole part in the high 16 bits and 1/10000ths in the
|
|
low 16, so `0x000C1388` is 12.5. If it is not 12.8, put it in the answers file
|
|
as `yaw.enc_const=12.5` (the installer never asks for it). On staeffelsberg,
|
|
2026-09-28, it was missing: homing took the 2% drift for a stop after 131°.
|
|
|
|
Check it **on site** after the first flash:
|
|
1. The fan runs at full speed from power-up.
|
|
2. It drops once readings come in, if the box is below 25 °C.
|
|
3. Warming the DHT11 by hand, or with a hair-dryer, spins it up.
|
|
4. `test hardware env` passes, fan leaf included.
|
|
|
|
### Phase 4 - IMU and ambient sensor
|
|
|
|
Only if fitted, and declared in `hardware.ini` (`imu`, `env`) - the declaration
|
|
is what switches them on. `test hardware imu` wants a `0xFA`-framed stream from
|
|
the MTi; `test hardware env` wants a real I2C adapter and a CRC-valid SHT41
|
|
reading. `[verified]` on tower 111, 2026-09-23: both.
|
|
|
|
### Phase 5 - unattended
|
|
|
|
Only once every phase above is green:
|
|
|
|
```bash
|
|
# config.ini: [Startup] home = true, mode = auto
|
|
scripts/fgc start # detached, inside tmux
|
|
scripts/fgc status # running? since when? which PID?
|
|
```
|
|
|
|
Close the ssh session, reconnect, and check it is still running. Then install
|
|
the boot service (`scripts/install-tower` asks; [deployment.md](deployment.md)
|
|
section 4), reboot the tower, and confirm with `scripts/install-tower --verify`
|
|
that the USB preconditions survived and the program came back by itself.
|
|
|
|
### 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 and a pitch axis; skip on a rig without them
|
|
```
|
|
|
|
## 10b. Bringing up the config handshake on an existing tower
|
|
|
|
This is the one upgrade that changes what an **unconfigured** tower does: the new
|
|
firmware boots into `NEEDCFG` and refuses `HOME` / `MOVE` / `ENABLE` until the
|
|
host has answered its `NEED` lines. Flashing it onto a tower whose `hardware.ini`
|
|
predates the drivetrain keys leaves the gimbal inert — working, talking, and not
|
|
turning.
|
|
|
|
So the order matters. **Host first, firmware second.** Each step below either
|
|
changes nothing or is reversible, and the step that could strand the tower comes
|
|
after the one that prevents it. `scripts/install-tower` follows this order by
|
|
itself, and blocks the flash while any drivetrain value is missing.
|
|
|
|
### The worked example: tower 111 `[verified]`
|
|
|
|
The Delta tower, which predated `hardware.ini`, the handshake and the kits.
|
|
2026-09-23 and -24, through the installer with the Delta kit:
|
|
|
|
1. **A rollback copy** of the sources, the running binary and `config.ini`, in
|
|
`~ggs/rollback-20260923/`, before anything changed.
|
|
2. **The host half.** Preconditions, `hardware.ini` with every drivetrain value
|
|
a `; TODO`, `config.ini`'s duplicated keys commented out, the missing `night`
|
|
profile, a new host. `test hardware`: axes, IMU, env and camera PASS; motor
|
|
FAIL on the old firmware's missing echoes, as expected (step 2 below). Firmware
|
|
blocked. It homed and resumed capturing on the new host.
|
|
3. **The drivetrain, without inventing it.** The sense resistors were read off
|
|
both boards (75 mOhm). Everything else was converted from what the old
|
|
firmware ran - its revision is in `DUMP BEGIN build=`, and git history at
|
|
that revision has its register values - through the host's own planners, and
|
|
round-tripped back to the identical registers before being written. Homing
|
|
and centring speeds were set to what that firmware used, not the kit's
|
|
gentle defaults, since its endstop detection was proven at those.
|
|
4. **The flash**, by the installer: every gap filled, the firmware built from
|
|
`hardware.ini` and flashed, `test hardware` green on every leaf, and homing
|
|
within 0.03 % of the old firmware's travel.
|
|
|
|
### 0. Record what you are rolling back to
|
|
|
|
```bash
|
|
gimbal dump # the running firmware's build id and register state
|
|
cat /etc/fire_gimbal_control/hardware.ini
|
|
git -C ~/projects/fwt_2a/firmware rev-parse --short HEAD
|
|
```
|
|
|
|
Keep the dump. It is the only record of what the old firmware was holding, and
|
|
the comparison that tells you whether the new one adopted the same values.
|
|
|
|
### 1. Update `hardware.ini` — nothing else, and no restart yet
|
|
|
|
Add the `[Motor.<axis>]` block (section 5) and `[Pins]`. The firmware asks for
|
|
eleven values per axis - `steps_per_rev`, `global_scaler`, `run_irun`,
|
|
`homing_irun`, `center_count`, `soft_margin`, `enc_deviation`, `normal_speed`,
|
|
`homing_speed`, `center_speed`, `sw_mode` - and the host derives them from the
|
|
block (currents from milliamps and the sense resistor, speeds from degrees per
|
|
second, centre and margin from `config.ini`). `install.sh --stdout` generates the
|
|
block; for values nobody has measured, convert what the old firmware runs, as
|
|
for tower 111 above.
|
|
|
|
Doing this **before** flashing is what keeps the window at zero: the host is ready
|
|
to answer the moment the firmware starts asking.
|
|
|
|
### 2. Deploy the new host against the OLD firmware
|
|
|
|
A real test, and it risks nothing. The old firmware never sends `NEED`, so the
|
|
host must behave exactly as it did before — which is also the guarantee the whole
|
|
Delta fleet depends on.
|
|
|
|
```bash
|
|
scripts/fgc start
|
|
scripts/fgc peek
|
|
test hardware motor
|
|
```
|
|
|
|
Expect: no handshake lines at all (and possibly a burst of `ERR unknown
|
|
command` - the old firmware refusing `SET`), and the motor leaf **failing** on
|
|
missing echoes, reported as a version skew rather than a mismatch:
|
|
|
|
```
|
|
; yaw_pins: yaw: the firmware does not report the pins it was built with, so its
|
|
wiring cannot be confirmed -- it predates the pin echo. Re-flash it
|
|
```
|
|
|
|
That FAIL is by design - a check that could not happen must not read as a pass -
|
|
so on old firmware the gate is "every other leaf green, and `hw_motor` failing
|
|
only on missing echoes". A speed can also differ before the first home, since
|
|
the host sends it after homing. If instead you see wrong *values* for something
|
|
the firmware does echo, stop: `hardware.ini` disagrees with what the tower is
|
|
really running, and flashing will not fix it.
|
|
|
|
### 3. Flash
|
|
|
|
```bash
|
|
cd firmware && ./deploy.sh # uploads; --compile-only if you want a dry run first
|
|
```
|
|
|
|
Watch the banner. A `NEED` line per axis should follow it:
|
|
|
|
```
|
|
BOOT build=<rev>
|
|
NEED Y steps_per_rev global_scaler run_irun homing_irun center_count soft_margin ...
|
|
```
|
|
|
|
**No `NEED` line means the axis is not in `NEEDCFG`** — either the flash did not
|
|
take, or the image is older than you think. Check the build id before going on.
|
|
|
|
### 4. Confirm the handshake completed
|
|
|
|
The host answers within a control-loop tick. Expect:
|
|
|
|
```
|
|
firmware config: answered 11 keys for axis Y
|
|
```
|
|
|
|
Then prove it landed rather than trusting the log:
|
|
|
|
```
|
|
gimbal dump # state should be 1 (RESET), NOT 5 (NEEDCFG)
|
|
# cfg_rx should be the full required mask
|
|
test hardware motor # both metrics PASS
|
|
```
|
|
|
|
`test hardware motor` is the real gate: it compares every declared value against
|
|
what the firmware echoes, so a `SET` that was lost on the wire shows up here and
|
|
nowhere else.
|
|
|
|
### 5. Prove the refusal actually refuses
|
|
|
|
Worth doing once per firmware version, because a guard that silently stopped
|
|
working looks exactly like a guard that is working.
|
|
|
|
On a **bench** board, not a mast: power-cycle it without the host running, then
|
|
send `HOME` over a serial monitor. Expect `ERR need config`. Send `RESET` and try
|
|
again — still `ERR need config`, because a RESET is deliberately not a way out.
|
|
`USE DEFAULTS` is, and should then let `HOME` through.
|
|
|
|
### 6. Only then, move the axis
|
|
|
|
```
|
|
gimbal home # at the reduced homing current
|
|
```
|
|
|
|
Check against the recorded figures: travel ~1,210,778 counts, repeatable to
|
|
~0.3 %, and the derived `counts_per_deg` near 3552.87. A travel that disagrees by
|
|
more than a few percent means the axis homed against something that was not the
|
|
endstop.
|
|
|
|
### Reading the log afterwards
|
|
|
|
Everything below is recorded to
|
|
`~/.local/share/fire_gimbal_control/logs/fgc_*.log`, not only to the dashboard —
|
|
which matters here, because in TUI mode the pane holds 500 lines and dies with
|
|
the tmux session. During this bring-up that cost us the handshake record four
|
|
separate times before the file existed.
|
|
|
|
```bash
|
|
L=$(ls -t ~/.local/share/fire_gimbal_control/logs/fgc_*.log | head -1)
|
|
grep -E "NEED|firmware config|need config" "$L" # step 4: did the handshake complete
|
|
grep -E "hardware:" "$L" # presence reconciliation
|
|
grep -E "speed:|motor current" "$L" # what was actually sent to the firmware
|
|
```
|
|
|
|
The file is more verbose than the dashboard was, so it holds lines the pane never
|
|
showed. `head -8` gives the build, tower and config that produced it.
|
|
|
|
### If it goes wrong
|
|
|
|
The board is recoverable over the network as long as USB CDC comes up:
|
|
`BOOTSEL` over the serial port, then `./deploy.sh` with the previous firmware
|
|
checked out. Only an image that wedges *before* `Serial.begin()` needs a physical
|
|
button press — which is why `Serial.begin()` is the first thing `setup()` does.
|
|
|
|
`USE DEFAULTS` is the in-place escape: it puts every axis on the compile-time
|
|
table and lets the tower move while you work out what `hardware.ini` is missing.
|
|
|
|
## 11. Verification checklist
|
|
|
|
Most of this is one command, which changes nothing:
|
|
|
|
```bash
|
|
scripts/install-tower --verify
|
|
```
|
|
|
|
It checks the USB preconditions, that `config.ini` and `hardware.ini` load
|
|
together, that `hardware.ini` has no gaps, that the firmware reports the pins it
|
|
declares, and the boot service - and runs `test hardware` and `test camera`: inside the running
|
|
program when it is up (pausing capture, then resuming it), or as a one-shot run
|
|
when it is not. By hand, the one-shot form is:
|
|
|
|
```bash
|
|
GENICAM_GENTL64_PATH=/opt/VimbaX/cti ./build/fire_gimbal_control --no-mqtt --test hardware
|
|
echo $? # 0 PASS, 4 FAIL, 5 could not start (the log says why)
|
|
```
|
|
|
|
It waits for the firmware to be talking and configured before it looks, so a
|
|
freshly flashed board is not failed for values the host had not sent yet.
|
|
|
|
| Check | How | Tower 121 (IOTA) | Tower 111 (Delta) |
|
|
|---|---|---|---|
|
|
| Key-only ssh | `ssh ggs@<tower> hostname`, no prompt | ✅ | ✅ |
|
|
| Preconditions applied | `--verify`: `usbfs_memory_mb 1000`, camera LPM `0` | ✅ | ✅ 2026-09-23 (were 16 and `u1_u2`) |
|
|
| Preconditions survive a reboot | reboot, then `--verify` | ✅ confirmed across a real reboot | ✅ 2026-09-24 |
|
|
| `hardware.ini` found unaided | startup names `/etc/fire_gimbal_control/hardware.ini` | ✅ | ✅ |
|
|
| Declared hardware matches | startup logs `declared assembly confirmed`; `test hardware axes` | ✅ yaw | ✅ yaw, pitch |
|
|
| IMU / ambient sensor | `test hardware imu` / `env` | none declared | ✅ MTi streaming, SHT41 CRC-valid on a real I2C bus |
|
|
| Camera enumerates | `ListCameras_VmbC` reports the `DEV_` id in `hardware.ini` | ✅ `DEV_1AB22C0B7D4E` | ✅ `DEV_1AB22C049CD1` |
|
|
| Camera profile applies | startup logs `profile 'standard' applied ... (N settings)` | ✅ 17206 settings | ✅ 3743 settings |
|
|
| Camera works, whatever it sees | `test camera`: whole frames; every pixel forced to black (minimum exposure and gain); the sensor responding at maximum gain and a long exposure; the profile restored | ✅ 2026-09-24, lens covered: 100 % ≤ 20 dark, bright mean 0 → 255 | ✅ 2026-09-24, inside the running program: 5 frames of 4024x3036 in 9.2 s (a dark scene, ~2 s exposures), 100 % ≤ 20 dark (mean 5.6), bright mean 5.6 → 255; capture resumed. An earlier run correctly reported the camera delivering nothing at all - known-issues #15 |
|
|
| Firmware identity | `DUMP BEGIN build=` is the revision flashed | ✅ | ✅ `a0b1e4a` |
|
|
| Driver answers over SPI | `IOIN` reads version `0x30` for every axis | ✅ | ✅ both axes |
|
|
| Firmware holds `hardware.ini` | `test hardware motor`: values, current, pins | ✅ | ✅ every leaf, 2026-09-24 |
|
|
| Remote re-flash | no hands on the board | ✅ `BOOTSEL` | ✅ 1200-baud touch |
|
|
| Homing repeatable | endstop counts across homes | ✅ three homes within 0.27 % | ✅ within 0.03 %, old and new firmware |
|
|
| Endstop survives homing | the axis must stall against the stop, not push through | ✅ at 189 mA, after failing at full torque | not inspected; full current, as for its whole service life |
|
|
| Degree scale derived | startup logs `counts_per_deg` agreeing with the homed travel | ✅ 3552.87 | ✅ agrees, calibration left alone |
|
|
| Images land | `.jxl` files appear under the configured output dir | ❌ needs a capture run | ✅ |
|
|
| Starts at boot | reboot with nobody logged in; `scripts/fgc status` from a later ssh | ✅ lock under `/run/user/1000` | ✅ 2026-09-24: homed and captured by itself within ~35 s. The Delta logs a desktop session in at boot, so lingering was not what put the lock in `/run/user` here |
|
|
| Reports to a broker | topics arrive at the configured broker | ❌ no broker on this tower yet | ✅ connects to its own RabbitMQ (retrying until Docker has it up at boot); forwarding to the ground station not checked |
|