Files
konradandCursor f8cad55e9c Switch the lab reflector to jayReflector and harden local station use.
Build DJ1JAY’s fork with an optional Satellite/Trunk stub, allow -c/--config, open capture and playback as separate devices, and add a lab start helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:14:41 +02:00

219 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Code review: funknetz
**Date:** 2026-10-07
**Scope:** Full review of the funknetz checkout (headset station + local SvxReflector lab).
**Fixed point:** `8e75515` (`Record the station glossary and the window and config decisions.`)
**Compared tip:** `HEAD` = `f90b615`
**Diff:** `git diff 8e75515...HEAD` (4222 insertions across 38 files)
**Spec:** [docs/issues/2026-10-03-funknetz-station/spec.md](../../docs/issues/2026-10-03-funknetz-station/spec.md) (workspace issue tracker)
**Standards:** [knowledge/engineering/go/](../../knowledge/engineering/go/), [CONTEXT.md](../CONTEXT.md), [docs/adr/](./adr/)
Two axes below are independent. Findings are not merged or ranked across axes.
### Commits in range (`8e75515..HEAD`)
| Commit | Summary |
| --- | --- |
| `9c4f662` | Join one FM-Funknetz talk group from a headset window |
| `6233292` | Pause playback while transmitting and declare microphone use |
| `ec5f660` | Append an optional station log and announce the software after login |
| `eda79e6` | Add a local SvxReflector Docker lab for headset testing |
| `f90b615` | Merge branch `lab/svxlink-docker` |
### Working tree (not in `HEAD` diff)
At review time, uncommitted edits touched `docs/svxlink.md`, `svxlink-docker/` (Dockerfile, compose, entrypoint, conf, `.env.example`, `.gitignore`), root `.gitignore`, and an untracked `svxlink-docker/config/svxreflector.link.conf`. Protocol and lab notes in this report include that working tree where it clarifies jayReflector Satellite/Trunk; Standards and Spec axes stay on `8e75515...HEAD` only.
### Tests run
```sh
GOWORK=off go test ./station/ -count=1 # ok (~10s)
```
`main`, `audio`, and `internal/opus` have no package tests. `TestConnectsToLabReflector` skips when `127.0.0.1:5300` is down (or `FUNKNETZ_LAB=0`).
---
## Standards
### Documented standards
**Hard — `modern.md` (new JSON → `encoding/json/v2`)**
`station/software.go` marshals with v1 `encoding/json` while `station/config.go` already uses `encoding/json/v2`. Same module, new code: prefer v2.
**Hard — `errors.md` (wrap with `%w`; don’t drop causes)**
Underlying failures are replaced with a bare string:
- `app.go` / config path resolution paths that surface `could not be read`
- `station/config.go` `loadConfig` / `parseConfig` / rewrite path: same `"could not be read"` without `%w`
**Hard — `testing.md` (prefer `t.Fatal` over `panic`)**
`station/config_test.go` `configJSON` / `configWithLog`: `panic(err)` on marshal failure.
**Judgement — `errors.md` / `modern.md` (`errors.Is`)**
`station/config.go`: `os.IsNotExist(err)` — prefer `errors.Is(err, fs.ErrNotExist)`.
**Judgement — `concurrency-context.md` + `functions-methods.md` (synchronous APIs; obvious goroutine lifetime)**
`audio.Start` returns after starting a cleanup goroutine on `ctx.Done()`. `app.go` fire-and-forgets `Run` with `_ =` (comment present; discard rule satisfied).
**Judgement — `modern.md` (`slices.Contains`)**
`station/protocol.go` `hasOPUS` hand-loops instead of `slices.Contains`.
**OK / tooling skip**
`_ = sendUDP(...)` sites carry justifying comments (`errors.md`). `gofmt`/layout not reviewed. `App` pass-through methods are required by ADR `0001-wails-station-window.md` (overrides Middle Man).
### Baseline smells (all judgement)
**Divergent Change** — `station.Station` / `session.go`: connection, TX, Opus, UDP, UI state, logging in one type.
**Data Clumps** — `onTCP` / `onServerInfo` pass `udpCh`, `udpErr`, `udpReady`, `udpRx`, `lastTCP` as pointers together.
**Duplicated Code** — `audio/device.go` repeats channel-count logic for capture vs playback; `reflector_test.go` reimplements append/parse helpers already in `protocol.go`.
**Mysterious Name** — `loaded`, `fileShape`, `up` / `outage` don’t say what they hold.
**Primitive Obsession** — `State.Connection` as free strings (`"Connecting"` / `"Connected"` / …); session `reason` as `"tcp"`/`"udp"`/`"timer"`.
**Middle Man** — `Fail` → `stop` → `endFatal` (thin hop).
---
## Spec
**Tests:** `GOWORK=off go test ./...` — `station` OK (~10s); `main` / `audio` / `internal/opus` have no tests. `TestConnectsToLabReflector` skips when `127.0.0.1:5300` is down (or `FUNKNETZ_LAB=0`).
### (a) Missing / partial
- **Separate default devices (partial).** Spec: “A device adapter behind the station talks to the operating system’s default input and default output” / US 52. `audio.Start` opens one `malgo.Duplex` device, not independent default input and default output—fails if duplex is unavailable even when separate devices would work.
- **Broken-JSON wording (partial).** Spec: “quit and say the file could not be read” (US 10). Implementation exits with `could not be read` (no “the file”).
Stories 1–9, 11–50, 53–62, 64–74 and the protocol / log Implementation Decisions look covered at the station seam (config, login, TG select, OPUS, PTT, heartbeats, retries, fatalities, log).
### (b) Scope creep
- **Local reflector lab.** Spec Solution / US 1–74 never ask for Docker or SvxReflector; Testing Decisions: “An in-process reflector double is the stand-in for TCP and UDP.” Diff adds `svxlink-docker/`, `docs/svxlink.md`, and `station/lab_reflector_test.go` (real TCP to `:5300`).
- **Audio-open fatal.** Spec never defines device-open failure copy. `app.go` calls `Fail("could not open the audio device")`, inventing an English sentence and stopping retries outside US 57–59.
### (c) Looks implemented, looks wrong
- **Duplex vs defaults (same as (a)).** Spec wants default **input and** default **output**; duplex is a different contract and can reject a valid headset/speakers pair.
- No other clear behavioral mismatches found against US 1–74 / protocol / log rules at HEAD (`8e75515...HEAD`). Wails labels, callsign/TG, PTT OR of button+Space, flush-on-release, busy-room inert PTT, and log line shapes match the quoted decisions.
---
## Protocol deep dive: SvxLink vs funknetz
There is no separate RFC. The contract is SvxLink / jayReflector message types (`ReflectorMsg.h` and client logics). Funknetz targets **protocol 2.0** (ReflectorV2 / AUTH_KEY), matching FM-Funknetz’s published guidance. Background: [docs/research/2026-10-03-fm-funknetz-go-client.md](../../docs/research/2026-10-03-fm-funknetz-go-client.md), lab notes in [docs/svxlink.md](./svxlink.md).
### 1. What funknetz implements
**Framing.** TCP: 4-byte BE length + `uint16` type + body (`station/protocol.go`). UDP V2 header: type, client id, sequence (`station/session.go`). Strings/blobs: `uint16` length + bytes. Max TCP frame 32768.
**TCP types handled or sent** (`protocol.go`, `session.go`, `software.go`):
| Type | Role in funknetz |
| --- | --- |
| 5 | Send ProtoVer **2.0** first |
| 10 / 11 / 12 | HMAC-SHA1 challenge (20 B) → callsign + digest; AuthOK is a no-op |
| 13 | Fatal string → stop retries |
| 100 | Parse reserved, client id, nodes, codecs; require `"OPUS"` |
| 111 | Send NodeInfoV2 JSON `{"sw","swVer"}` only |
| 106 | Send configured TG once after 111 |
| 104 / 105 | Talker start/stop: honor only matching TG; own callsign start ignored |
| 1 | TCP heartbeat |
Unknown TCP types are ignored.
**UDP:** send/recv type **1** heartbeat; **101** audio (`uint16` len + Opus); **102** flush on PTT release (after padding a partial frame). Drop datagrams whose client id ≠ ours; **no** sequence check on RX. While transmitting, discard RX audio.
**Auth / codecs / Opus.** Auth key never on the wire. Opus-only: 16 kHz mono, 320 samples / 20 ms, 20 kbit/s VBR, voice, medium-band, DTX off (`internal/opus/opus.go`).
**Heartbeats / talker rules.** Match ReflectorV2 counters: TCP TX 10 s / RX 15 s; UDP TX 15 s / RX 60 s. Room busy from talker-start (matching TG) or any accepted RX audio; PTT inert while busy; mic never looped to playback.
**Tests assert:** login ProtoVer 2.0, HMAC digest, NodeInfo keys, SelectTG, initial UDP HB seq 0; heartbeats, retries, MsgError / no-OPUS stop retry, TCP/UDP timeouts, TG-filtered talker, busy blocks TX, PTT + flush 102, seq wrap, wrong-client-id audio dropped.
### 2. Parity with SvxLink ReflectorV2
| Capability | Upstream (ReflectorV2 / research) | funknetz |
| --- | --- | --- |
| Proto 2.0 + AUTH_KEY HMAC-SHA1 | ReflectorV2Logic | Yes |
| Framed TCP + UDP same host/port | AsyncFramedTcp / V2 UDP | Yes |
| MsgServerInfo → client id + codecs | type 100 | Yes (OPUS required) |
| MsgSelectTG | 106 | Yes |
| MsgNodeInfoV2 | 111 JSON | Yes (minimal) |
| TalkerStart/Stop with TG | 104 / 105 (≥ proto 2) | Yes |
| UDP HB / Audio / FlushSamples | 1 / 101 / 102 | Yes |
| One talker/TG; client mute when busy | Reflector + client | Client-side busy |
| Heartbeat timers 10 / 15 / 15 / 60 | ReflectorV2Logic.h | Yes |
| Opus defaults 16 kHz / 20 ms / 20 kbit | AsyncAudioEncoderOpus | Yes |
### 3. Differences
**Intentional (spec Out of Scope):** protocol 3 / certs / AES-GCM UDP; MsgTgMonitor / QSY / DTMF; DNS SRV / baked-in host; GSM / Speex / RAW; rich node-info; multi-TG; station list / siglev UI.
**Thinner than ReflectorV2 client:**
| Topic | Upstream V2 | funknetz |
| --- | --- | --- |
| MsgProtoVerDowngrade (6) | Client should retry lower ver | Ignored |
| RX UDP sequence / peer filter | Drop behind-seq; peer = TCP peer | Client-id only; connected UDP socket |
| MsgUdpAllSamplesFlushed (103) | Flush handshake | Not sent/handled |
| TalkerStop vs late audio | Audio may still arrive after 105 | Stop clears busy; **any later 101 re-sets `roomBusy`** without a named talker (`session.go` `onDatagram`) — can strand PTT |
| Pre-auth size cap | 64 B | Always up to 32768 |
| MONITOR_TGS / StateEvent / Signal / TxStatus | Optional client telemetry | Absent |
| Codecs | First mutually supported | Hard-fail if no OPUS |
| jayReflector type 121 trunk node list | Extra | Ignored (OK) |
### 4. Currently missing
**(A) Intentionally out of scope for v1 headset** — Proto 3 / encryption; MsgTgMonitor; MsgRequestQsy; NodeJoined / Left UI; StateEvent / SignalStrength / TxStatus; DNS SRV; non-OPUS codecs; rich NODE_INFO; multi-TG.
**(B) Gaps that can still affect ReflectorV2 / FM-Funknetz / jayReflector headset use**
1. **Late audio after TalkerStop** re-busying the room without a named talker (upstream documents post-stop audio).
2. **No ProtoVerDowngrade** handling (unlikely on FM’s V2 servers; breaks if a server asks to step down).
3. **No UDP flush / all-flushed** — usually fine for app clients; edge cases if a peer expects 103.
4. **No SRV** — operators must set host/port (FM docs often use 5308 + `DNS_DOMAIN` on nodes).
5. **No MsgTgMonitor** — idle monitoring of other TGs unavailable; single SelectTG is enough for one configured TG.
6. **Stricter RX sequence** not implemented — more permissive than V2; rarely blocks interop.
Core login + SelectTG + Opus UDP path is aligned with FM-Funknetz’s ReflectorV2 guidance.
### 5. Lab stack
| Stack | Role |
| --- | --- |
| **funknetz `svxlink-docker`** | Builds **jayReflector** → `svxreflector`; TCP+UDP **5300**; `[USERS]` / `[PASSWORDS]` for proto 2; optional **Satellite** (outbound **5303**) / **Trunk** (**5302**); clients stay on local `:5300` |
| **Stock sm0svx SvxReflector** | Same client wire protocol; **no** reflector peering |
| **jayReflector Satellite / Trunk** | Server-side mesh / uplink; Funknetz does **not** speak Trunk / Satellite |
Headset interop testing needs a ReflectorV2-capable `svxreflector` with OPUS and AUTH_KEY users. Satellite / Trunk only matter when exercising FM uplink, not the client protocol itself.
### Message coverage (quick map)
| Direction | Implemented | Not implemented (by design unless noted) |
| --- | --- | --- |
| TCP send | 5, 11, 1, 111, 106 | 14–21 (proto 3), 107 monitor, 109 QSY, 110–113 telemetry |
| TCP recv | 1, 10, 12, 13, 100, 104, 105 | 6 downgrade (**gap**), 101 NodeList (legacy), 102/103 presence, 114 UDP encrypt |
| UDP send | 1, 101, 102 | 103 all-flushed, 104 signal |
| UDP recv | 1 (timer), 101 | sequence validation, 102/103 handshake |
---
## Summary
**Standards:** 3 hard findings (json/v2 in `software.go`; bare `could not be read` dropping causes; `panic` in test helpers); several judgement smells — worst hard issue is inconsistent JSON API / dropped error causes on config read.
**Spec:** 2 partial gaps (duplex vs separate default devices; broken-JSON wording) + 2 scope-creep items (Docker lab; audio-open fatal copy) — worst issue is duplex audio vs the specified separate default input/output devices.
### Follow-up (2026-10-07)
Hard Standards and Spec gaps addressed in code where possible:
| Finding | Status |
| --- | --- |
| `software.go` → `encoding/json/v2` | Fixed |
| Config / path errors wrap with `%w`; US 10 wording `the file could not be read` | Fixed |
| Test helpers use `t.Fatal` instead of `panic` | Fixed |
| Separate default Capture + Playback devices (not Duplex) | Fixed |
| Docker lab scope creep | Left in place — intentional engineering aid, not a product story |
| Audio-open `Fail("could not open the audio device")` | Left — spec has no copy; stopping without audio is still required UX |