Part 14 — the last — of Beyond Voice, a 14-part deep dive into everything GopherTrunk decodes that is not a trunked voice call — the AFSK signalling formats, paging, APRS, ADS-B, AIS, DSC, LoRa, M17 and the amateur digital-voice family — and the one eleven-place wiring pattern that carries each of them from a burst on the air to a row in the web console. Part 13 finished the tour of physical layers; this closing part turns the pattern into a checklist, names the tests that fail when a place is missed, sorts message decoders from trunked protocols, and lists what remains open.
TL;DR: A GopherTrunk decoder is done when eleven places agree; FleetSync is the worked example: protocol core +
afskfront end (internal/radio/fleetsync),events.KindFleetSyncMessage,storage.FleetSyncLog+ thefleetsync_logtable +decoderLogTables,GET /api/v1/fleetsync/messages, the/fleetsyncpanel + nav entry,FleetSyncConfig+ thefleetsynctab key, FieldMeta help, the Config Buildertypes.ts+ section, the daemon wiring, thedoctor“needs storage.path” list, and theconfig.example.yamlblock +docs/fleetsync.md. Three tests police it —TestFieldHelpCoverage,TestConfigSchemaCoveredByWebBuilder, and the webregistry.test.ts/App.panels.test.tsxroute lists — plus theweb-typecheckCI job, becausevitestnever typechecks. Trunked protocols take a different route (Protocolenum, pipeline factory, composer voice kind, recorder vocoder map). M17 shows a decoder that stopped short: REST but no panel.
Key takeaways
- The pattern is the product. Twelve physical layers reduce to one shape because everything above the framer — bus, storage, REST, panel, config, help, doctor, example, docs — is identical machinery.
- Tests police places, not behaviour.
TestFieldHelpCoverageandTestConfigSchemaCoveredByWebBuilderwalkconfig.Configby reflection; the web route tests walkApp.tsx. - Green
npm testis not a built console.vitestnever typechecks; onlytsc --noEmitcatches an unused parameter, soweb-typecheckruns it over all five SPAs. - Wired is not verified. All eleven places can be present and the decoder still synthetic-green; the open list below is the ledger.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Field help guard | every exported config field needs FieldMeta.Help |
internal/configbuilder/fieldmeta_test.go |
| Web schema guard | every config field appears in the builder’s types.ts |
internal/configbuilder/webschema_test.go |
| TUI twin | every leaf gets a handled widget kind | internal/configtui/meta_drift_test.go (TestEveryLeafEditable) |
| Route guards | every <Route> has a nav entry and mounts clean |
web/src/nav/registry.test.ts, web/src/App.panels.test.tsx |
| Typecheck job | tsc --noEmit over all five SPAs |
.github/workflows/ci.yml (web-typecheck) |
| Doctor preflight | names decoders configured without storage.path |
cmd/gophertrunk/preflight.go |
| Retention membership | decoder log tables swept by retention.log_days |
internal/storage/retention.go (decoderLogTables) |
In this post
- The eleven places — one table, FleetSync’s file at each.
- The three tests that police it — reflection walks and route lists.
- The CI job vitest cannot replace —
web-typecheckand TS6133. - Message decoder or trunked protocol? — the decision table, and M17.
- What’s still open — the honest ledger.
- Where to go from here — the series index and its siblings.
The eleven places
Part 1 introduced the pattern through FleetSync’s landing (Part 4); here it is as the checklist, with the real file at every step and the test that fails when the row is skipped.
| # | Place | FleetSync’s file | Policed by |
|---|---|---|---|
| 1 | Protocol core + front end | internal/radio/fleetsync/framer.go, fleetsync/afsk/receiver.go |
TestReceiverPublishesRealAirBurstsOnBus |
| 2 | Bus kind | internal/events/bus.go (KindFleetSyncMessage = "fleetsync.message") |
docs/api-events.md passthrough list |
| 3 | Storage log + table + retention | internal/storage/fleetsynclog.go, sqlite.go (fleetsync_log), retention.go |
TestFleetSyncLogSweptByRetention |
| 4 | REST route + provider | internal/api/handlers_fleetsync.go, server.go (Options.FleetSync) |
TestFleetSyncMessagesReturns503WhenNotWired |
| 5 | Web client, panel, route, nav | web/src/api/fleetsync.ts, panels/FleetSync.tsx, App.tsx, nav/registry.ts |
registry.test.ts, App.panels.test.tsx, FleetSync.test.tsx |
| 6 | Config struct + tab key | internal/config/config_peripherals.go (FleetSyncConfig), config.go ("fleetsync": true) |
TestConfigSchemaCoveredByWebBuilder |
| 7 | Field help | internal/configbuilder/fieldmeta.go, sections.go |
TestFieldHelpCoverage, TestSectionMetaComplete |
| 8 | Config Builder | web/configbuilder/src/api/types.ts, sections/FleetSync.tsx, sections/index.tsx |
TestConfigSchemaCoveredByWebBuilder, web-typecheck |
| 9 | Daemon wiring | cmd/gophertrunk/daemon.go (receivers, NewFleetSyncLog, provider) |
TestDaemonWiresFleetSyncChannels, TestDaemonOpensFleetSyncLogWithStorage |
| 10 | Doctor preflight | cmd/gophertrunk/preflight.go (needs = append(needs, "fleetsync")) |
the WARN text below |
| 11 | Example config + docs | config.example.yaml (fleetsync: block), docs/fleetsync.md, CHANGELOG.md |
the 4 Sep rule; guide-advanced.md index |
Three rows are where earlier landings went wrong. Row 3 is one
generic: storage.FleetSyncLog embeds *eventLog[FleetSyncMessage], the
shared subscribe → drain → close lifecycle, so a new decoder supplies only
its insert and Recent SQL — and must add its table to
decoderLogTables, or retention.log_days never sweeps it
(Recording Part 10).
Row 4 returns a bare array; the History panel once read r.rows from
a {"calls": […]} envelope and rendered “No calls” for every query, so the
row 5 panel test renders a real burst. Row 10 exists because message decoders surface only
through SQLite: with storage.path empty the receiver runs, the route
returns 503 and the panel stays empty — a silent failure (#565) that
doctor now names:
WRN storage.path is empty but these decoders need it to surface decoded
messages: paging, fleetsync — they will run, but their REST endpoints
(e.g. /api/v1/pager/messages) return 503 and the web panels stay empty.
Set storage.path to enable persistence.
Row 11’s rule dates from 4 September, when diversity_capture_format
shipped in code but not in config.example.yaml.
The three tests that police it
The guards walk the structure, so they fail the day a place is skipped.
The first walks config.Config by reflection and demands help text for
every exported field of every struct in internal/config:
// internal/configbuilder/fieldmeta_test.go (shape)
func TestFieldHelpCoverage(t *testing.T) {
walk = func(rt reflect.Type) {
/* only structs whose PkgPath ends in internal/config */
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if !isRoot && FieldMetaFor(rt.Name(), f.Name).Help == "" {
missing = append(missing, rt.Name()+"."+f.Name)
}
walk(f.Type)
}
}
walk(reflect.TypeOf(config.Config{}))
/* t.Errorf listing every missing Struct.Field */
}
It is load-bearing for both editors: the terminal and web builders read
help from the same registry (the web store fetches /config/fieldmeta), so
a FleetSyncChannelConfig.DropBadCRC without a Help ships in neither. The
second test reads the web builder’s TypeScript schema off disk —
readWebTypes resolves web/configbuilder/src/api/types.ts from
runtime.Caller — and fails when a config field has no counterpart there
and is not on the round-trip allow-list, “a config change that the web
Config Builder would silently drop on save/load”. The TUI has its twin,
TestEveryLeafEditable. The third is on the web side: registry.test.ts
holds a ROUTED_PATHS list — “every top-level route mounted by App.tsx
must have a registry entry, otherwise it becomes unreachable by navigation
(the pre-overhaul bug, where six DSP panels were orphaned)” — and
App.panels.test.tsx mounts each listed route connected and asserts the
error boundary never trips, with vi.mock("./api/fleetsync") supplying the
client
(Operator Cockpit Part 14).
Adding /fleetsync to App.tsx without both lists fails one of them.
The CI job vitest cannot replace
This guard came from the console, not a decoder. npm test runs vitest,
which transpiles with esbuild and never typechecks; npm run build is
tsc --noEmit && vite build. Three TS6133 unused-parameter errors in
web/src/api/reconnectingSocket.test.ts landed on main with green SPA
jobs and broke make web-build for an operator. The only job that caught
them was “Windows installer (PR)” — not a required check — so the PR merged
red and read as passed.
ci.yml now carries web-typecheck, matrixed over web,
web/configbuilder, web/siglab, web/rfscope and web/cryptolab — the
last two built by no other workflow — running npm run typecheck in each. For a decoder landing: run cd web && npm run typecheck before
pushing a panel, remember tsconfig.json sets noUnusedLocals and
noUnusedParameters with test files inside include: ["src"] (an unused
mock parameter needs a leading underscore), and check the non-required
jobs on the PR too.
Message decoder or trunked protocol?
Not every decoder in this series took the eleven-place route. The AFSK, paging, APRS, ADS-B, AIS, DSC, LoRa and M17 decoders are message decoders with their own config section, bus kind, table and panel. The Part 13 trio are trunked protocols: they join the engine, whose Active, History and recorder are their UI. Decide before touching a file:
| Question | Message decoder (FleetSync shape) | Trunked protocol (dPMR shape) |
|---|---|---|
| Identity | own config section (fleetsync.channels) |
Protocol enum in internal/trunking/site.go |
| Channel rate | receiver picks (9600 Hz for 1200-baud FFSK) | ddcTargetForProtocol |
| Decode entry | daemon builds receivers from config | factory in the ccdecoder pipelines map |
| Events | a new events.Kind + payload struct |
KindCCLocked / KindGrant with trunking.Grant |
| Storage | own *_log table in decoderLogTables |
call_log via the engine |
| UI | own panel, route, nav entry, tab key | Active / History / Scanner, unchanged |
| Voice | none | composer classifyVoiceKind + recorder vocoder map |
| Doctor | in the “needs storage.path” list | not needed |
The routes are exclusive by design: the D-STAR landing touched site.go,
pipelines.go, composer.go and recorder.go but no App.tsx.
M17 is the reminder that the message route must be walked to the end: it
has rows 1–4 and 6–11 — KindM17LinkSetup, m17_log,
GET /api/v1/m17/linksetups, M17Config, FieldMeta, M17.tsx, the example
block, the doctor entry, docs/m17.md — and no row 5: no /m17 in
registry.ts, no panel in App.tsx, no m17 tab key. Nothing fails,
because the route tests police routes that exist. That is the one gap the
checklist cannot automate, and why the list is written down.
How the checklist shaped the Go code
- One generic per layer.
eventLog[T]for storage,FieldMetaForfor help,useDataPollfor panels: a new decoder adds data, not machinery. - Registries are walked, not listed. The guards reflect over
config.Configand scanApp.tsx’s routes — the structure is the checklist. - Optional providers, nil-safe. A nil
api.Options.FleetSyncdisables the route with a 503 that namesstorage.path. - The doctor speaks the operator’s language. The preflight WARN names the decoder, the endpoint and the fix, because the failure it prevents was silent.
What’s still open
Each open item sits at a named gate:
- FleetSync on a live Kenwood fleet — offline-verified on the #1184 captures (5/6 and 8/8 CRC-valid, Fleet 107 / Unit 1772); the reporter’s on-air lab test is still open, and #437 with it.
- M17’s row 5 and its voice — no
/m17panel or tab key; no Codec 2 path; Golay matrix and slicer constants synthetic-verified only (Part 12). - YSF’s FICH caller —
DecodeFICHOnAirandProcessFICHare tested but unjoined on the hot path, so a Fusion repeater locks and never grants (Part 13). - dPMR’s CSBK channel coding — the cyclic + rate-¾ convolutional + interleave stage is absent; CSBKs are parsed as already-clean bits.
- D-STAR and dPMR voice tables — placeholder deinterleaves, synthetic round-trip only; the D-STAR header scrambler and interleaver await MMDVMHost table confirmation. Each needs a voice capture.
- Rows 10 and 11 have no test. A reflection walk diffing
config.Configagainstconfig.example.yamlwould close that.
None is blocked on effort; each is blocked on a capture, a reporter, or a wiring step whose verification needs one — the closing line of From Spec to Shipping Part 14, because it is the project’s operating principle: green synthetic is not on-air correct.
Where to go from here
The series index
lists all fourteen parts. For the machinery every row rests on,
SDR Internals is the
foundation, starting with
Part 11
where events.Kind begins. For the method behind every “unverified on air”
above — From Spec to Shipping.
Two series launch alongside this one:
DMR End to End
follows the trunked-protocol route to decrypted voice, and
The Field Notebook
reads the daemon’s log one line family at a time.
FAQ
What files does a new GopherTrunk message decoder touch?
Eleven places: the protocol package and front end, an events.Kind, a
storage log with its SQLite table and retention membership, a REST handler,
the web client, panel, route and nav entry, the config struct
and tab key, FieldMeta help, the Config Builder types.ts and section, the
daemon wiring, the doctor preflight list, and config.example.yaml plus docs.
Which tests fail if I forget a config field’s help text?
TestFieldHelpCoverage in internal/configbuilder walks every struct
reachable from config.Config by reflection and lists each exported field
without a non-empty Help in the FieldMeta registry. Both the terminal and
web Config Builders read that registry, so the field ships in neither
editor until help is written.
Why does npm test pass on code that fails npm run build?
vitest transpiles with esbuild and never typechecks; npm run build runs
tsc --noEmit first. With noUnusedLocals and noUnusedParameters set,
an unused parameter is a TS6133 error the tests never see. Run
npm run typecheck before pushing; the web-typecheck job also runs it.
Why does my decoder’s REST endpoint return 503?
The daemon started without storage.path. Message decoders surface only
through SQLite, so with storage off the receiver runs but nothing persists,
and the handler answers 503 naming the fix. gophertrunk doctor
warns at preflight for every message decoder that needs storage.
Should a new protocol be a message decoder or a trunked protocol?
If it grants voice onto a channel the engine follows, it is a trunked
protocol: add a Protocol enum, a pipeline factory, a composer voice kind
and a vocoder mapping. If it produces messages to log and list —
ANI bursts, pages, positions — it takes the eleven-place route.
Series navigation
Part 14 of 14 · ← Part 13: D-STAR, System Fusion & dPMR · Back to the series index