From cec1c5ae4527d084a631308c1bcf558082267f2c Mon Sep 17 00:00:00 2001 From: Bryan Clark Date: Thu, 11 Jun 2026 18:13:17 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Post:=20MQTT=20wait=5Ffor=5Ftrigger=20value?= =?UTF-8?q?=5Ftemplate=20doesn't=20filter=20=E2=80=94=20three=20layers=20o?= =?UTF-8?q?f=20HA=20template=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...plate-not-filtering-fires-every-message.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 _posts/2026-06-11-home-assistant-mqtt-wait-for-trigger-value-template-not-filtering-fires-every-message.md diff --git a/_posts/2026-06-11-home-assistant-mqtt-wait-for-trigger-value-template-not-filtering-fires-every-message.md b/_posts/2026-06-11-home-assistant-mqtt-wait-for-trigger-value-template-not-filtering-fires-every-message.md new file mode 100644 index 0000000..662d9ed --- /dev/null +++ b/_posts/2026-06-11-home-assistant-mqtt-wait-for-trigger-value-template-not-filtering-fires-every-message.md @@ -0,0 +1,177 @@ +--- +layout: post +title: "Home Assistant MQTT trigger value_template doesn't filter: why our wait_for_trigger fired on every message, and why script variables never reach trigger templates" +description: "An MQTT wait_for_trigger that 'matched' a JSON trace_id passed every end-to-end test for two days — and had never filtered anything. Three stacked gotchas: value_template transforms instead of filters without payload:, templates render native types so a boolean never matches a string payload, and script variables aren't in scope in the per-message value_template render. The working fix is a repeat/until loop." +date: 2026-06-11 +tags: + - homeassistant + - mqtt + - automation + - voice-assistant + - templates + - selfhosted +--- + +A Home Assistant script that does a synchronous MQTT round-trip — publish a question, `wait_for_trigger` on the reply topic for the message whose JSON payload carries the matching `trace_id` — passed every end-to-end test for two days. Then a second producer started publishing on the reply topic, and the wait matched the wrong message instantly. The "filter" had never filtered anything, and fixing it properly took peeling three separate layers of HA template semantics. This is the broke → tried → fixed. + +## Problem + +The setup: a voice query is published to an ask topic with a generated `trace_id`; an agent daemon answers on a shared say topic; the script waits for the reply that carries the same `trace_id` back, then speaks it. + +```yaml +# script: ask the agent, wait for the matching reply +- variables: + trace_id: "{{ now().timestamp() | round(3) }}-{{ range(1000, 9999) | random }}" +- action: mqtt.publish + data: + topic: naturali/intents/ask + payload: '{"text": "{{ text }}", "trace_id": "{{ trace_id }}"}' +- wait_for_trigger: + - trigger: mqtt + topic: naturali/agents/+/say + value_template: "{{ value_json is defined and value_json.trace_id | default('') == trace_id }}" + timeout: "00:01:15" + continue_on_timeout: true +# ...then speak wait.trigger.payload_json.text +``` + +This worked in every trial. Then the daemon gained interim acknowledgments — it now publishes "Let me check the pilot book." on the same say topic *before* the final answer, so the user isn't sitting in silence during a long tool call. Interim messages carry no `trace_id` at all. + +The moment that shipped, the voice satellite started speaking the interim acknowledgment *as the answer*, and the real answer never arrived. The script trace shows the wait happily matching a payload that doesn't even contain a `trace_id`: + +```yaml +wait: + completed: true + trigger: + payload: '{"text": "Let me check the pilot book.", "interim": true}' +``` + +`value_json.trace_id | default('') == trace_id` should be false for that payload. The wait completed anyway. + +## Diagnosis + +An MQTT trigger's `value_template` is not a filter. The [trigger docs](https://www.home-assistant.io/docs/automation/trigger/#mqtt-trigger) say exactly what it is, if you read it the right way: + +> The `payload` option can be combined with a `value_template` to process the message received on the given MQTT topic **before matching it with the payload**. + +`value_template` is a payload *transformer*: it rewrites the incoming message, and the rewritten result is then compared against the `payload:` key. **With no `payload:` key, nothing is ever compared, and the trigger fires on every message on the topic.** Our template rendered `True` or `False` per message and the result was thrown away — the trigger fired regardless. + +So why did it pass two days of end-to-end trials? Because under the old traffic mix, the reply was the *only* message ever published on the say topic. "Fire on everything" and "fire on the matching reply" are observationally identical when there's exactly one message. The filter was unverified the whole time; the interim messages just made it visible. + +This is the opposite of a template `condition`, where `value_template` rendering truthy *is* the gate. Same key name, different semantics per context — that asymmetry is the root trap here. + +## What we tried (and why it failed) + +### Attempt 1 — add `payload: "True"` so the boolean has to match + +If the rendered template is compared against `payload:`, then requiring the render to equal `"True"` should turn the existing boolean template into a filter: + +```yaml +- wait_for_trigger: + - trigger: mqtt + topic: naturali/agents/+/say + payload: "True" + value_template: "{{ value_json is defined and value_json.trace_id | default('') == trace_id }}" + timeout: "00:01:15" + continue_on_timeout: true +``` + +Result: the wait matched *nothing*. Every ask now timed out: + +```yaml +wait: + completed: false + trigger: null +``` + +Why: Home Assistant templates render **native types**. That template returns the boolean `True`, not the string `"True"`, and a boolean never equals the string payload it's compared against. The trigger went from matching everything to matching nothing — which at least proved the comparison was now happening. + +### Attempt 2 — render an explicit string instead of a boolean + +Fine — sidestep the type problem by rendering a sentinel string and matching on that: + +```yaml +- wait_for_trigger: + - trigger: mqtt + topic: naturali/agents/+/say + payload: "reply-match" + value_template: >- + {{ 'reply-match' if (value_json is defined + and value_json.trace_id | default('') == trace_id) + else 'reply-skip' }} + timeout: "00:01:15" + continue_on_timeout: true +``` + +Still matched nothing. The template silently rendered `reply-skip` for every message — including the real answer with the correct `trace_id`. + +Why: **script variables are not in scope inside an MQTT trigger's `value_template`.** `trace_id` was undefined in that render, so the comparison could never be true. This one is genuinely confusing, because the [script docs](https://www.home-assistant.io/docs/scripts/#wait-for-a-trigger) say: + +> All previously defined trigger variables, variables and script variables are passed to the trigger. + +That's true — *at trigger setup*. In [the MQTT trigger source](https://github.com/home-assistant/core/blob/dev/homeassistant/components/mqtt/trigger.py), the `topic:` and `payload:` templates are rendered once, when the trigger is attached, with your variables available: + +```python +variables = trigger_info.get("variables") +wanted_payload = command_template(None, variables) +topic = topic_template.async_render(variables, limited=True, parse_result=False) +``` + +But the per-message `value_template` render gets only the payload — no variables parameter at all: + +```python +payload := value_template(mqttmsg.payload, PayloadSentinel.DEFAULT) +``` + +So `value_json` and `value` exist in a `value_template`; your script's `trace_id` does not, and there is no error — `| default('')` swallowed the undefined variable and the else-branch rendered every time. A correct trigger-level match on a runtime variable is simply not expressible here. (Related long-standing report for the trigger's `for:` template: [home-assistant/core#63886](https://github.com/home-assistant/core/issues/63886).) + +## The fix + +Stop filtering in the trigger entirely. Fire on every message, and loop until the matching one — because a template **condition** in the script body *does* see script variables: + +```yaml +- repeat: + sequence: + - wait_for_trigger: + - trigger: mqtt + topic: naturali/agents/+/say + timeout: "00:01:15" + continue_on_timeout: true + until: + - condition: template + value_template: >- + {{ wait.trigger is none + or ((wait.trigger.payload_json | default({})).trace_id + | default('')) == trace_id }} +``` + +How it works: + +- The bare MQTT trigger matches every say. Each iteration consumes one message. +- The `until` condition exits the loop when the payload's `trace_id` matches — and `trace_id` *is* in scope here, because conditions render with the script's run variables. +- `wait.trigger is none` is the timeout escape: on timeout with `continue_on_timeout: true`, `wait.trigger` is `null`, the condition is true, and the loop exits instead of spinning forever. +- The `wait` variable persists after the `repeat`, so downstream steps are unchanged: a timeout branch still checks `wait.trigger is none`, and the answer still comes out of `wait.trigger.payload_json.text`. + +Trace-verified live: the loop consumed two interim messages (`reply-skip` cases, in attempt-2 terms) and exited on the reply carrying the matching `trace_id`, which the satellite then spoke. + +One behavior change to be aware of: the timeout is now *per message*, not per wait. A chatty topic can keep the loop alive past the old 75-second ceiling. For an interactive voice round-trip that's acceptable; if you need a hard overall deadline, track `now()` in a variable before the loop and add an elapsed-time clause to the `until`. + +## Why it matters / gotchas + +**A filter you've never seen reject anything is unverified — even if every end-to-end test passes.** Our trials all published a question and got an answer back; all criteria green, two days running. None of them published a message that *must not* match and watched it get ignored. Selectivity needs a negative case, and a topic with one producer can't generate one by accident. + +**New traffic on a shared topic is a breaking change to subscribers.** Every consumer of that topic was implicitly tuned to the old traffic mix — one producer shape for a month. Adding a second message shape broke two consumers in one afternoon: this wait, and the broadcast automation that announces says on the voice satellite. (That second one is its own HA gotcha: calling `assist_satellite.announce` on a satellite during its active voice session kills the session. Interim messages now carry `interim: true` in the payload and the broadcast automation skips them — the payload contract lives in the agent daemon, linked below.) + +**HA template semantics differ by context, and the differences are silent.** Three separate live failures, one per assumption: + +| Context | `value_template` means | Sees script variables? | +|---|---|---| +| MQTT trigger | transform payload, then compare to `payload:` (no `payload:` → no filtering) | No — per-message render gets payload only | +| Condition | render truthy → pass | Yes | +| Everywhere | renders **native types** — boolean `True` ≠ string `"True"` | — | + +None of these failure modes log anything. The trigger that matches everything, the payload that matches nothing, and the variable that silently renders as its default all look identical in the logs — the script trace (`wait.completed`, `wait.trigger`) is where each one actually showed its shape. + +## Close + +This round-trip is the spine of a voice assistant for an all-electric charter catamaran — a Home Assistant satellite asking an MCP-backed agent about pilot books and tide windows, with MQTT as the seam between them. The agent daemon side, including the say-topic payload contract with the `interim` flag, is open source: [github.com/sailingnaturali](https://github.com/sailingnaturali). From a2af6fbc0125c21035acca80f7bacee64b4e0448 Mon Sep 17 00:00:00 2001 From: Bryan Clark Date: Thu, 11 Jun 2026 18:13:32 -0700 Subject: [PATCH 2/2] Add post: logging VHF DSC distress calls in SignalK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-dive on signalk-dsc — the $--DSC/$--DSE and PGN 129808 gaps stock SignalK parsers drop, how the plugin captures/stores/alarms on received DSC traffic, and the 47 CFR 80.409 radio-log standard it mirrors. Co-Authored-By: Claude Opus 4.8 --- ...ss-call-logging-nmea0183-dse-pgn-129808.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 _posts/2026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808.md diff --git a/_posts/2026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808.md b/_posts/2026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808.md new file mode 100644 index 0000000..ec2285c --- /dev/null +++ b/_posts/2026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808.md @@ -0,0 +1,227 @@ +--- +layout: post +title: "Logging VHF DSC distress calls in SignalK: the $--DSC/$--DSE and PGN 129808 gaps stock parsers drop" +description: "When a nearby boat hits the red button, its radio broadcasts a DSC distress burst on channel 70 — MMSI, position, nature of distress — readable even when the voice call on 16 is not. Stock SignalK quietly drops most of it: the NMEA 0183 hook misses sparse distress sentences and persists nothing, ignores $--DSE position refinement, and n2k-signalk has no PGN 129808 mapping at all. Here's how signalk-dsc captures, stores, and alarms on every received call — and the century-old radio-log regulation (47 CFR 80.409) that makes logging intercepted distress traffic the right default." +tags: + - signalk + - marine + - nmea0183 + - nmea2000 + - dsc + - vhf + - pgn-129808 + - gmdss +date: 2026-06-11 +--- + +A DSC distress alert is the most important packet a marine VHF will ever hand you, and it's structured data. When a vessel hits the red button, its radio transmits a digital selective calling burst on **channel 70**: format specifier, the sender's MMSI, category, nature of distress, position, and UTC time — encoded per ITU-R M.493. A DSC-equipped radio that hears it re-emits it to your network, as `$--DSC`/`$--DSE` sentences on **NMEA 0183** or as **PGN 129808** on **NMEA 2000**. + +That burst arrives — and stays readable — even when the follow-up voice MAYDAY on channel 16 is garbled, stepped on, or out of range. If you might be the nearest boat, you want every received alert stored with its position and surfaced as an alarm. + +Stock SignalK quietly drops most of it. This is the broke → tried → fixed of getting **DSC distress calls into SignalK**, with receipts for every gap. The result is [`signalk-dsc`](https://github.com/sailingnaturali/signalk-dsc). + +## The test sentence — try it without a radio + +You don't need a transmitting vessel in distress to reproduce all of this. Feed two sentences through any NMEA 0183 input (TCP, UDP, file playback): + +``` +$CDDSC,12,3380400790,12,05,00,1423108312,2019,,,S,E*69 +$CDDSE,1,1,A,3380400790,00,45894494*1B +``` + +That's a **format 12** (distress alert), from MMSI `338040079` (the sentence carries it `* 10`, with a trailing zero), **category 12** (distress), **nature 05** (sinking), a position field, UTC `2019`, and the `E` expansion flag saying *a `$--DSE` sentence follows*. The second sentence is that expansion, refining the position. Both together are one incident. + +Now walk through what stock SignalK does with each, and where it falls short. + +## Gap 1: the 0183 hook misses sparse distress alerts — and persists nothing + +The DSC field layout (sentence id and checksum already stripped) is: + +``` + 0 1 2 3 4 5 6 7 8 9 10 + | | | | | | | | | | | + $--DSC,XX,XXXXXXXXXX,XX,XX,XX,XXXXXXXXXX,XXXX,,,A,C*hh + + 0: format specifier (12 = distress alert) + 2: category (00 routine / 08 safety / 10 urgency / 12 distress) + 3: nature of distress + 5: position (quadrant + ddmm + dddmm) + 6: UTC time hhmm + 10: expansion flag — 'E' means a $--DSE sentence follows +``` + +The upstream issue here is [SignalK/nmea0183-signalk#217](https://github.com/SignalK/nmea0183-signalk/issues/217): some radios emit a distress alert with the **category field empty** — it's redundant, because format `112` *is* a distress alert by definition. The stock parser treats the empty category as "not a distress call" and the alert evaporates. + +`signalk-dsc` registers its own `DSC` parser (a superset of the stock hook) that infers the category from the format when it's missing: + +```js +let categoryCode = field(parts, 2); +// Some radios omit the category on distress alerts — it is implied by +// format 112 (see SignalK/nmea0183-signalk#217). +if (!categoryCode && formatCode === '12') categoryCode = '12'; +``` + +The whole parser is tolerant by design: anything it can't interpret stays `undefined`, and the **raw sentence is always kept** alongside the parsed fields. That's the second half of the gap — even when the stock hook *does* fire, it raises a transient notification and stores nothing. There is no record afterward. A distress alert with no persistence is a distress alert you can't act on the moment you look away from the screen. + +`signalk-dsc` appends every received call to an on-disk JSONL log and serves the history at `/signalk/v2/api/resources/dsc-calls` (anonymously readable under `allow_readonly`): + +```js +app.registerResourceProvider({ + type: 'dsc-calls', + methods: { + async listResources() { /* every stored call, by id */ }, + async getResource(id) { /* one call */ }, + setResource() { throw new Error('dsc-calls is read-only'); }, + deleteResource() { throw new Error('dsc-calls is read-only'); }, + }, +}); +``` + +The store is deliberately boring — append-only JSONL, synchronous I/O, full-file compaction past a cap. DSC traffic is rare (a busy day within range of a coast station might see dozens of calls), so the simplest thing that survives a power cut mid-write wins: a torn last line is skipped on load, the rest is kept. + +## Gap 2: $--DSE position refinement, which the stock parser ignores entirely + +The position in the DSC sentence is truncated to whole minutes — about **±1 NM**. That's coarse for a search. The optional `$--DSE` expansion sentence carries the missing precision, down to ten-thousandths of a minute: + +``` + 0 1 2 3 4 5 + |_|_|_|__________|__|________ + $--DSE,t,n,A,XXXXXXXXXX,00,llllyyyy*hh + + 3: address — MMSI * 10, same convention as DSC + 4+: (code, data) pairs; code 00 = enhanced position, + data = 4 digits lat + 4 digits lon, in 1/10000 of a minute +``` + +The stock parser doesn't handle DSE at all. `signalk-dsc` pairs an incoming DSE with the matching DSC call it stored in the last two minutes (same MMSI, position still at minute resolution) and refines it. DSC truncates *toward zero*, so the fractional minutes always extend the magnitude — sign-preserving: + +```js +function refinePosition(position, ext) { + const extend = (value, minuteFraction) => { + const sign = value < 0 ? -1 : 1; + return sign * (Math.abs(value) + minuteFraction / 60); + }; + return { + latitude: extend(position.latitude, ext.latMinuteFraction), + longitude: extend(position.longitude, ext.lonMinuteFraction), + }; +} +``` + +The stored call's `positionResolution` flips from `minute` to `enhanced`, and the refined position goes out as a delta. ±1 NM becomes a tight fix. + +## Gap 3: n2k-signalk has no PGN 129808 mapping at all + +On NMEA 2000, a DSC call is **PGN 129808**, "DSC Call Information." `n2k-signalk` — the converter that turns N2K into SignalK deltas — produces **no delta for it**. There's nothing to subscribe to. + +So `signalk-dsc` goes one layer down and listens to the server's analyzer stream directly: + +```js +const DSC_PGN = 129808; + +function onPgn(pgnData) { + if (!started || !pgnData || pgnData.pgn !== DSC_PGN) return; + record(normalizePgn129808(pgnData), { source: 'n2k', raw: pgnData.fields }); +} + +app.on('N2KAnalyzerOut', onPgn); +``` + +Normalizing 129808 is fiddly because canboatjs may resolve the enumerations to their names *or* pass through the raw ITU symbol numbers when a lookup misses, and the distress vs. general variants use different field names. The mapping handles both — name and number — for format, category, and nature: + +```js +const NATURES = new Map([ + ['sinking', 'sinking'], + ['man overboard', 'mob'], + ['flooding', 'flooding'], + // ...resolved names above; raw ITU symbols below + [5, 'sinking'], + [10, 'mob'], + [1, 'flooding'], +]); +``` + +Both transports — 0183 and N2K — land in the same canonical event shape, so everything downstream (storage, alarms, logbook) is transport-agnostic. + +## Alarms under your own vessel — and surviving a restart + +Every received distress/urgency/safety call raises a notification under *self* so the vessel's own alarm chain fires. The category maps straight onto SignalK's severity ladder: + +```js +const NOTIFICATION_STATES = { + distress: 'emergency', + urgency: 'alarm', + safety: 'alert', +}; +// → notifications.dsc.distress / .urgency / .safety +``` + +Routine calls never alarm. DSC distress alerts **auto-repeat until acknowledged**, so a naive implementation would re-alarm every few minutes; instead, a repeat inside a 5-minute window bumps a `repeats` counter on the stored call rather than firing again. + +The subtle one: SignalK notifications live **in memory**. If the server restarts mid-incident, an active distress alarm silently vanishes — and a received MAYDAY must not disappear because the server bounced. So on start, `signalk-dsc` re-raises the newest alert per category that's still fresh (within the last hour): + +```js +reannounceTimer = setTimeout(() => { + const now = Date.now(); + const reannounced = new Set(); + const events = store.list(); + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (!NOTIFICATION_STATES[event.category] || reannounced.has(event.category)) continue; + const at = Date.parse(event.lastReceivedAt || event.receivedAt); + if (now - at <= REANNOUNCE_WINDOW_MS) { + notify(event); + reannounced.add(event.category); + } + } +}, 30000); +``` + +The 30-second delay is deliberate: it lets position providers come up first, so the spoken alert can say "2.3 nautical miles northwest" instead of reading out raw coordinates. + +## Voice-sized message vs. full log detail + +The notification message gets **spoken** by a voice pipeline, so it's deliberately minimal — type, vessel, situation, range and direction from own position, action, and nothing else: + +``` +DSC distress alert: vessel Wind Chaser, sinking, 2.3 nautical miles +northwest. Monitor channel 16. +``` + +Note what's *not* spoken: the MMSI. Text-to-speech reads `366123456` as "three hundred sixty-six million…", which is useless and slow on a safety alert. (The plugin never speaks an MMSI — a small but real design rule.) The full detail lands in the call log and a GMDSS-style logbook entry instead: + +``` +[DSC] DISTRESS alert from Wind Chaser (MMSI 338040079): sinking. +position 48°47.700′N 123°12.300′W at 20:19 UTC, 2.3 NM northwest of us. via nmea0183 +``` + +The vessel name, when present, comes from AIS static data already in the data model — not from the DSC sentence, which only carries the MMSI. + +## Why a *log*, specifically + +Capturing the alert and alarming on it is the operational case. The reason to also **persist it as a log entry** is older than SignalK by about a century. + +Recording distress traffic is the long-standing radio-station regulatory standard. The cleanest US statement is **[47 CFR § 80.409](https://www.law.cornell.edu/cfr/text/47/80.409)** (FCC station logs). The radiotelegraph-log subsection, **(d)(4)**: + +> All distress calls, automatic-alarm signals, urgency and safety signals **made or intercepted**, the complete text, if possible, of distress messages and distress communications, and any incidents or occurrences which may appear to be of importance to safety of life or property at sea, must be entered, together with the time of such observation or occurrence and the position of the ship or other mobile unit in need of assistance. + +The load-bearing phrase is **"made or intercepted."** The rule isn't only about the distress *you* declare — it explicitly covers the traffic you *receive*. That's exactly what a DSC receiver does: it intercepts. The required fields — text of the distress, the time, and the position of the station in need of assistance — are precisely what a DSC burst (plus its DSE refinement) carries, which is why the plugin stores all three. The radiotelephone-log equivalent is subsection **(e)**. + +**The honest caveat, because a knowledgeable reader will poke at an overstated claim:** applicability is subsection **(f)**, and the log *mandate* binds **compulsorily-equipped / GMDSS / SOLAS-class / Great Lakes / Bridge-to-Bridge** stations — **not** voluntary recreational stations. A private sailor is generally *not* legally required to keep this log. Internationally the same duty flows from **SOLAS Chapter IV** and the **ITU Radio Regulations**; Canada's equivalent is **TP 1539** (Ship Station Radio Technical Regulations), which is the relevant one for a boat working BC waters. + +So the accurate framing is narrow and worth getting right: the law doesn't force a recreational station to keep a distress log — but the standard it sets for stations that *are* required to is a good one, and the data to meet it is already arriving on your network. `signalk-dsc` gives you that same SOLAS-grade record automatically. (For the practical side of receiving DSC distress alerts, the USCG NavCen [DSC Distress](https://www.navcen.uscg.gov/dsc-distress) page is the reference.) + +## The shape of it + +``` +$--DSC ─┐ +$--DSE ─┼─→ canonical DSC event ─┬─→ JSONL log (/resources/dsc-calls) +PGN 129808 ─┘ ├─→ notifications.dsc. (alarm chain) + ├─→ voice-sized spoken message + ├─→ remote-vessel position delta (chartplotter) + └─→ GMDSS-style logbook entry (signalk-logbook) +``` + +Two wire formats, three things stock SignalK drops, one canonical event, five sinks. A received distress alert no longer vanishes the moment you look away. + +This is one piece of an all-electric charter-catamaran ops stack — the radio should keep the log the regulations describe whether or not anyone's watching the screen. Code's here: [`signalk-dsc`](https://github.com/sailingnaturali/signalk-dsc).