Close

One button transmits. A test proves it's the only one.

A project log for Scottina

A pocket-sized front panel for the diagnostic tools you already use

scottskyScottsky • 09/03/2026 at 20:32•0 Comments

Log 6 closed with a promise: the one control in the whole UI that transmits anything at all — the NMEA2K screen's GNSS source node, and how a heartbeat exception gets built so it can never quietly become a general-purpose one. This is that log.

Why anything on this screen would transmit at all

Everything else in Scottina listens. The CAN screen sniffs raw arbitration IDs, the NMEA2K screen decodes known PGNs against installed tables, and both enforce RX-only in code, not by promise. But an NMEA2000 bus doesn't let a silent node sit on it indefinitely — a device that never answers an ISO address claim or an ISO request looks broken to everything else on the bus, and there's a real bench case for wanting Scottina to be a GPS source: proving a freshly converted PGN table against real bus traffic without dragging an actual chartplotter to the workbench. n2k/node.py exists for exactly that case, and nowhere else. It claims a source address, defends it, answers ISO requests, and broadcasts five GNSS PGNs — 126992, 129025, 129026, 129029, 126993 — sourced from the same gpsd snapshot the GPS tile already reads. Nothing about the rest of the CAN stack changes; one module gets permission to speak.

A state machine that can say "no"

GnssSourceNode isn't a flag, it's five states: OFF, CLAIMING, ACTIVE, CANNOT_CLAIM, STOPPED_FIX. The NMEA2K screen's one TX affordance — a single button, deliberately living on the bus screen and not the GPS tile, because sourcing PGNs is a bus action — reads its label straight off that state: Source GNSS → bus when off, GNSS: claiming… mid-handshake, GNSS ■ stop once active, GNSS: no address if the claim loses out to another device already holding that source address, GNSS: fix lost ■ if the bus keeps the node parked after the fix that justified it disappears. Every one of those is a real, distinct outcome shown in plain words — there's no state where the button just goes quiet and leaves the bench guessing whether Scottina is talking or not.

The node doesn't get to guess it has a fix

_gnss_eligible() gates the button on two facts, not one: /dev/gps0 has to exist, and gps.snapshot.read_position() has to return a current fix — the same staleness rule GPS.md already applies everywhere else a tile reads position. No fix, no button; the toast says so plainly, "No GPS fix — a node never sources stale data." That check doesn't stop mattering once the node is running: losing the fix mid-session drops the state to STOPPED_FIX rather than letting the node keep broadcasting a last-known position as if it were current. A GNSS source that quietly goes stale is worse than one that visibly stops.

Telling your own echo from the boat's GPS

Once the node is active, its own broadcasts land right back in the NMEA2K screen's decode view like any other bus traffic — which is useful, since watching your own PGNs arrive is how you confirm the TX actually worked. But that echo has to be told apart from a real GPS already on the bus, so every row sourced from the node's own claimed address gets tagged with a ▸ marker, and the screen's GPS-vs-bus comparison explicitly excludes self-traffic. Verifying your own transmission and trusting a second, independent GNSS source are two different questions, and the UI keeps them from blurring into one.

The part that keeps this from creeping

None of the above matters if the exception leaks. So it's checked the same way the LAN Scan screen's blocked nmap flags are checked: in code, at build time, not by anyone remembering a rule. tests/test_txscan.py AST-scans the entire tree against a positive allow-list of exactly one TX-permitted module — n2k/node.py. Any send-shaped call on a socket turning up anywhere else fails the build outright. That's one half of the gate; tests/test_busmon.py and tests/test_n2k.py are the independent reject pass, proving the CAN and NMEA2K screens and their bus models stay RX-only on their own terms, without relying on the allow-list to catch what they might do wrong. A heartbeat exception granted to one named module, checked from both directions, is not the same thing as a diagnostics tool that happens to transmit today and might transmit more tomorrow.

Next log: the other half of that same discipline, working in the opposite direction — the LAN Scan screen, where six specific nmap flags are the ones that must never be reachable, and how a reject-list earns the same build-time trust as the GNSS allow-list did here.

Discussions