Rewrite the README around running a tower, with screenshots and diagrams

This commit is contained in:
pgdalmeida 2026-09-25 14:59:32 +02:00
parent d8d2fee8af
commit 50273779a3
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
8 changed files with 498 additions and 210 deletions

708
README.md
View File

@ -1,269 +1,557 @@
# Fire Gimbal Control # Fire Gimbal Control
Real-time control software for an automated **fire-watch gimbal**. A C++17 program runs on a tower-mounted **The program that runs a fire-watch tower.** It points a camera around the
PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama for wildfire horizon on a motorised gimbal, keeps one well-exposed picture at every stop,
detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT. saves it, and tells the ground station about it - all day, unattended, on a
small PC at the top of a mast.
A single codebase serves all fire-watch towers (FWT); the tower identity comes from `config.ini`. ![The dashboard of tower 111, live](docs/images/readme/dashboard.png)
<sub>The dashboard of tower 111, captured live on 2026-09-25: both axes homed
and ready, the camera mid-sweep at waypoint 2 of 15, the orientation and
ambient sensors reporting, messages flowing to the broker, and the last
self-test green.</sub>
The code is organized around an **SDK-independent core** (`fgc_core`: config, logging, capture scheduler, ---
parsers) and **swappable I/O implementations** behind three interfaces — motor controller, control channel,
and camera source — each with a real and a mock/null version. This makes the system deployable on any machine
and runnable **without hardware or an MQTT broker** for development.
State is held in memory (mutex-guarded), configuration is read once from an INI file, images are written to ## Contents
the filesystem as `.jxl`, and telemetry flows over MQTT. There is no database.
## Architecture at a glance - [What it does](#what-it-does)
- [How the pieces fit together](#how-the-pieces-fit-together)
- [A scan, step by step](#a-scan-step-by-step)
- [Running a tower](#running-a-tower)
- [The dashboard](#the-dashboard)
- [The plain console](#the-plain-console)
- [Everyday commands](#everyday-commands)
- [Is the tower healthy?](#is-the-tower-healthy)
- [Setting up and updating a tower](#setting-up-and-updating-a-tower)
- [For developers](#for-developers)
- [Documentation](#documentation)
``` ---
┌──────────────────────── fgc_core (no SDKs) ─────────────────────────┐
│ Config · Logger · CaptureScheduler · TelemetryParser · CommandParser │ ## What it does
└───────▲─────────────────▲──────────────────▲────────────────────────┘
│ IMotorController │ IControlChannel │ ICameraSource A fire-watch tower is a mast with a camera on a **gimbal** - a mount with two
┌───────────┴──────┐ ┌────────┴─────────┐ ┌──────┴────────────────┐ motors, one turning left and right (**yaw**), one tilting up and down
real │ SerialMotorCtrl │ │ MqttControlChannel│ │ VimbaCameraSource │ (WITH_VIMBA/WITH_MQTT) (**pitch**). This program, `fire_gimbal_control`, runs on the tower's
mock │ MockMotorCtrl │ │ NullControlChannel│ │ MockCameraSource │ LattePanda PC and does the watching:
└──────────────────┘ └──────────────────┘ └──────────┬────────────┘
frames 1. **Sweeps a scan grid.** A list of directions - "0 deg, 10 deg up", "45 deg,
▼ 10 deg up", and so on - that together cover the view. Tower 111's grid has
ImagePipeline → .jxl + CamEvent 15 stops.
2. **Keeps one good picture per stop.** The camera runs continuously with its
own auto-exposure; when the gimbal has stopped and settled, the newest frame
is kept.
3. **Saves it as JPEG XL**, a compact modern image format, one file per picture.
4. **Tells the ground station** over MQTT - which camera, which direction, and
when - so the picture can be fetched and checked for smoke.
It does this unattended: it starts at boot, homes the gimbal, and sweeps until
it is told to stop. A person only needs to look in when something is wrong -
and the dashboard, the log and the self-tests are there to show what.
---
## How the pieces fit together
Two programs cooperate, on two different chips:
| | The **firmware** | The **host program** (this repository) |
|---|---|---|
| Runs on | the small motor board (an RP2040 on the newer IOTA towers, an ATmega32U4 "Leonardo" on the older Delta ones) | the LattePanda PC |
| Lives in | its own repository, `GGS/fwt_firmware` on the same git server, checked out beside this one as `../firmware` | here |
| Its job | the **muscles and reflexes**: drive the two stepper-motor drivers, find the endstops, hold a position, stop the moment something is wrong | the **brain**: decide where to point and when to take a picture, own every setting, run the camera, talk to the ground station |
The firmware keeps **no settings of its own**. Every number that describes the
motors - the currents, the speeds, the travel, how a stall is detected - lives
on the PC, in a file called `hardware.ini`, and the program hands it to the
firmware each time they connect. Until it has them, the firmware **refuses to
move** - a motor running on guessed values can cook its driver or push the
gimbal through its endstop, and both have happened on this project.
```mermaid
flowchart LR
HW["hardware.ini<br/>what the tower IS"] --> APP
CFG["config.ini<br/>what the tower DOES"] --> APP
APP["fire_gimbal_control<br/>on the LattePanda"] -- "USB serial<br/>commands + settings" --> MCU["motor board<br/>firmware"]
MCU -- "status, 4x a second" --> APP
MCU -- SPI --> DRV["TMC5160<br/>stepper drivers"] --> MOT["yaw + pitch<br/>motors"]
CAM["camera<br/>(USB3)"] -- frames --> APP
APP --> JXL["pictures<br/>.jxl files"]
APP -- MQTT --> BROKER["broker on the tower"] --> GS["ground station"]
``` ```
See [docs/architecture.md](docs/architecture.md) for the full design. ### The two files
## Build Everything that makes one tower different from another is in two files:
| File | Where | What goes in it | Who writes it |
|---|---|---|---|
| `hardware.ini` | `/etc/fire_gimbal_control/` | **What the tower IS** - the board, which axes and sensors are fitted, how the motor board is wired, each motor's currents, travel and speeds, the camera's id | the installer, once; a person when the hardware changes |
| `config.ini` | beside the program | **What the tower DOES** - its name, the scan grid, capture settings, the broker, logging, and calibration such as which way is north | the installer (a first copy), the calibration commands, a person |
Where both set the same thing, `hardware.ini` wins, and the program says so at
start-up. Full reference: [docs/configuration.md](docs/configuration.md) and
[config/hardware.example.ini](config/hardware.example.ini).
### How they start talking
Every time the program connects to the motor board - at boot, after a restart,
after the firmware is updated - they go through the same short conversation:
```mermaid
sequenceDiagram
participant F as Firmware (motor board)
participant H as fire_gimbal_control (PC)
Note over F: power-up: stop both drivers,<br/>refuse HOME / MOVE
F->>H: BOOT build=...
F->>H: NEED Y steps_per_rev run_irun ... (what it is missing)
H->>F: SET Y steps_per_rev 177000
H->>F: SET Y ... (every value, from hardware.ini)
Note over F: all values in: axis ready to home
H->>F: DUMP
F->>H: DUMP ... (every value it now holds, and the pins it was built with)
Note over H: compare with hardware.ini<br/>(this is `test hardware motor`)
H->>F: HOME
F->>H: ST Y:A,... P:A,... (both axes READY)
```
So a tower is re-tuned by **editing `hardware.ini` and restarting the program** -
no firmware update needed. Only the **wiring** (which pin drives which driver)
is built into the firmware, and even that is read from the tower's own
`hardware.ini` when the firmware is built.
---
## A scan, step by step
```mermaid
flowchart TD
A([next waypoint]) --> B["move yaw and pitch<br/>to the waypoint"]
B --> C{"both axes<br/>stopped?"}
C -- no --> C
C -- yes --> D["hold still for settle_wait_ms<br/>(1 s by day, 2.5 s at night)"]
D --> E["keep the newest frame<br/>the camera delivered"]
E --> F["encode to JPEG XL<br/>save to the output folder"]
F --> G["publish a CamEvent<br/>(camera, heading, pitch, time)"]
G --> A
```
The camera never stops running. It free-runs at its profile's frame rate with
its own auto-exposure following the light, and almost every frame is thrown
away - their job is to keep the exposure right, so that the one frame kept at
each stop is already correctly exposed and was taken with the gimbal at rest.
Which frame rate and exposure limits apply comes from a **camera profile**: a
settings file exported from the camera itself. `standard` is for daylight,
`night` for low light; switch with `set camera profile night`.
---
## Running a tower
On a tower the program runs **detached, inside `tmux`**, so it keeps running
when you close your ssh session. `scripts/fgc` does that for you:
```bash ```bash
cmake -B build # configure (needs the Vimba X SDK + Paho for a full build) scripts/fgc start --init --start # start it: home the gimbal, then sweep
cmake --build build # -> build/fire_gimbal_control scripts/fgc attach # open the dashboard (Ctrl-b d to leave it running)
scripts/fgc status # is it running? since when? which PID?
# Development build with NO proprietary SDKs (mocks only): scripts/fgc peek # a text snapshot of the dashboard, without attaching
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF scripts/fgc stop # a clean shutdown - camera and motors stopped properly
cmake --build build scripts/fgc restart # stop, then start again
``` ```
Dependencies and the Vimba X SDK setup are in [docs/build-and-setup.md](docs/build-and-setup.md). > **Tip - detach, don't quit.** Inside the dashboard, `Ctrl-b d` leaves the
> program running. The dashboard's own `q` asks whether you want to **quit** the
> program or **background** it; background does the same as `Ctrl-b d`.
## Run **At boot**, a tower with the boot service starts itself the same way - through
`fgc`, so the dashboard is still there to attach to:
On a tower the program must outlive the ssh session that started it, so **`scripts/fgc`** runs it
detached inside a tmux session:
```bash ```bash
scripts/fgc start --start # start detached; returns immediately sudo systemctl status fire-gimbal-control # is the service running?
scripts/fgc attach # watch the dashboard — Ctrl-b d leaves it running sudo systemctl restart fire-gimbal-control # restart it (it homes the gimbal again)
scripts/fgc status # running? since when? which PID?
scripts/fgc stop # SIGTERM -> clean camera/motor teardown
scripts/fgc peek # snapshot of the pane without attaching
``` ```
Disconnecting from ssh while attached — or closing the laptop — is invisible to the program: tmux owns `--init` homes the gimbal at every start, which a tower in service needs: the
the pty. **When attached, the dashboard's `q` key exits the program; `Ctrl-b d` is what detaches.** motor board forgets its position whenever it loses power. On tower 111 the
service starts it with `--init --start`, so after a power cut it is back to
capturing within about a minute, with nobody touching it.
From a dev box, `./deploy.sh --run` builds on the device and starts it this way; `--attach`, `--status` **Only one copy can run at a time.** Two would fight over the motor board and
and `--stop` drive it afterwards without re-syncing. the camera, so a second one refuses to start and says which process holds the
lock. After a crash or a power cut there is nothing to clean up.
**Only one instance may run at a time.** The binary takes an exclusive `flock` on **The log** is kept per run in
`/run/user/<uid>/fire_gimbal_control.lock` (or `/tmp/fire_gimbal_control-<uid>.lock`) at startup and `~/.local/share/fire_gimbal_control/logs/fgc_<date>-<time>.<n>.log`, more
exits with status 2 and `another instance is already running (PID N)` if it is taken — two instances detailed than what the dashboard shows:
would fight over the serial port and the camera. The check is in the binary, so a
bare `./build/fire_gimbal_control` is refused too. Nothing needs cleaning up after a crash or a power
cut: the kernel drops the lock when the process dies. `--no-lock` bypasses it for a second mock-only
instance during development.
For development, or for a foreground run you intend to babysit, use `run.sh` directly:
```bash ```bash
# Real deployment (needs cameras, motor MCU on the configured serial port, MQTT broker): tail -F "$(ls -t ~/.local/share/fire_gimbal_control/logs/fgc_*.log | head -1)"
scripts/run.sh --start
scripts/run.sh --init --start # find endstops first
# Development, no hardware or broker:
scripts/run.sh --mock-serial --mock-camera --no-mqtt --start --log-level debug
# Same, but with the full-screen terminal dashboard:
scripts/run.sh --tui --mock-serial --mock-camera --no-mqtt
``` ```
`run.sh` resolves the binary relative to the repo, so it works from any checkout; extra args are forwarded to More in [docs/deployment.md](docs/deployment.md).
`fire_gimbal_control`. On launch the program starts the motor controller, opens the camera(s), starts the
image pipeline, connects the control channel (continuing in **degraded mode** if the broker is unreachable),
then enters a control loop. Without `--start` it idles until you type `start`. Stop with `exit` or Ctrl-D.
Config is resolved from `--config <path>`, then `$FGC_CONFIG`, `./config.ini`, the executable's directory, and ---
`$XDG_CONFIG_HOME/fire_gimbal_control/config.ini`. Copy the template to get started:
## The dashboard
The dashboard (the "TUI", for terminal user interface) is what `scripts/fgc
attach` opens. It updates live, and everything on it is read-only unless you
press a key that acts.
![The main view](docs/images/readme/dashboard.png)
| Panel | What it shows |
|---|---|
| **Header** | the tower's name, the program's build, `LIVE` (or `PAUSED`), how long it has been up |
| **GIMBAL** `g` | each axis: its state (`READY`, `HOMING`, `ERROR`...), the angle it is at and the one it is heading for, the motor and encoder counts, and flags - `STILL`/`MOVE`, `STALL`, over-temperature `OT`, endstops `L`/`R` |
| **SENSORS** `i` | the orientation sensor (roll, pitch, yaw) and the ambient temperature and humidity, each marked `live` or not |
| **CAMERA** `c` | whether it is capturing and how fast, the active profile, and the last picture: which camera, where it pointed, how long ago |
| **CONNECTIVITY** `m` | the broker connection, the tower name the messages carry, the control mode |
| **LOG** | the latest log lines; warnings in yellow, errors in red. `PgUp` scrolls back, `End` follows again |
| **ACTIVITY** | what is running right now (a sweep, a homing, a test) and the result of the last one |
| **Key bar** | the keys that do something in this view |
### Keys
| Key | Does |
|---|---|
| `s` / `x` | start / stop the capture sweep |
| `h` | home the gimbal |
| `r` | refresh: ask the firmware for a full status dump, re-read the sensor setup |
| `g` `i` `c` `m` | open the full-screen gimbal, sensors, camera or connectivity view; press it again to close |
| `?` | the built-in help: every command, with examples |
| `:` | type a command, exactly as in the [command list](#everyday-commands) |
| `p` | pause the display, so you can select and copy text (the program keeps running) |
| `PgUp` `PgDn` `Home` `End` | scroll the log |
| arrows | nudge the gimbal by hand - only when no sweep is running |
| `Space` | take one picture now - only when no sweep is running |
| `H` | set up north: aim at known landmarks and type their bearings - only when no sweep is running |
| `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration) |
| `q` | quit or background the program - it asks which |
| `Ctrl-b d` | leave the dashboard and keep the program running (a `tmux` key) |
### The full-screen views
**Gimbal** (`g`) - both axes in detail: positions, the motor driver's live
registers decoded into words, how well the encoder is tracking, the homed
limits and the calibration. The first time it opens it asks the firmware for a
full dump; `d` refreshes it.
![The gimbal view](docs/images/readme/gimbal.png)
**Sensors** (`i`) - the orientation sensor's full output (acceleration, turn
rate, magnetic field, its internal temperature) and configuration, and the
ambient sensor with the bus it is on.
![The sensors view](docs/images/readme/sensors.png)
**Camera** (`c`) - the camera itself (model, frame size, exposure, gain,
temperature, frames delivered and dropped, stream restarts), its profile and
encoding, and the whole scan grid with the current waypoint marked.
![The camera view](docs/images/readme/camera.png)
**Connectivity** (`m`) - the broker connection, message counters, the topics
the tower listens and speaks on, and the most recent messages, exactly as sent.
![The connectivity view](docs/images/readme/connectivity.png)
**Help** (`?`) - every command, grouped, with an explanation and an example.
The same text is available anywhere as `help` or `help <topic>`.
![The help view](docs/images/readme/help.png)
---
## The plain console
Without the dashboard, the program prints its log lines to the terminal and
reads commands from the keyboard, one per line. That is what you get with
`--no-tui`, with `[UI] enable_tui = false` in `config.ini`, or whenever the
output goes somewhere that is not a terminal - a pipe, a file, a script.
![The plain console](docs/images/readme/console.png)
<sub>Tower 111's own log from its start on 2026-09-24 - the plain console prints
exactly these lines. It loads its settings, opens the motor board, the sensors
and the camera, hands the firmware its settings, homes, starts sweeping, and
answers a typed `test hardware`.</sub>
Type any command from the [list below](#everyday-commands) and press Enter;
`exit` (or `Ctrl-D`) stops the program cleanly. Nothing here needs a mouse or a
large window, which makes it the easiest way to run the program by hand while
bringing a tower up.
For scripts, `--test` runs one self-test and exits with its verdict:
```bash ```bash
cp config/config.example.ini config.ini # then edit ./build/fire_gimbal_control --no-mqtt --test hardware
echo $? # 0 = PASS, 4 = FAIL, 5 = could not start (the log says why)
``` ```
---
## Everyday commands
The same commands work at the `:` prompt in the dashboard and in the plain
console. The ground station, over MQTT, has a narrower remote control: it can
switch the tower between the automatic sweep and pointing at a bearing it
names (`ControlCode`, `target_HDG` - see [docs/mqtt-api.md](docs/mqtt-api.md)).
| Command | Does |
|---|---|
| `start` / `stop` | start / stop the capture sweep (the gimbal stays homed and powered) |
| `gimbal home` | find the endstops and centre - needed after every power-up |
| `gimbal move <yaw>,<pitch>` | point at a **true bearing** (0 = north) and an elevation, in degrees |
| `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand |
| `snapshot` | take one picture now (only when no sweep is running) |
| `test [<subsystem> [<test>]]` | run self-tests - see [below](#is-the-tower-healthy) |
| `gimbal heading setup` | tie the yaw axis to true north by sighting landmarks |
| `set camera profile <name>` | switch the camera profile, e.g. `night` |
| `set fps <n>` | how many pictures per second the sweep aims for |
| `camera reset` | reboot the camera and put it back on its profile - for a camera that has stopped sending pictures |
| `gimbal dump` | show everything the firmware is holding |
| `debug` | more detail in the log, or back to normal |
| `trace <serial\|mqtt\|camera\|control\|all\|off>` | log every message to and from a part, word for word |
| `help [<topic>]` | the full list, with examples |
| `exit` | stop everything and quit |
The complete list, with every option, is in the built-in `help` and in
[docs/configuration.md](docs/configuration.md).
---
## Is the tower healthy?
One command checks almost everything, and changes nothing:
```bash
scripts/install-tower --verify
```
```text
== verify ==
ok usbfs_memory_mb 1000 (want >= 1000)
ok camera USB3 LPM 0 (want 0)
ok config files load cleanly, 0 shadowed key(s)
ok hardware.ini complete 0 TODO(s)
ok firmware pins running build=a0b1e4a ...; declared ... Y=8,7 P=1,0
ok test hardware PASS
ok test camera PASS
ok boot service enabled
ok user lingering yes
>> all checks pass
```
<sub>Tower 111, 2026-09-24.</sub>
If the program is running, the tests run **inside** it - the sweep pauses for
a few seconds and carries on afterwards. The two tests that matter most:
- **`test hardware`** - is this the tower `hardware.ini` describes? The declared
axes answer, the firmware holds exactly the declared settings and was built
for the declared wiring, the orientation and ambient sensors (if fitted) are
really there, the camera opened, and the USB settings the camera needs are in
place.
- **`test camera`** - does the camera work, whatever it is looking at? It never
judges the picture - fog, night or a lens cap would all make a bad picture out
of a healthy camera. Instead it uses the camera's own settings:
- **frames** - whole pictures arrive, in time;
- **dark** - at the shortest exposure and lowest gain, every pixel goes black;
- **bright** - at the highest gain and a long exposure, the picture becomes
clearly different from the dark one. That can only happen if the sensor is
alive and took the settings.
Afterwards the camera goes back on its profile.
A test that could not run - no ambient sensor fitted, say - says **not
checked**, never PASS. Every run is written to
`~/.local/share/fire_gimbal_control/logs/test_*.log` and compared with the one
before. More: [docs/test-command.md](docs/test-command.md).
Other test groups need a homed gimbal and move it: `test gimbal` (homing
repeatability, encoder, friction, backlash, balance) and `test imu`. `test host`
checks the PC itself (temperature, disk, memory, load).
---
## Setting up and updating a tower
**A new tower** - or one set up before any of this existed - is installed by
one script, run on the tower:
```bash
scripts/install-tower --dry-run # look, ask, show the plan; change nothing
scripts/install-tower # the same, then do it after one "yes"
```
It installs the packages and USB settings, the camera SDK, builds this program
and the firmware, writes the two files (asking only what it cannot find out
for itself), flashes the firmware, runs the tests, and sets up the boot
service. It **never guesses a motor value**: anything nobody has measured is
written as a `; TODO`, and the firmware is not flashed until it is filled in.
It only homes the gimbal after someone says they are standing at it. Re-running
it on a working tower is safe; it changes only what is out of date. The whole
story: [docs/tower-installation.md](docs/tower-installation.md).
**Updating** is done from a development machine, with each repository's
`deploy.sh`, and always in this order:
1. **this program first** - `./deploy.sh` here copies the code to the tower and
builds it there; restart it to use the new version;
2. **the firmware second** - `./deploy.sh` in the firmware repository builds it
on the tower and flashes the motor board.
Host first, because a new firmware asks for its settings the moment it starts,
and an older program would not know how to answer. Details:
[docs/deployment.md](docs/deployment.md).
---
## For developers
### The shape of the code
The program is a core that needs no hardware or proprietary SDKs, plus a real
and a stand-in version of each part that touches the outside world. That is
what lets the whole thing run on any Linux machine, with nothing attached.
```mermaid
flowchart TB
core["fgc_core - no hardware, no SDKs<br/>Config · CaptureScheduler · TestRunner<br/>parsers for telemetry, DUMP and commands"]
core --> I1[IMotorController] & I2[IControlChannel] & I3[ICameraSource]
I1 --> R1[SerialMotorController] & M1[MockMotorController]
I2 --> R2[MqttControlChannel] & M2[NullControlChannel]
I3 --> R3["VimbaCameraSource<br/>(Allied Vision Vimba X)"] & M3[MockCameraSource]
R3 --> P[ImagePipeline] --> J[".jxl files + CamEvent"]
```
Real on the left of each pair, stand-in on the right. There are stand-ins for
the orientation and ambient sensors too. The full design is in
[docs/architecture.md](docs/architecture.md).
### Build
```bash
cmake -B build # full build: needs Vimba X, fetches Paho MQTT
cmake --build build -j"$(nproc)" # -> build/fire_gimbal_control, build/fgc-config
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF # no proprietary SDKs: stand-ins only
```
Dependencies (OpenCV, Boost, libjxl, the Vimba X SDK) are in
[docs/build-and-setup.md](docs/build-and-setup.md).
### Run with nothing attached
```bash
./build/fire_gimbal_control --mock-serial --mock-camera --no-mqtt --tui
```
The stand-in motor board, camera and broker behave like the real ones closely
enough to drive the dashboard, run a sweep and run most self-tests.
`[Features] mock_needcfg = true` makes the stand-in motor board ask for its
settings the way the real firmware does.
### Command-line options ### Command-line options
| Flag | Effect | | Flag | Effect |
|------|--------| |---|---|
| `-c, --config <path>` | Use a specific `config.ini` (otherwise the search path above). | | `-c, --config <path>` | use this `config.ini` (otherwise `$FGC_CONFIG`, then `./config.ini`, ...) |
| `-i, --init` | Run the endstop-finding init sequence before entering the loop. | | `--hardware <path>` | use this `hardware.ini` (otherwise `$FGC_HARDWARE`, then `/etc/fire_gimbal_control/hardware.ini`, then beside `config.ini`) |
| `-s, --start` | Begin capturing immediately on launch (else wait for the `start` command). | | `-i, --init` | home the gimbal at start |
| `-d, --demo` | Demo mode: copy a placeholder `.jxl` instead of encoding live frames. | | `-s, --start` | start sweeping at once |
| `--no-mqtt` | Disable MQTT; use a null control channel (overrides `enable_mqtt`). | | `--test <selection>` | run one self-test and exit: 0 PASS, 4 FAIL, 5 could not start |
| `--mock-camera` | Use a simulated camera — no Vimba hardware needed. | | `--tui` / `--no-tui` | the dashboard / the plain console (overrides `[UI] enable_tui`) |
| `--mock-serial` | Use a simulated motor controller — no serial hardware needed. | | `--mock-serial`, `--mock-camera`, `--mock-env` | stand-ins instead of the motor board, camera, ambient sensor |
| `--tui` / `--no-tui` | Show the full-screen terminal dashboard / force the headless console (overrides `[UI] enable_tui`). | | `--no-mqtt` | no broker |
| `--no-lock` | Skip the single-instance lock (dev only — for a second mock-only instance). | | `--degraded` | start even though `hardware.ini` declares a part that is missing (field triage) |
| `--lock-file <path>` | Use a specific lockfile instead of the default per-uid path. | | `-d, --demo` | save a placeholder picture instead of encoding live frames |
| `--log-level <lvl>` | `trace`, `debug`, `info`, `warn`, `error`, or `off`. | | `--log-level <level>` | `trace`, `debug`, `info`, `warn`, `error`, `off` - for the screen |
| `--trace <cats>` | Verbatim wire tracing; comma list of `serial,mqtt,camera,control,all,none`. | | `--log-file-level <level>`, `--no-log-file` | the log file's level / no log file this run |
| `-h, --help` | Print options and exit. | | `--trace <parts>` | log every message to and from `serial`, `mqtt`, `camera`, `control`, `all` |
| `--no-lock`, `--lock-file <path>` | skip / move the single-instance lock (development only) |
| `-h, --help` | all of the above |
CLI flags override the matching `[Features]` / `[Logging]` keys in `config.ini`. ### Watching the wires
### Interactive commands Two separate knobs control what is logged:
While running, the program reads commands from stdin (one per line): - **the level** (`--log-level`, `debug`, `[Logging] level`) - how much ordinary
detail. At `info`, a running sweep is nearly silent;
- **traces** (`--trace`, `trace <part>`, `[Logging] trace`) - every single
message exchanged with a part, word for word, whatever the level. Off by
default; the camera one is busy.
| Command | Action | ```text
|---------|--------|
| `start` / `stop` | Start / stop the capture cycle (the gimbal stays powered and homed). |
| `gimbal move <yaw>,<pitch>` | Aim at an absolute **true bearing** (0 = north) and elevation, in degrees. |
| `gimbal home [y\|p]` | Run the endstop-finding home sequence. |
| `gimbal dump` | Request a firmware state dump (shown in the gimbal `g` view). |
| `gimbal diag [y\|p\|all]` | Run the firmware motor self-test (results in the activity strip + `logs/`). |
| `gimbal heading setup` | Tie yaw to true north by sighting known landmarks (TUI key `H`; **no IMU needed**). |
| `gimbal heading offset <deg>` | Shift the whole yaw map by a known amount, and save it. |
| `gimbal pitch setup` | Level pitch against gravity (auto-homes if needed; **needs the IMU**). |
| `snapshot` | Take one image now and publish its CamEvent (TUI: `Space`; manual mode only). |
| `gimbal …` | Other motor controls — `steps`/`nudge`/`jog`/`stop`/`speed`/`reset`/`enable`/`disable`/`setpos`/`status`. |
| `gimbal raw "<command>"` | **Low-level:** send the quoted text to the firmware verbatim (newline added). Bypasses wrappers/unit conversion — use with care. |
| `debug` | Toggle debug-level logging on/off. |
| `set fps <n>` | Capture rate, in images per second. |
| `set camera fps <n>` | Camera sensor frame rate. |
| `set camera profile [<cam>] <name>` | Switch a camera's profile (any `<name>.xml` in its `profile_dir`). |
| `set camera jxlq <d>` | JPEG XL quality as butteraugli **distance** (lower = higher quality / larger files). |
| `set camera jxle <n>` | JPEG XL encoder effort (higher = slower, smaller). |
| `set camera display <0\|1>` | Toggle the local preview window. |
| `trace <cat> [on\|off]` | Enable/disable a wire-trace category live (e.g. `trace serial`, `trace mqtt off`). |
| `trace all` / `trace off` | Enable every category / silence all of them. |
| `help [topic]` | List commands / expand one section. |
| `exit` | Stop everything and quit (Ctrl-D also works). |
**Image capture** runs the camera continuously and keeps one frame per waypoint. The camera free-runs
at the active mode's rate with its own auto-exposure tracking the light; almost every frame is
discarded, and their job is simply to keep that exposure controller converged. At each waypoint the
gimbal settles, holds still for the mode's `settle_wait_ms`, and the newest frame is kept — so the saved
image was exposed at rest and already correctly metered, with nothing waiting on a convergence loop.
Which rate and which exposure limits apply comes from a **camera profile**: a settings XML exported from
the VimbaX Viewer and handed straight to the Vimba X SDK, so the camera gets exactly the combination the
GUI validated. `standard` runs 10 Hz for daylight, `night` runs 1 Hz with long exposures; switch live with
`set camera profile <name>`. Details in
[docs/architecture.md](docs/architecture.md#image-capture); the mode-file format is `[Camera]` in
[docs/configuration.md](docs/configuration.md).
The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live
roll/pitch/yaw and powers `gimbal pitch setup`; the **SHT41** (`[Features] enable_env` + `[Env] i2c_device`,
or `--mock-env`) supplies the panel's ambient temperature and humidity — the MTi's device-internal
temperature is expanded-view-only, so the main window shows one unambiguous temperature. In the TUI,
`g` and `i` open full-screen **gimbal** and **sensors** views (the latter covering both the MTi and
the SHT41, each reading labelled with its source), and an **activity strip** below the log shows the
running calibration/diagnostics/scan plus the last result. See [docs/configuration.md](docs/configuration.md).
A typical first bring-up: `scripts/run.sh --init` to home the gimbal, then type `start` once you've confirmed
telemetry looks sane; adjust `set fps` / `set camera jxlq` live as needed. See
[docs/configuration.md](docs/configuration.md) for the full `config.ini` reference.
### Verbosity & wire tracing
Two independent knobs control output:
- **Log level** (`--log-level`, the `debug` console toggle, `[Logging] level`) gates ordinary
messages: `info` (default) shows discrete events only; `debug` adds per-image saves; `trace` is
the most verbose. At `info` an active capture is nearly silent.
- **Wire-trace categories** (`--trace`, the `trace` console command, `[Logging] trace`) log
**every** message exchanged with a subsystem, verbatim, and are **independent of the level** —
`--trace serial` shows all firmware serial traffic even at `info`. Categories are **off by
default** (the camera one is high-rate). Only `--log-level off` silences them.
Lines are tagged by category with a `TX`/`RX` direction, so a single subsystem is easy to follow
or `grep`:
```
14:02:01.234 [SERIAL] TX MOVE -90,0 14:02:01.234 [SERIAL] TX MOVE -90,0
14:02:01.250 [SERIAL] RX ST Y:A,982,969,80084000,0,8,8,Se P:A,... 14:02:01.250 [SERIAL] RX ST Y:A,982,969,80084000,0,8,8,Se P:A,...
14:02:02.000 [MQTT] PUB GGS/FWT/Tower/StatusCode 0 14:02:02.000 [MQTT] PUB GGS/FWT/Tower/StatusCode 0
14:02:02.500 [CAMERA] TX trigger cam0 14:02:02.500 [CAMERA] TX trigger cam0
14:02:02.910 [CAMERA] RX frame cam0 1936x1216 7064576B 14:02:02.910 [CAMERA] RX frame cam0 1936x1216 7064576B
14:02:02.000 [CONTROL] sweep -> grid yaw=-90 pitch=0
``` ```
Example — watch only firmware serial traffic on the device: ### Tests
`./deploy.sh --run` with `RUN_ARGS="--start --trace serial"`.
## Test
```bash ```bash
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF -DBUILD_TESTING=ON cmake -B build-test -DWITH_VIMBA=OFF -DWITH_MQTT=OFF -DBUILD_TESTING=ON
cmake --build build cmake --build build-test -j"$(nproc)"
ctest --test-dir build --output-on-failure ctest --test-dir build-test --output-on-failure
``` ```
The unit tests cover the core (config, paths, telemetry/command parsers, capture scheduler) and need no SDKs. Over 360 unit tests, needing no hardware and no SDKs: configuration and the two
files, the parsers, the capture scheduler, the self-test checks against the
stand-ins, the firmware handshake, the camera checks, the config-file editor.
## Deploy to the LattePanda ### Repository layout
The LattePanda is the machine wired to the gimbal and camera, so it builds and runs there. `deploy.sh` ```
rsyncs the codebase over ssh and runs the cmake build remotely (mirrors `../firmware/deploy.sh`). CMakeLists.txt build (WITH_VIMBA, WITH_MQTT, WITH_TUI, BUILD_TESTING)
main.cpp entry point: command line -> Application
```bash include/fgc/ headers: the interfaces, config, scheduler, tests, ...
cp .deploy.env.example .deploy.env # then edit REMOTE_HOST / REMOTE_DIR mock/ the stand-in implementations
./deploy.sh --check-deps # verify the device has OpenCV/Boost/libjxl src/
./deploy.sh # rsync + remote configure + build core/ the core: Application, config, scheduler, parsers, tests, ...
./deploy.sh --run # ... then run it over ssh (RUN_ARGS, ctrl-c to stop) camera/ Vimba X camera, image pipeline, JPEG XL encoder
./deploy.sh --clean --run # wipe remote build dir first, then build + run serial/ motor board and orientation-sensor links
mqtt/ MQTT control channel
sensors/ ambient sensor (SHT41) over I2C
ui/ the dashboard and the plain console
tests/ unit tests (doctest)
tools/fgc_config.cpp fgc-config: edits config.ini / hardware.ini safely
config/ config.example.ini, hardware.example.ini, scan grids, camera profiles
hardware/ per-board setup kits (LattePanda IOTA, LattePanda 3 Delta)
scripts/ fgc, install-tower, the boot service, camera-profile export
docs/ everything longer than a README
ini.c, ini.h the bundled INI parser (inih)
``` ```
`config.ini` and `images/` are device-local and never overwritten by a deploy; the first deploy seeds ---
`config.ini` from `config/config.example.ini` if the device has none. One-time device prerequisites
(Ubuntu): `sudo apt-get install -y build-essential cmake git libopencv-dev libboost-program-options-dev
libjxl-dev`, plus the Vimba X SDK under `/opt/VimbaX` for `-DWITH_VIMBA=ON`.
## Documentation ## Documentation
| Document | Contents | | Document | Read it for |
|----------|----------| |---|---|
| [docs/architecture.md](docs/architecture.md) | Components, interfaces, threading, data flow, capture state machine | | [docs/tower-installation.md](docs/tower-installation.md) | installing a tower, step by step, and what each step is for |
| [docs/build-and-setup.md](docs/build-and-setup.md) | CMake, dependencies, options, Vimba X / Paho, host setup | | [docs/deployment.md](docs/deployment.md) | running and updating a tower: `deploy.sh`, `fgc`, the boot service, the log, troubleshooting |
| [docs/deployment.md](docs/deployment.md) | Running and updating a tower: `deploy.sh`, `scripts/fgc`, the boot service, the log | | [docs/configuration.md](docs/configuration.md) | every key in `config.ini`, every flag, every command |
| [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console commands | | [docs/test-command.md](docs/test-command.md) | the self-tests: groups, profiles, reports, baselines |
| [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog and payloads | | [docs/mqtt-api.md](docs/mqtt-api.md) | the MQTT topics and what each message contains |
| [docs/test-command.md](docs/test-command.md) | The `test` hardware self-test suite: subsystems, profiles, reports, baselines | | [docs/known-issues.md](docs/known-issues.md) | what is known to be wrong or unfinished |
| [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures | | [docs/architecture.md](docs/architecture.md) | the design: parts, threads, data flow, the capture cycle |
| [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats | | [docs/modules-reference.md](docs/modules-reference.md) | file by file |
| [docs/roadmap.md](docs/roadmap.md) | Forward-looking project roadmap | | [docs/build-and-setup.md](docs/build-and-setup.md) | building, dependencies, the camera SDK |
| [docs/hardware-config-plan.md](docs/hardware-config-plan.md) | Splitting `hardware.ini` out of `config.ini`, and the firmware config handshake | | [docs/hardware-config-plan.md](docs/hardware-config-plan.md) | why `hardware.ini` exists, and the firmware handshake |
| [docs/hardware-porting-plan.md](docs/hardware-porting-plan.md) | Porting the tower to the LattePanda IOTA / RP2040 | | [docs/hardware-porting-plan.md](docs/hardware-porting-plan.md) | moving towers to the LattePanda IOTA |
| [docs/lattepanda-iota.md](docs/lattepanda-iota.md) | Hardware facts for the IOTA board | | [docs/lattepanda-iota.md](docs/lattepanda-iota.md) | the IOTA board |
| [docs/test-roadmap.md](docs/test-roadmap.md) | Planned tests blocked on not-yet-integrated components | | [docs/roadmap.md](docs/roadmap.md), [docs/test-roadmap.md](docs/test-roadmap.md) | what comes next |
## Repository layout ---
```
CMakeLists.txt Build (options: WITH_VIMBA, WITH_MQTT, BUILD_TESTING)
cmake/ FindVmb.cmake (Vimba X), Paho.cmake (FetchContent)
config/ config.example.ini, scan.csv (real config.ini is gitignored)
include/fgc/ Public headers: interfaces, Config, Logger, scheduler, impls
mock/ Mock/null implementations
src/
core/ Config, Logger, Paths, parsers, Geometry, ScanGrid, CaptureScheduler, Application
serial/ SerialMotorController
mqtt/ MqttControlChannel
camera/ VimbaCameraSource, ImagePipeline, JpegXlEncoder
main.cpp Thin entry point (CLI -> Application)
tests/ doctest unit tests
scripts/ run.sh + systemd unit template
ini.c / ini.h Bundled inih INI parser
```
## Ownership ## Ownership
Internal tooling for the GGS fire-watch tower network. No license file is present in the repository. Internal tooling for the GGS fire-watch tower network. No license file is
present in the repository.

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
docs/images/readme/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB