Compare commits
No commits in common. "f9e209bf2ae3525010e5c18b67d984b6691606c3" and "08a6f7c1fa9eee8353122edd6352bc3915e5af32" have entirely different histories.
f9e209bf2a
...
08a6f7c1fa
|
|
@ -63,7 +63,6 @@ add_library(fgc_core STATIC
|
|||
src/core/SingleInstance.cpp
|
||||
src/core/Logger.cpp
|
||||
src/core/Geometry.cpp
|
||||
src/core/TmcCurrent.cpp
|
||||
src/core/ScanGrid.cpp
|
||||
src/core/TelemetryParser.cpp
|
||||
src/core/CaptureScheduler.cpp
|
||||
|
|
|
|||
|
|
@ -168,8 +168,6 @@ period_ms = 2000
|
|||
; 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.
|
||||
;
|
||||
; 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):
|
||||
; Yaw ~320 deg (NOT a full revolution: there is an unreachable arc)
|
||||
; Pitch 40 deg
|
||||
|
|
@ -192,10 +190,6 @@ pitch_angle_at_center = 10.0
|
|||
pitch_soft_margin_counts = 5000
|
||||
; Live encoder-error WARN threshold in counts (0 = off).
|
||||
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]
|
||||
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).
|
||||
|
|
|
|||
|
|
@ -73,38 +73,6 @@ device = /dev/ttyS0
|
|||
i2c_device = /dev/i2c-2
|
||||
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]
|
||||
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
|
||||
; GigE IP address.
|
||||
|
|
|
|||
|
|
@ -69,10 +69,6 @@ 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` | `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` | `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` | `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 |
|
||||
|
|
@ -592,53 +588,10 @@ The firmware speaks **full-word, newline-terminated** commands in absolute **enc
|
|||
| `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`) |
|
||||
| `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 Y\|P <pos>` | — | single-axis absolute move (`set motorctl`) |
|
||||
| `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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -297,34 +297,6 @@ 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 |
|
||||
| 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 | `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 |
|
||||
|
|
|
|||
|
|
@ -1,692 +0,0 @@
|
|||
# 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 |
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# 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 |
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# 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'"
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -56,40 +56,6 @@ struct HardwareConfig {
|
|||
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 {
|
||||
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
|
||||
|
|
@ -254,11 +220,6 @@ struct AppConfig {
|
|||
LoggingConfig logging;
|
||||
UiConfig ui; // [UI] terminal dashboard toggle
|
||||
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
|
||||
// firmware (MARGIN), which enforces it; kept in counts rather than degrees
|
||||
// because it must apply at homing time, before any degree map is trusted.
|
||||
|
|
|
|||
|
|
@ -21,16 +21,6 @@ struct DumpAxis {
|
|||
long center_count = 0; // count the firmware anchored the homed midpoint to
|
||||
long hold_target = 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.
|
||||
long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET
|
||||
long hold_peak_dev = 0; // largest deviation seen while holding (counts)
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
#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
|
||||
|
|
@ -289,22 +289,11 @@ struct CalibAxisView {
|
|||
double r2 = 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 {
|
||||
bool has = false; // has a fit run THIS SESSION?
|
||||
bool has = false;
|
||||
long long ts_ms = 0;
|
||||
bool pitch_present = false;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
#!/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())
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
#include "fgc/HelpText.h"
|
||||
#include "fgc/Paths.h"
|
||||
#include "fgc/CameraProfile.h"
|
||||
#include "fgc/TmcCurrent.h"
|
||||
#include "fgc/ICameraSource.h"
|
||||
#include "fgc/IControlChannel.h"
|
||||
#include "fgc/IEnvSensor.h"
|
||||
|
|
@ -238,9 +237,6 @@ struct Application::Impl {
|
|||
CalibResultView last_calib_view;
|
||||
std::vector<std::string> last_calib_summary;
|
||||
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
|
||||
// Which command produced the pending values, recorded into the `;prev`
|
||||
// comment when they are saved so the file says what superseded what.
|
||||
|
|
@ -907,9 +903,6 @@ struct Application::Impl {
|
|||
// --- Activity strip + last calibration ---
|
||||
s.calib = last_calib_view;
|
||||
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.test = last_test_view;
|
||||
fillActivity(s);
|
||||
|
|
@ -994,12 +987,7 @@ struct Application::Impl {
|
|||
|
||||
// Pending yes/no: persist the just-applied calibration as the new default?
|
||||
if (calib_save_pending_)
|
||||
// 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)";
|
||||
a.prompt = "Save the new [Motor] calibration to config as the default? (y / n)";
|
||||
}
|
||||
|
||||
void publishSnapshot() {
|
||||
|
|
@ -1043,32 +1031,12 @@ struct Application::Impl {
|
|||
} clear_homing{homing_active_};
|
||||
|
||||
motor->sendCommand("ENABLE Y");
|
||||
// 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");
|
||||
motor->sendCommand("ENABLE P");
|
||||
// Tell the firmware where to anchor the homed midpoint and how much
|
||||
// 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
|
||||
// home, not this one.
|
||||
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
|
||||
|
||||
auto allReady = [](const MotorTelemetry& t) {
|
||||
|
|
@ -1089,12 +1057,7 @@ struct Application::Impl {
|
|||
|
||||
// Wait for completion (both axes READY again) or the homing timeout.
|
||||
bool homed = false;
|
||||
// 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);
|
||||
const auto deadline = std::chrono::steady_clock::now() + 65s;
|
||||
while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(250ms);
|
||||
drainAndDiscardCommands();
|
||||
|
|
@ -1116,125 +1079,24 @@ struct Application::Impl {
|
|||
motor->sendCommand("STOP ALL");
|
||||
return;
|
||||
}
|
||||
// Re-anchor the yaw zero to an endstop now that the homed limits are known,
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
// Re-anchor the yaw zero to an endstop now that the homed limits are known.
|
||||
if (homed) applyHomedTravel();
|
||||
// Production move speeds for subsequent MOVE commands.
|
||||
motor->sendCommand("SPEED Y 50000");
|
||||
if (cfg.hardware.hasAxis("pitch")) motor->sendCommand("SPEED P 150000");
|
||||
motor->sendCommand("SPEED P 150000");
|
||||
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.
|
||||
// The centre count is what finishHoming() re-origins onto, so all counts stay
|
||||
// positive and reproducible across power cycles; the margin is what
|
||||
// clampTarget() keeps clear of each hard limit.
|
||||
void sendAnchorConfig() {
|
||||
motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count));
|
||||
motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts));
|
||||
if (cfg.hardware.hasAxis("pitch")) {
|
||||
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 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
|
||||
// limits. These are deliberately NOT configurable: the firmware's limits are
|
||||
|
|
@ -1247,34 +1109,24 @@ struct Application::Impl {
|
|||
// not take effect - firmware predating it answers "ERR unknown command" and
|
||||
// homes to its own compiled default, which would leave every published
|
||||
// bearing silently wrong.
|
||||
// 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) {
|
||||
void applyHomedTravel() {
|
||||
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");
|
||||
const auto deadline = std::chrono::steady_clock::now() + wait;
|
||||
DumpData dd;
|
||||
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
||||
while (running && std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(100ms);
|
||||
publishSnapshot();
|
||||
DumpData dd = parseDump(motor->lastDump());
|
||||
if (dd.valid) return dd;
|
||||
dd = parseDump(motor->lastDump());
|
||||
if (dd.valid) break;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void applyHomedTravel(const DumpData* pre = nullptr) {
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
std::optional<DumpData> got;
|
||||
if (!pre) {
|
||||
got = freshDump(3s);
|
||||
if (!got) {
|
||||
if (!dd.valid) {
|
||||
LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits";
|
||||
return;
|
||||
}
|
||||
}
|
||||
const DumpData& dd = pre ? *pre : *got;
|
||||
|
||||
bool any = false;
|
||||
for (const auto& a : dd.axes) {
|
||||
|
|
@ -1534,71 +1386,6 @@ struct Application::Impl {
|
|||
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
|
||||
// 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
|
||||
|
|
@ -1621,8 +1408,7 @@ struct Application::Impl {
|
|||
while (iss >> t) tok.push_back(t); // tok[0] == "gimbal"
|
||||
if (tok.size() < 2) {
|
||||
LOG_WARN << "usage: gimbal <move|steps|nudge|jog|heading|pitch|home|stop|reset|"
|
||||
"enable|disable|speed|current|setpos|status|dump|raw> "
|
||||
"(self-test: 'test')";
|
||||
"enable|disable|speed|setpos|status|dump|raw> (self-test: 'test')";
|
||||
return;
|
||||
}
|
||||
std::string sub = tok[1];
|
||||
|
|
@ -1719,8 +1505,7 @@ struct Application::Impl {
|
|||
// home rather than a fire-and-forget HOME that skips the re-anchor.
|
||||
runInitSequence();
|
||||
} else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" ||
|
||||
sub == "disable" || sub == "speed" || sub == "setpos" ||
|
||||
sub == "status" || sub == "current") {
|
||||
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") {
|
||||
// Passthrough to the (case-insensitive) firmware verb + args.
|
||||
std::string fw = sub;
|
||||
for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch));
|
||||
|
|
@ -2317,9 +2102,6 @@ struct Application::Impl {
|
|||
// the wait is the longest across them, not the first one's.
|
||||
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();
|
||||
if (imu) imu->start();
|
||||
if (env) env->start();
|
||||
|
|
|
|||
|
|
@ -419,27 +419,6 @@ 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.enc_error_warn_counts = getLong(kv, "Motor.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).
|
||||
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
|
||||
|
|
@ -471,27 +450,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
|||
throw std::runtime_error("Motor.yaw_soft_margin_counts must be >= 0");
|
||||
if (cfg.pitch_soft_margin_counts < 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
|
||||
// leave an operator convinced they had configured a heading that is in fact
|
||||
|
|
@ -500,22 +458,15 @@ 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_max_deg", "Motor.pitch_min_deg", "Motor.pitch_max_deg",
|
||||
"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;
|
||||
for (const char* k : kRemovedMotorKeys)
|
||||
if (kv.count(k)) stale += (stale.empty() ? "" : ", ") + std::string(k + 6);
|
||||
if (!stale.empty())
|
||||
LOG_WARN << "[Motor] ignoring keys nothing reads: " << stale
|
||||
<< " -- travel limits are derived from homing, the heading origin "
|
||||
"comes from yaw_heading_at_center (set it with 'gimbal heading "
|
||||
"setup'), and drivetrain keys belong under [Motor.yaw] / "
|
||||
"[Motor.pitch], not [Motor]";
|
||||
LOG_WARN << "[Motor] ignoring keys removed in the centre-anchored layout: " << stale
|
||||
<< " -- travel limits are now derived from homing, and the heading "
|
||||
"origin comes from yaw_heading_at_center (set it with "
|
||||
"'gimbal heading setup')";
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
|
@ -608,34 +559,6 @@ const std::string kPrevMarker = ";prev ";
|
|||
// 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
|
||||
// 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) {
|
||||
bool was_space = false;
|
||||
for (size_t i = 0; i < rhs.size(); ++i) {
|
||||
|
|
@ -689,7 +612,7 @@ std::string updateIniSectionKeys(const std::string& contents, const std::string&
|
|||
if (!seen[i] && key == kv[i].first) {
|
||||
seen[i] = true;
|
||||
old_value[i] = iniValueOf(line.substr(eq + 1));
|
||||
changed[i] = !sameIniValue(old_value[i], kv[i].second);
|
||||
changed[i] = old_value[i] != kv[i].second;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,9 +162,6 @@ DumpData parseDump(const std::string& block) {
|
|||
ax.center_count = asInt(get(m, "center", "0"));
|
||||
ax.hold_target = asInt(get(m, "hold_target", "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_peak_dev = asInt(get(m, "hold_peak", "0"));
|
||||
ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0"));
|
||||
|
|
|
|||
|
|
@ -74,12 +74,6 @@ const std::vector<HelpSection>& helpCatalog() {
|
|||
"the whole 'gimbal' family is ignored while one is in progress."}},
|
||||
{"gimbal speed <y|p> <vel>",
|
||||
"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>",
|
||||
"Other firmware motor controls.", {}},
|
||||
}},
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
#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
|
||||
|
|
@ -909,7 +909,7 @@ Element axisDumpDetail(const DumpAxis& ax) {
|
|||
// (incl. RAMP_STAT, the last one) is visible without scrolling.
|
||||
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
|
||||
char letter, bool calib_has, const CalibAxisView& cal,
|
||||
const AxisMapView& live_map, const DiagAxisView* diag, ScrollState& st) {
|
||||
const DiagAxisView* diag, ScrollState& st) {
|
||||
auto fmt = [](const char* f, double v) {
|
||||
char b[32];
|
||||
std::snprintf(b, sizeof(b), f, v);
|
||||
|
|
@ -957,21 +957,13 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
|
|||
// Last calibration result for this axis.
|
||||
col.push_back(separator());
|
||||
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) {
|
||||
// A fit ran this session: add its quality, and flag if the live map has
|
||||
// since moved away from it (a homing derivation, or a manual offset).
|
||||
col.push_back(kvRow("cnt/deg", fmt("%.3f", cal.counts_per_deg)));
|
||||
col.push_back(kvRow("deg@ctr", fmt("%.3f", cal.deg_at_center)));
|
||||
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(text("from a fit this session") | dim);
|
||||
} else if (calib_has) {
|
||||
col.push_back(text("last fit this session FAILED") | color(Color::Yellow));
|
||||
} else {
|
||||
col.push_back(text("from config, no fit this session") | dim);
|
||||
col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim);
|
||||
}
|
||||
|
||||
// Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the
|
||||
|
|
@ -993,7 +985,7 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
|
|||
std::string reset;
|
||||
for (size_t i = 0; i < d.reset_flags.size(); ++i)
|
||||
reset += (i ? " " : "") + d.reset_flags[i];
|
||||
std::string calib_note = "calibration from config";
|
||||
std::string calib_note = "uncalibrated this session";
|
||||
if (calib.has) {
|
||||
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
|
@ -1010,11 +1002,10 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
|
|||
});
|
||||
|
||||
std::vector<Element> cols;
|
||||
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, calib.live_yaw,
|
||||
diagFor('Y'), st[0]));
|
||||
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y'), st[0]));
|
||||
if (g.pitch_present)
|
||||
cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch,
|
||||
calib.live_pitch, diagFor('P'), st[1]));
|
||||
cols.push_back(
|
||||
axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P'), st[1]));
|
||||
|
||||
return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]),
|
||||
vbox({header, separator(), hbox(std::move(cols)) | flex}));
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ add_executable(fgc_tests
|
|||
test_paths.cpp
|
||||
test_singleinstance.cpp
|
||||
test_config.cpp
|
||||
test_tmccurrent.cpp
|
||||
test_cameraprofile.cpp
|
||||
test_camerasettingsxml.cpp
|
||||
test_hash.cpp
|
||||
|
|
|
|||
|
|
@ -596,98 +596,3 @@ TEST_CASE("A mount angle that cannot be a rotation is rejected") {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,32 +136,3 @@ TEST_CASE("formatDump renders valid and invalid dumps") {
|
|||
CHECK(joined.find("[P]") != 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
#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);
|
||||
}
|
||||
Loading…
Reference in New Issue