Skip to content

Repository files navigation

rov-payload

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.

Quick start (new owner)

Three journeys, in the order you'll need them:

  1. Build & test locallyDevelopment: venv, pytest, ruff; no hardware needed (SIM=1 runs the whole stack synthetically). Image builds need Build prerequisites.
  2. Cut a releaseRelease automation: bump version in pyproject.toml, commit, push a matching git tag — CI builds and pushes the multi-arch image. Manual fallback: Release walkthrough.
  3. Install on a vehicleRelease 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-ORG placeholders in Dockerfile (extension-store metadata labels).
  • First release only: set the NEW_REPO variable to 1 (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)

Build prerequisites

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:

  1. Docker with the buildx plugin. Docker Desktop (macOS/Windows) and any recent Docker Engine (Linux) include it. Check: docker buildx version.
  2. 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:
    docker run --privileged --rm tonistiigi/binfmt --install arm,arm64
    
    Check: docker buildx ls — the platform list must include both linux/arm/v7 and linux/arm64.
  3. A registry login for the namespace you push to: run docker login freshly 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 the NEW_REPO=1 step 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.

Stinger 1.0 — the standard payload set

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

Don't compete with ArduSub for native sensors (the governing rule)

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.

Hard constraints (non-negotiable — see docs/specs/001-core-framework.md §1)

These came out of a previous attempt at this project and are treated as requirements, not style preferences:

  1. Exactly one threading.Lock guards every write to the pymavlink socket — two threads writing at once corrupts the stream.
  2. UDP is the default MAVLink transport; TCP is never hardcoded (TCP to the router has caused connection-reset storms on this hardware).
  3. I2C uses i2c_rdwr transactions only. read_i2c_block_data and read_byte do not appear anywhere in app/ or tests/ — enforced by a grep gate in scripts/build.sh.
  4. 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.
  5. 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 refuses latest, 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>.
  6. The container needs zero extra Docker capabilities (no NET_ADMIN, no privileged mode) — see the BlueOS permissions pitfall below.

Add a new sensor — path 1: YAML only (zero code)

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.0

11 lines — under the 15-line budget. Then:

  1. Restart the extension in BlueOS Extensions Manager (config is loaded once at startup; there is no hot-reload).
  2. Open Cockpit -> Tools -> DataLake and confirm TEMP_X is 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.

Add a new sensor — path 2: driver class

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 on i2c-1 at 0x76 — 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 answers 0xA0 reads with near-empty data, chip-id 0x58 at register 0xD0). The empty-whitelist default makes this impossible unless someone opts in.

Add a new sensor — path 3: push transport (UDP)

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 poll

Push 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        # default

Two 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.

Add a new sensor — path 4: NPort stream (nport_stream + a protocol parser)

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.

⚠️ Operational dependency (OC300): the ANB GUI's SCAN keeps the sensor streaming. No SCAN → silent stream → no pH, by design.

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.

Adding a new protocol

This is the point of the restructure: a new serial device behind an NPort is one small parser module, zero driver/transport work.

  1. 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.
        ...
  1. Reference it from config: protocol: my_device (plus hosts/host/ port as above). No other wiring — the registry autoimports the module, and config validation picks the name up automatically. Optionally implement sim_read(t) for SIM mode.
  2. 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.

Configuration

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 bus whitelist

i2c:
  buses: []            # I2C buses that MAY be scanned/used, e.g. [6].
  retries: 4           # per-transaction retries
  retry_backoff_s: 0.01

i2c.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).

Config is the last word

devices: and sensors: entries are reconciled against what discovery finds (spec §5):

  • A devices: entry disables a discovered device with enabled: false (the address is released, not just hidden).
  • interval, bus, address on a devices: entry override the discovered values.
  • probe: false on a devices: entry forces instantiation of an undiscovered device — bus and address become mandatory in that case. probe: true (default) probes first and skips with a warning if the device doesn't answer.
  • sensors: entries always instantiate generic_i2c — never probed, config is authoritative.
  • Any devices:/sensors: entry naming a bus outside i2c.buses is a startup error, not a silent skip.

Environment variables

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

⚠️ BlueOS permissions pitfall

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 reach mavlink-router on localhost UDP.
  • A bind mount for /app/userdata — the persistent config volume.
  • A Devices entry for /dev/i2c-6inert 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.

Release automation (GitHub Actions)

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

Release walkthrough

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.

  1. 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 pytest and ruff yourself before releasing. The version is the next semver after version in pyproject.toml: bump that field in the same commit and pass the identical string (the script does not read pyproject.toml — keeping them in lockstep is a convention you maintain).

    export IMAGE_REPO=docker.io/<your-namespace>/rov-payload
    scripts/build.sh <version> --push
    

    The 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.

  2. 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=1 is 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 login freshly first:

    NEW_REPO=1 scripts/build.sh <version> --push
    

    Once 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.

  3. 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
    
  4. 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's permissions label 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).

  5. Verify: EXT_UP is ticking and your sensor values are updating in Cockpit -> Tools -> DataLake. The docker commands in this step and the next run on the vehicle, not the build machine — open a shell via BlueOS → Terminal (red-pill for the host shell) and find the container name with docker 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>
    
  6. 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.

MAVLink naming rules

  • NAMED_VALUE_FLOAT names 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_UP is reserved — it's the bridge's own 1 Hz liveness signal; no driver may use it.

Development

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).

Known limitations

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.

About

stinger_payload_extension

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages