rov-payload is a BlueOS extension (a multi-arch
linux/arm/v7 + linux/arm64 Docker container) for vehicles running
BlueOS + ArduSub on a Raspberry Pi 4B or Compute Module 5 with a Blue
Robotics Navigator. It polls payload sensors — I2C today, UART/GPIO/UDP
planned — and republishes each reading to the Cockpit GUI as a MAVLink
NAMED_VALUE_FLOAT, routed through BlueOS's mavlink-router.
Three journeys, in the order you'll need them:
- Build & test locally — Development: venv,
pytest,ruff; no hardware needed (SIM=1runs the whole stack synthetically). Image builds need Build prerequisites. - Cut a release — Release automation:
bump
versioninpyproject.toml, commit, push a matching git tag — CI builds and pushes the multi-arch image. Manual fallback: Release walkthrough. - Install on a vehicle — Release walkthrough step 4 onward.
One-time org setup (handover checklist):
- Fork this repo into Stinger's GitHub organisation (further development happens on the fork).
- Create the container-registry namespace (e.g. a Docker Hub org).
- Set the CI variables + secrets from Release automation.
- Replace the
STINGER-CONTACT-EMAIL/STINGER-ORGplaceholders inDockerfile(extension-store metadata labels). - First release only: set the
NEW_REPOvariable to1(brand-new registry repo — see the walkthrough), delete it afterwards, and make the new repository public so vehicles can pull it.
Design goal #1: adding a new sensor must be trivial. Simple register-based
I2C devices need zero code (a sensors: config entry); anything else is a
small driver class. Both recipes are below.
I2C sensors (whitelisted buses only) UDP push devices (e.g. CP-Tool)
| i2c_rdwr (smbus2), retried | bound socket, JSON
v v
Transports app/transports/i2c.py -- I2CBus, I2CTransport
| app/transports/udp.py -- UdpTransport
v
Drivers app/payloads/* -- generic_i2c, demo, your class
| one poll-<device> thread per instance
v
Scheduler app/scheduler.py -- fixed-rate polling, failure
| degradation after 5 misses
| driver.read() -> {NAME: value}
v
MavlinkBridge app/mavlink/bridge.py -- single _send_lock, EXT_UP +
| HEARTBEAT ticker at 1 Hz
| named_value_float_send() over UDP
v
mavlink-router BlueOS-managed, host networking,
| udpout:127.0.0.1:14550 by default
v
Cockpit -> Tools -> DataLake (live NAMED_VALUE_FLOAT values)
Every release is a multi-arch image: linux/arm/v7 + linux/arm64
under one version tag, built with docker buildx and (on non-ARM hosts)
QEMU emulation. A machine that has never built this project needs, in
order:
- Docker with the buildx plugin. Docker Desktop (macOS/Windows) and
any recent Docker Engine (Linux) include it. Check:
docker buildx version. - QEMU binfmt emulation for whichever target platforms your CPU
can't run natively. Docker Desktop ships it; on a bare-Linux build
host install it once with:
Check:
docker run --privileged --rm tonistiigi/binfmt --install arm,arm64docker buildx ls— the platform list must include bothlinux/arm/v7andlinux/arm64. - A registry login for the namespace you push to: run
docker loginfreshly before releasing. This is the one prerequisite the script cannot pre-check — a missing or expired login only surfaces at the registry steps, and against a brand-new repository it is indistinguishable from "repository does not exist" (see theNEW_REPO=1step in the walkthrough).
scripts/build.sh verifies the first two before spending minutes on an
emulated build and fails with the fix in its error message — it never
falls back to a single-arch image. (A git clone preserves the script's
exec bit; if your copy arrived some other way and it got lost, run
bash scripts/build.sh … or chmod +x scripts/build.sh once.) The
release steps are in Release walkthrough.
From 1.0 on this extension is a product, not a toolkit: it ships with a standard, built-in payload set, enabled by default (spec 004). A fresh install on the standard vehicle produces working telemetry with zero configuration; anything beyond the set arrives in 1.1, 1.2, … .
| Payload | Category | Status |
|---|---|---|
| CP-Tool (cathodic protection) | network push (UDP) | shipped, field-verified |
| pH — ANB OC300 | nport_stream + anb_oc300 parser (NPort lock-on) |
shipped (spec 008) — awaiting field verification; Modbus rollback: oc300_modbus (spec 005) |
| Temperature — BR Celsius | ArduSub-native: not in the extension, by design | documented below |
| Sonar — Oculus M-series | scope undecided (telemetry vs Cockpit display) | placeholder (spec 006) |
| PWM motor control | actuator/output — the one outbound payload | placeholder (spec 007) |
| Bar30 (pressure/temp) | ArduSub-native on the standard vehicle; driver retained as the I2C reference | dormant by default |
Any sensor ArduSub reads natively (Celsius, Bar30/Bar100, Ping, …) is
ArduSub's: its value reaches Cockpit directly from the autopilot
stream, and this extension deliberately does not touch it. Two masters on
one chip corrupt readings for both — field-proven here when ArduSub and
the extension both drove the same MS5837 (Errno 121 NAK bursts, on the
chip ArduSub dives on). Bus sharing through the same kernel /dev/i2c-N
is safe (the kernel serializes); chip sharing never is. The extension
exists for what ArduSub does not handle — in-house and third-party
devices — which is why the 1.0 set is network/serial payloads and the I2C
whitelist ships empty. The Bar30 driver (spec 002) stays in the image as
the I2C-category reference for bench rigs and future non-ArduSub
platforms; enabling it there is a two-line config change.
These came out of a previous attempt at this project and are treated as requirements, not style preferences:
- Exactly one
threading.Lockguards every write to the pymavlink socket — two threads writing at once corrupts the stream. - UDP is the default MAVLink transport; TCP is never hardcoded (TCP to the router has caused connection-reset storms on this hardware).
- I2C uses
i2c_rdwrtransactions only.read_i2c_block_dataandread_bytedo not appear anywhere inapp/ortests/— enforced by a grep gate inscripts/build.sh. - Only I2C buses explicitly whitelisted in config are ever opened. ArduSub owns the IMU/compass buses; touching them breaks the vehicle's compass and horizon.
- Every version tag is a multi-arch manifest:
linux/arm/v7(Pi 4B on 32-bit BlueOS) +linux/arm64(CM5 — its 16 KB-page kernel cannot run 32-bit armv7; the container exits 139 before any code runs). The build script refuseslatest, refuses to overwrite an existing registry tag, and after pushing verifies the manifest really contains both platforms. If a vehicle misbehaves after a deploy, check which variant it is actually running:docker image inspect --format '{{.Architecture}}/{{.Variant}}' <image>. - The container needs zero extra Docker capabilities (no
NET_ADMIN, no privileged mode) — see the BlueOS permissions pitfall below.
For any sensor that is "read N bytes from a register, scale, done", add a
sensors: entry to the extension's config file on the vehicle (the
persistent volume — this is not a file in the image): the container sees it
as /app/userdata/config.yaml; on the vehicle's own filesystem it is
/usr/blueos/extensions/rov-payload/config.yaml. This is the same
TEMP_X example shipped in config.example.yaml:
sensors:
- name: TEMP_X # NAMED_VALUE_FLOAT name (<=10 ASCII chars)
bus: 1 # must be listed in i2c.buses
address: 0x48
register: 0x00
length: 2
scale: 0.0625
offset: 0.0
signed: true # optional, default false
byteorder: big # optional, default big
interval: 2.011 lines — under the 15-line budget. Then:
- Restart the extension in BlueOS Extensions Manager (config is loaded once at startup; there is no hot-reload).
- Open Cockpit -> Tools -> DataLake and confirm
TEMP_Xis updating.
generic_i2c reads register (length bytes, byteorder, signed per the
options above), interprets it as an integer, and publishes
raw * scale + offset under name. sensors: entries are never probed —
config is authoritative, so the bus in bus: must already be in i2c.buses
(section below) or startup fails with an error naming the entry.
For anything generic_i2c can't express (multi-register reads, PROM
calibration words, non-linear decoding, a different transport), drop a class
in app/payloads/. The shipped Blue Robotics Bar30 driver
(app/payloads/ms5837.py, spec 002) is the worked example — its skeleton
is exactly the recipe:
from app.payloads.base import PayloadBase
from app.payloads.registry import register
@register("bar30")
class MS5837(PayloadBase):
i2c_addresses = (0x76,) # discovery probes these on whitelisted buses
sim_capable = True # optional: SIM=1 runs it synthetically
@classmethod
def probe(cls, transport): # a known-PROM read, never an address scan
try:
transport.read_register(0xA0, 2)
except Exception: # transport errors mean "not present"
return False
return True
def setup(self):
... # one-time init; raising = refused + logged
def value_names(self):
return ("PRES", "TEMP") # <=10 ASCII chars, validated at startup
def read(self): # runs on the device's own poller thread
... # -> {"PRES": <mbar abs>, "TEMP": <degC>}Everything the full file adds is device knowledge, not framework wiring:
the 7-word PROM read (retried 5×) with a CRC-4 gate in setup() — a
bad PROM means garbage calibration, so the driver refuses to operate and
startup logs init failed instead of publishing nonsense — plus the D1/D2
conversion cycle with per-OSR settling waits and the datasheet's 1st- +
2nd-order temperature compensation as two pure functions. Those functions
are pinned by golden tests (tests/golden/test_ms5837_golden.py): the
datasheet's example values in, exact expected outputs out, all three
compensation branches covered.
Driver options ride in the devices: entry (e.g. options: {osr: 8192})
and are validated in the constructor, so a typo fails startup loudly.
Drop the file in app/payloads/, rebuild the image (scripts/build.sh,
below) — app.payloads.registry.autoimport() imports every module under
app/payloads/ at startup, so @register(...) runs and the class is in
the registry with no other wiring. If i2c_addresses is set, discovery
probes it automatically on every whitelisted bus at startup; probe() must
never raise.
Bar30 on the standard vehicle: don't. ArduSub owns it (depth /
SCALED_PRESSURE2 reach Cockpit from the autopilot stream) — see the
Stinger 1.0 governing rule above. The driver exists as the I2C-category
reference for bench rigs and non-ArduSub platforms only: there,
whitelist the bus the sensor hangs on (i2c: buses: [N]), restart, and
discovery finds the chip at 0x76 with no devices: entry — PRES
(absolute mbar, ≈1013 at the surface, +≈100 per metre of water) and TEMP
(°C) appear in the DataLake.
⚠️ Address-collision trap (field-verified 2026-07-06): the Navigator's internal BMP280 barometer sits oni2c-1at0x76— the same address as the Bar30's MS5837. A driver pointed at bus 1 discovers ArduSub's barometer instead of a payload sensor and fails the PROM CRC gate (the BMP280 answers0xA0reads with near-empty data,chip-id 0x58at register0xD0). The empty-whitelist default makes this impossible unless someone opts in.
Some payloads push readings instead of waiting to be polled. The shipped
CP-Tool driver (app/payloads/cp_tool.py, spec 003) is the worked
example: an Arduino streams {"tag":1234 , "value":-259.00 , "time_stamp":12345678} datagrams at ~5 Hz to the Pi's UDP port 8888.
@register("cp_tool")
class CpTool(PayloadBase):
transport_kind = "udp" # build_devices hands it a UdpTransport
default_interval = 0.2 # matched to the device's push rate
sim_capable = True
def value_names(self):
return ("CP_MV",)
def read(self): # drain the socket, newest value wins
... # {} on silence = a healthy, empty pollPush drivers are never discovered and never probed — a devices: entry is
the whole installation (config is authoritative, like sensors:):
devices:
- driver: cp_tool
options:
host: "0.0.0.0" # bind address (default)
port: 8888 # defaultTwo rules make push coexist with the polling scheduler: read() blocks at
most the socket timeout (0.25 s), so shutdown is never held up; and a quiet
socket returns {}, which the scheduler counts as a successful poll —
datagram gaps are normal UDP weather, not sensor failure (after 10 s of
silence the driver logs one throttled INFO instead of failing). Malformed
datagrams are skipped with a throttled WARNING, never a crash. The packet's
time_stamp field is a firmware placeholder and is ignored — values are
timestamped on receipt. No extra container permissions: host networking
already exposes the UDP port.
Some payloads talk on their own — the extension just listens. The fleet
exposes them through Moxa NPort serial servers on static IPs
192.168.2.60–.69 (RealCOM mode, data on TCP port 950, so the
vendor's own GUI can stay attached). An NPort is a transport, not a
sensor: different vehicles use different slots in the range, and any
serial device may sit behind one. So the driver is generic
(app/payloads/nport_stream.py) and the device protocol is a config
choice: options.protocol picks a parser from app/protocols/. The
shipped parser is anb_oc300 (OC300 pH, spec 008): it splits the
stream into $ANB lines and emits PH, TEMP_PH (°C; named so it
can't collide with ArduSub's own temperature in the DataLake) and
PH_HLTH (transducer health 0–9, raw integer-as-float; code table in
the parser, interpretation in Cockpit). Diagnostics is intentionally not
emitted — ANB-GUI troubleshooting data, not telemetry (spec 005 §9.7
decision carries over).
Lock-on: candidates are dialed in order, and a TCP connect proves
nothing — the Moxa answers whether or not the right sensor is behind it.
A host is locked only when the parser returns valid data (INFO
locked on nport:<host>:<port> (protocol <name>)). An unlocked
candidate gets lock_window_s (20 s for anb_oc300) to prove itself; a
locked host that goes silent past stale_after_s (180 s) is a fault.
Either way the driver raises, the scheduler's 5-failure degradation
engages, and the next poll rotates to the next candidate (WARNING
no valid <protocol> data for Xs ... rotating) — so the extension
settles on the data-producing host with no manual steps, and one config
runs fleet-wide. A link drop redials the same host first. Multiple
nport_stream entries share a claim registry and never attach to the
same host; note each entry still needs distinct value names (NAMED_VALUE
uniqueness is enforced at startup), so two entries only make sense with
different protocols. The transport never sends a byte (no send API
at all). If no candidate connects at startup, the entry is skipped with
init failed and the extension runs on.
Stream devices are never discovered and never probed — a devices:
entry is the whole installation:
devices:
- driver: nport_stream
options:
protocol: anb_oc300 # required: which parser understands the stream
# hosts: ["192.168.2.60", "192.168.2.61"] # default: full fleet range .60–.69
# host: "192.168.2.61" # sugar for a one-element hosts list
# port: 950 # RealCOM data port (default)Freshness rules (unchanged from spec 008): a poll with no new line is a
healthy {} (the stream is slower than the poll; once locked, one
throttled INFO after 15 s of silence); pH 99.99 is the sensor's
invalid sentinel — PH is omitted, never published as real. Values are
only emitted on new lines, so a frozen reading is never refreshed.
Parse defensively: lines terminate LF-then-CR, recv chunks split lines
anywhere, the file-number field carries a stray ?> prefix — all
covered by the captured-line golden tests. The $ANB CRC field is not
validated yet (algorithm unconfirmed: Modbus-CRC16 vs CRC-16/CCITT —
TODO in the parser). Device-side TCP is fine — the project's TCP ban
protects the MAVLink endpoint only; no extra container permissions.
This is the point of the restructure: a new serial device behind an NPort is one small parser module, zero driver/transport work.
- Create
app/protocols/my_device.py:
from app.protocols import register
from app.protocols.base import StreamProtocol
@register("my_device")
class MyDevice(StreamProtocol):
lock_window_s = 20.0 # tune to the device cadence (see base.py for
stale_after_s = 180.0 # quiet_info_after_s / quiet_hint too)
def value_names(self):
return ("MY_VAL",) # <=10 chars, unique fleet-wide
def parse_line(self, line): # one complete, terminator-stripped line
# return {"MY_VAL": 1.23} for a valid sample; None for anything
# else. None is the lock-on signal ("not my device speaking"):
# log throttled, never raise.
...- Reference it from config:
protocol: my_device(plushosts/host/portas above). No other wiring — the registry autoimports the module, and config validation picks the name up automatically. Optionally implementsim_read(t)for SIM mode. - Test with captured golden lines, like
tests/test_protocols.py.
Migration: - driver: oc300 still works as an alias for
nport_stream + protocol: anb_oc300 (deprecation INFO at startup);
old vehicle configs run unchanged, and the fleet-range default means
the same config now runs on any vehicle regardless of its NPort IP.
Rollback: the spec-005 Modbus request/response driver is retained as
oc300_modbus (the reference implementation for polled TCP devices) —
it needs the Moxa back in TCP Server mode on port 4001.
config.example.yaml is the full annotated reference; copy it onto the
vehicle and edit from there (if the file is missing, the extension writes a
commented default template on first boot and runs with its safe defaults).
The file is /app/userdata/config.yaml inside the container; on the
vehicle's filesystem that same file is
/usr/blueos/extensions/rov-payload/config.yaml (the extension's bind
mount) — edit it there via the BlueOS web Terminal or scp. Config is
loaded once at startup — restart the extension to apply changes.
i2c:
buses: [] # I2C buses that MAY be scanned/used, e.g. [6].
retries: 4 # per-transaction retries
retry_backoff_s: 0.01i2c.buses defaults to empty, and from 1.0 that is product policy, not
just caution (spec 004 §1.1): on the standard vehicle every I2C sensor
belongs to ArduSub — bus 1 carries the internal IMU/compass/baro (BMP280
at 0x76), bus 4 the compass/ADC, and bus 6 is ArduSub's external
sensor bus (BARO_EXT_BUS) for the Bar30/Celsius family. Discovery
issues real I2C transactions; a second master on an ArduSub chip corrupts
the readings the vehicle dives on. Whitelist a bus only on a platform
where ArduSub does not own it (bench rig, CM5 study). The /dev/i2c-6
permission in the image exists for exactly those setups and is inert
while the whitelist is empty (see the pitfall below).
devices: and sensors: entries are reconciled against what discovery finds
(spec §5):
- A
devices:entry disables a discovered device withenabled: false(the address is released, not just hidden). interval,bus,addresson adevices:entry override the discovered values.probe: falseon adevices:entry forces instantiation of an undiscovered device —busandaddressbecome mandatory in that case.probe: true(default) probes first and skips with a warning if the device doesn't answer.sensors:entries always instantiategeneric_i2c— never probed, config is authoritative.- Any
devices:/sensors:entry naming a bus outsidei2c.busesis a startup error, not a silent skip.
| Variable | Effect | Default |
|---|---|---|
MAVLINK_ENDPOINT |
Overrides mavlink.endpoint from config |
udpout:127.0.0.1:14550 |
SIM |
1/true/yes/on: skip discovery, open no I2C bus, force-enable the demo driver; devices: entries whose driver is sim_capable (e.g. bar30) run synthetically |
unset |
LOG_LEVEL |
stdlib logging level |
INFO |
CONFIG_PATH |
Path to the config file (lets local/dev runs avoid /app/userdata) |
/app/userdata/config.yaml |
Changing the Docker image tag in BlueOS Extensions Manager does NOT update the container's permissions. Capabilities, volume binds, and device nodes come from the install-time "Custom settings" JSON — they are captured once, when the extension is installed, and are not re-read on a version bump. Any permission change requires removing and reinstalling the extension, not just pulling a new tag.
The image ships these permissions in its permissions label (see
Dockerfile):
NetworkMode: host— to reachmavlink-routeron localhost UDP.- A bind mount for
/app/userdata— the persistent config volume. - A
Devicesentry for/dev/i2c-6— inert by default (the I2C whitelist ships empty; bus 6 is ArduSub's external sensor bus on the standard vehicle). Kept so bench/non-ArduSub setups can enable the Bar30 reference driver without the remove+reinstall dance.
No CapAdd, no Privileged: the extension needs zero extra Docker
capabilities by design. If your sensor is on a different I2C bus, you must
edit the Custom settings JSON to add that device node at install time
(remove the extension, reinstall with the edited JSON) — bumping the version
tag alone will not grant access to the new bus.
.github/workflows/release.yml builds and pushes the multi-arch image on
every pushed version tag, so releasing never depends on an individual's
machine. It first re-runs the commit gates (pytest, ruff check) — a
red tree cannot become a release — then runs scripts/build.sh, so CI and
manual releases share the exact same guard rails (no latest, no per-arch
tags, no overwriting an existing registry tag, both platforms verified
after push).
Configure once in the GitHub repo/org settings (Settings → Secrets and variables → Actions):
| Kind | Name | Value |
|---|---|---|
| Variable | IMAGE_REPO |
full image path, e.g. docker.io/<stinger-namespace>/rov-payload |
| Variable | REGISTRY |
(optional) registry host for login, default docker.io |
| Variable | NEW_REPO |
(first release only) set to 1, then delete |
| Secret | REGISTRY_USERNAME |
registry account the CI pushes as |
| Secret | REGISTRY_TOKEN |
write-scope access token for that account — never a real password |
No credential is ever committed; the workflow reads only these.
Releasing: set version in pyproject.toml to the new version, commit,
then tag that exact string and push the tag (Docker tags mirror git tags —
no v prefix):
git tag 1.0 && git push origin 1.0
The workflow refuses a tag that doesn't match pyproject.toml, and the
build script's rails do the rest. Read the job log's final lines — a good
release prints both platforms of the pushed manifest.
The manual path — same script, same rails, from any machine with the
Build prerequisites. Every version tag is
one multi-arch manifest covering the Pi 4B (linux/arm/v7) and the CM5
(linux/arm64) — never per-arch tags. Versions are semver and mirror
version in pyproject.toml; latest is refused; a tag already in the
registry is never overwritten — bump the version instead.
-
Run the commit gates, pick the version, build and push. The gates (see Development) are manual — of the four, the build script re-runs only the banned-call grep, so run
pytestandruffyourself before releasing. The version is the next semver afterversioninpyproject.toml: bump that field in the same commit and pass the identical string (the script does not readpyproject.toml— keeping them in lockstep is a convention you maintain).export IMAGE_REPO=docker.io/<your-namespace>/rov-payload scripts/build.sh <version> --pushThe script runs its guard rails first (banned-call grep, buildx + emulation checks, registry free-tag check), then builds both platforms and pushes them as a single manifest.
-
First release into a brand-new namespace only: the free-tag check fails closed, and registries answer "denied" / "repository does not exist" for a repo that has never been pushed — indistinguishable from a broken login, so the script refuses to guess and stops.
NEW_REPO=1is the deliberate safety valve for exactly this case — the check still runs, but an unverifiable answer is downgraded to a loud WARNING instead of a hard stop (a tag that verifiably exists is still refused), and every other rail still runs. Because a dead login looks identical,docker loginfreshly first:NEW_REPO=1 scripts/build.sh <version> --pushOnce the repository exists, drop it: the overwrite guard is what makes a version tag immutable, and it exists because a stale tag on Docker Hub once handed a vehicle an image that couldn't run.
-
Read the final output. A good push ends like this, and the script fails if either platform is missing from the pushed manifest:
pushed docker.io/<you>/rov-payload:<version> with platforms: Platform: linux/arm/v7 Platform: linux/arm64 OK: docker.io/<you>/rov-payload:<version> manifest covers linux/arm/v7 + linux/arm64 -
Install on the vehicle (BlueOS → Extensions Manager). First install: add the extension manually with your image (
<your-namespace>/rov-payload) and the version tag — container permissions are taken from the image'spermissionslabel at install time (read the BlueOS permissions pitfall section above before editing them). The vehicle pulls anonymously: after the first push, check on the registry website that the new repository is public, or the pull fails with "pull access denied". Upgrade: pull the new version tag and restart the extension. Docker on the vehicle picks the right variant by itself (Pi 4B →arm/v7, CM5 →arm64). -
Verify:
EXT_UPis ticking and your sensor values are updating in Cockpit -> Tools -> DataLake. Thedockercommands in this step and the next run on the vehicle, not the build machine — open a shell via BlueOS → Terminal (red-pillfor the host shell) and find the container name withdocker ps | grep rov-payload. If a vehicle misbehaves, first check which variant it actually pulled:docker image inspect --format '{{.Architecture}}/{{.Variant}}' <your-namespace>/rov-payload:<version> -
Baseline check: count error/reconnect lines over a fixed window and compare against the previous deploy's baseline (48 minutes is this project's standard control window):
docker logs --since 48m <container> 2>&1 | grep -c "reconnect #"A rising count relative to the previous deploy's window means something regressed (link flakiness, a misbehaving driver holding up the bus, etc.) — investigate before calling the deploy good.
NAMED_VALUE_FLOATnames are 1–10 ASCII characters,[A-Za-z0-9_]only.- Validated at startup, after every enabled device is instantiated: an
invalid name or a duplicate aborts startup with an error naming both
colliding sources (e.g.
duplicate name 'TEMP_X': sensors[0] and demo). EXT_UPis reserved — it's the bridge's own 1 Hz liveness signal; no driver may use it.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
Run the checks that gate every commit (scripts/build.sh re-runs only the
last one, the banned-call grep — pytest and ruff are on you, so run them
before every release):
pytest
ruff check .
ruff format --check .
grep -RInE 'read_i2c_block_data|read_byte' app/ tests/ # must match nothing
Hardware-free run (no I2C bus is ever opened, demo is force-enabled):
CONFIG_PATH="$(mktemp -d)/config.yaml" SIM=1 python -m app.main
expected log lines: SIM mode: skipping discovery, no i2c bus will be opened, SIM mode: 'cp_tool' running synthetically (no i2c),
connected to udpout:..., running: 3 device(s), 5 thread(s) (the
default template enables cp_tool + nport_stream (anb_oc300); demo is
force-enabled by SIM).
pymavlink's UDP transport swallows most socket errors internally, and an
unconnected UDP send succeeds locally regardless of whether anything is
listening on the other end. That means the bridge's exception-triggered
reconnect (spec §6.3) fires reliably on initial-connect failures and on
explicitly configured TCP endpoints, but a silently dead UDP path to
mavlink-router is not locally detectable — there is no socket-level
signal to react to. This is expected for connectionless UDP (traffic resumes
on its own once the router comes back, no reconnect needed) but it means the
container's own logs cannot prove end-to-end health by themselves.
The operational health check is EXT_UP visible and ticking in Cockpit
(see the deploy routine above) — if EXT_UP is live, the MAVLink path is
up, full stop. Treat a stalled EXT_UP as the signal to investigate, not
the absence of reconnect # lines in the log.