<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://gophertrunk.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gophertrunk.org/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-08-15T00:11:51-05:00</updated><id>https://gophertrunk.org/feed.xml</id><title type="html">GopherTrunk</title><subtitle>Pure-Go digital-trunking RTL-SDR scanner engine. P25, DMR, TETRA, NXDN, Motorola, EDACS, LTR, MPT 1327, dPMR, D-STAR, YSF — single static binary.</subtitle><author><name>Matt Cheramie</name></author><entry><title type="html">From the Issue Tracker, Part 3: Encrypted, Says Who — Four Layers Between a Flag and Its Metadata</title><link href="https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-03-phase2-encryption-metadata/" rel="alternate" type="text/html" title="From the Issue Tracker, Part 3: Encrypted, Says Who — Four Layers Between a Flag and Its Metadata" /><published>2026-08-15T00:00:00-05:00</published><updated>2026-08-15T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-03-phase2-encryption-metadata</id><content type="html" xml:base="https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-03-phase2-encryption-metadata/"><![CDATA[<p><em>Part 3 of <strong>From the Issue Tracker</strong>, postmortems of GopherTrunk bugs that fought
back. <a href="/blog/solution-postmortem/from-the-issue-tracker-02-talker-alias-hunt/">Part 2</a>
chased a talker alias through cipher land. This one is about a field that was
always half right: every encrypted P25 Phase 2 call got flagged <code class="language-plaintext highlighter-rouge">encrypted: true</code>,
and not one of them ever said *what kind</em> of encrypted. The answer turned out to be
four independent defects deep — and the tool that finally ordered them was a log
line that fires even when there is nothing to say.*</p>

<blockquote>
  <p><strong>TL;DR:</strong> On a live P25 Phase 2 system, 66 of 66 encrypted calls were flagged
<code class="language-plaintext highlighter-rouge">encrypted: true</code> while <code class="language-plaintext highlighter-rouge">algorithm_id</code>/<code class="language-plaintext highlighter-rouge">key_id</code> never populated
(<a href="https://github.com/MattCheramie/GopherTrunk/issues/813">#813</a>). The flag and
the metadata come from different places, so one can be right while the other is
structurally impossible. Underneath sat a four-stage chain: an encryption-sync
MAC opcode (<code class="language-plaintext highlighter-rouge">0x70</code>) that doesn’t exist on real air, a plausible-but-wrong
carrier-recovery diagnosis disproven by a TCXO source failing identically, a
48-bit sync-word constant silently truncated into a 40-bit field, and a missing
2↔3 dibit remap that let superframes lock while every payload decoded to
garbage. The technique that cracked the ordering was an <strong>unconditional per-call
census</strong> that logs stage counters even at zero — and the last line of defense is
a validity gate that refuses to publish an algorithm ID the standard has never
heard of.</p>
</blockquote>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Fact</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Issue</td>
      <td><a href="https://github.com/MattCheramie/GopherTrunk/issues/813">#813</a></td>
    </tr>
    <tr>
      <td>Symptom</td>
      <td>66/66 encrypted Phase 2 calls flagged <code class="language-plaintext highlighter-rouge">encrypted: true</code>; <code class="language-plaintext highlighter-rouge">algorithm_id</code> / <code class="language-plaintext highlighter-rouge">key_id</code> never populate</td>
    </tr>
    <tr>
      <td>Wrong theory</td>
      <td>Missing carrier recovery — a real defect, genuinely fixed, and not this bug (a TCXO source failed identically)</td>
    </tr>
    <tr>
      <td>Real causes</td>
      <td>Fictional <code class="language-plaintext highlighter-rouge">OpEncryptionSync = 0x70</code>; a 48-bit sync constant truncated into a 40-bit field; a missing 2↔3 dibit remap</td>
    </tr>
    <tr>
      <td>The diagnostic</td>
      <td>Unconditional per-call census — stage counters logged even at zero</td>
    </tr>
    <tr>
      <td>Field numbers</td>
      <td>Phase 1: 89% of encrypted calls resolved a valid ALGID; Phase 2: 0.5%, and those were a bit-error smear</td>
    </tr>
    <tr>
      <td>Last line of defense</td>
      <td><code class="language-plaintext highlighter-rouge">p25.AlgorithmKnown(id)</code> validity gate at the composer — absent beats wrong</td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The symptom as reported</strong> — the flag is right; the metadata is structurally impossible.</li>
  <li><strong>Stage zero: the opcode that never existed</strong> — <code class="language-plaintext highlighter-rouge">0x70</code> was a working model, unit-tested against itself.</li>
  <li><strong>The census: a log line that fires at zero</strong> — the three-way stage disambiguator.</li>
  <li><strong>The plausible wrong theory: carrier recovery</strong> — the right bug class, the wrong bug.</li>
  <li><strong>Root cause one: a 48-bit constant in a 40-bit field</strong> — hunting for a sync word never transmitted.</li>
  <li><strong>Root cause two: the 2↔3 remap</strong> — superframes lock while every payload decodes to garbage.</li>
  <li><strong>The numbers, and the gate</strong> — 89% vs 0.5%, and refusing to publish nonsense.</li>
  <li><strong>What we keep</strong> — the durable rules and their Field Guide entries.</li>
</ul>

<h2 id="the-symptom-as-reported">The symptom as reported</h2>

<p>The reporter’s setup was deliberately clean: an Airspy on the control channel, a
dedicated RTL-SDR voice follower, <code class="language-plaintext highlighter-rouge">recordings.skip_encrypted: false</code> so encrypted
calls are actually followed, and a generous 5-second metadata window. Thirty
minutes of live traffic, then straight to the API rather than the logs:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">9367</span><span class="p">,</span><span class="w"> </span><span class="nl">"system"</span><span class="p">:</span><span class="w"> </span><span class="s2">"MMR"</span><span class="p">,</span><span class="w"> </span><span class="nl">"protocol"</span><span class="p">:</span><span class="w"> </span><span class="s2">"p25-phase2"</span><span class="p">,</span><span class="w"> </span><span class="nl">"group_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">3202</span><span class="p">,</span><span class="w">
 </span><span class="nl">"frequency_hz"</span><span class="p">:</span><span class="w"> </span><span class="mi">468612500</span><span class="p">,</span><span class="w"> </span><span class="nl">"encrypted"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
 </span><span class="nl">"end_reason"</span><span class="p">:</span><span class="w"> </span><span class="s2">"encrypted"</span><span class="p">,</span><span class="w"> </span><span class="nl">"talkgroup_alpha"</span><span class="p">:</span><span class="w"> </span><span class="s2">"22-02 WD2"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>66 calls flagged <code class="language-plaintext highlighter-rouge">encrypted: true</code>. Zero with an <code class="language-plaintext highlighter-rouge">algorithm_id</code> or <code class="language-plaintext highlighter-rouge">key_id</code> key at
all — <code class="language-plaintext highlighter-rouge">omitempty</code> was hiding zero values. The reporter had already ruled out the
obvious: not the follow window (1500 → 5000 ms changed nothing), not the recorder
short-circuiting (confirmed via log lines that calls were followed), not a logging
gap (the API is the stored value).</p>

<p>Why can the flag be right while the metadata never arrives? Because they are
unrelated. <code class="language-plaintext highlighter-rouge">encrypted: true</code> comes from the <strong>grant’s ServiceOptions “protected”
bit</strong> on the control channel. The algorithm and key IDs must be recovered from the
<strong>voice channel’s MAC layer</strong>. One path worked perfectly; the other, it turned
out, had never worked at all.</p>

<h2 id="stage-zero-the-opcode-that-never-existed">Stage zero: the opcode that never existed</h2>

<p>The extraction code existed and was unit-tested: a MAC PDU handler keyed on
<code class="language-plaintext highlighter-rouge">OpEncryptionSync = 0x70</code>, wired all the way through to the engine. Every test
passed — because every test <em>synthesized</em> a <code class="language-plaintext highlighter-rouge">0x70</code> PDU. The constant was an
explicit working model; the source even carried a note that the relevant spec PDF
wasn’t available. On real Phase 2 (TIA-102.BBAC), ALGID/KID/MI don’t ride a
standalone opcode: they ride the <strong>MAC_PTT</strong> message that begins each
transmission, identified by <em>slot type</em>, not by MAC opcode. <code class="language-plaintext highlighter-rouge">AsEncryptionSync</code>
could never match anything on air.</p>

<p>That explained the shape of the symptom — flagged but never populated — and
produced fix number one: parse MAC_PTT at the documented offsets. It did nothing,
which is how the real diagnostic entered the story.</p>

<h2 id="the-census-a-log-line-that-fires-at-zero">The census: a log line that fires at zero</h2>

<p>The first instrumentation pass added detail to the existing
<code class="language-plaintext highlighter-rouge">composer: p25p2 mac pdu</code> line. The reporter ran ~19 minutes of live traffic: 202
voice chains started with valid configuration, 33 encrypted calls — and <strong>zero</strong>
of those log lines, for any slot type.</p>

<p>Here is the trap: that line only fires on a <em>successful</em> MAC decode. Its silence
is identical whether superframe sync never locked, the ISCH never classified a MAC
slot, or the MAC FEC failed every time. <strong>The silence of a success-only log line
carries no diagnostic information.</strong></p>

<p>The replacement was an unconditional per-call census — one line at the end of
every call, even when every counter is zero:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>composer: p25p2 call census serial=… system=… superframes=N \
    voice_subframes=N mac_subframes=N mac_pdus=N  slot_Voice4V=… slot_Unknown=…
</code></pre></div></div>

<p>Read as a three-way stage disambiguator:</p>

<table>
  <thead>
    <tr>
      <th>Census reading</th>
      <th>Failing stage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">superframes=0</code></td>
      <td>Upstream of MAC entirely — superframe sync never locks</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">superframes&gt;0</code>, <code class="language-plaintext highlighter-rouge">mac_subframes=0</code></td>
      <td>Sync locks but ISCH never yields a MAC slot</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">mac_subframes&gt;0</code>, <code class="language-plaintext highlighter-rouge">mac_pdus=0</code></td>
      <td>MAC FEC chain fails every slot — <em>now</em> byte layouts matter</td>
    </tr>
  </tbody>
</table>

<p>The next run returned <code class="language-plaintext highlighter-rouge">superframes=0</code> on <strong>67 of 67 calls</strong>. Not one superframe
ever locked, encrypted or not — on the same dongle that recorded Phase 1 voice
cleanly the same night. The MAC_PTT byte-offset question was moot; the failure was
upstream of MAC entirely.</p>

<h2 id="the-plausible-wrong-theory-carrier-recovery">The plausible wrong theory: carrier recovery</h2>

<p>The Phase 2 H-DQPSK receiver was <code class="language-plaintext highlighter-rouge">MatchedFilter → Gardner timing → differential
decode</code> — no NCO, no AGC, no Costas loop. A differential decoder cancels a
<em>constant</em> carrier phase but not the per-symbol rotation <code class="language-plaintext highlighter-rouge">2π·Δf/baud</code> left by a
real tuner’s frequency offset. At 6000 baud, ~750 Hz of offset rotates every
symbol a full π/4 into the wrong quadrant. And the hardware split matched
perfectly: the Airspy control channel (TCXO, low offset) decoded; the RTL-SDR
voice follower (no TCXO) never locked. It was even the same bug class already
fixed twice on sibling paths — Phase 1 C4FM got coarse AFC in
<a href="https://github.com/MattCheramie/GopherTrunk/issues/275">#275</a>, Phase 1 CQPSK got
an NCO seed and Costas loop in
<a href="https://github.com/MattCheramie/GopherTrunk/issues/492">#492</a>. Every Phase 2
test had synthesized zero-offset IQ, so CI never noticed.</p>

<p>Carrier recovery was added — coarse seed, NCO, AGC, a rotation-aware Costas loop —
and the synthetic stream went from ~72% symbol errors at 1500 Hz offset to 0%
across ±5 kHz. A genuine defect, genuinely fixed.</p>

<p>The field result: <code class="language-plaintext highlighter-rouge">superframes=0</code>, 56 of 56 calls. Unchanged.</p>

<p>The clincher came later, unprompted: the reporter re-ran with the <strong>Airspy R2 —
a TCXO source — as the voice follower</strong> and got <code class="language-plaintext highlighter-rouge">superframes=0</code> on 20 of 20
unencrypted follows too. A low-offset source failing <em>identically</em> to a
high-offset one rules out carrier recovery entirely, and rules out hardware with
it. Whatever was broken was hardware-independent.</p>

<h2 id="root-cause-one-a-48-bit-constant-in-a-40-bit-field">Root cause one: a 48-bit constant in a 40-bit field</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/p25/phase2/sync.go — before</span>
<span class="n">OutboundSyncHex</span> <span class="kt">uint64</span> <span class="o">=</span> <span class="m">0x575F7DFF77FF</span>  <span class="c">// 48 bits…</span>
<span class="n">SyncDibits</span>             <span class="o">=</span> <span class="m">20</span>              <span class="c">// …into a 20-dibit (40-bit) field</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">hexToDibits</code> silently used only the low 40 bits, so the correlator hunted for
<code class="language-plaintext highlighter-rouge">0x5F7DFF77FF</code> — neither the standard sync word nor anything that has ever been
transmitted. No superframe could lock, on any tuner, ever. And every round-trip
test passed, because the test encoder injected sync <strong>from the same wrong
constant</strong> it decoded with. A self-consistent fiction.</p>

<p>The authoritative P25 Phase 2 outbound sync is <code class="language-plaintext highlighter-rouge">0x575D57F7FF</code> (cross-checked
against OP25’s <code class="language-plaintext highlighter-rouge">frame_sync_magics.h</code> and SDRtrunk, per TIA-102.BBAC). The
regression test that pinned it synthesizes the sync <em>independently of the
project’s own modulator</em> — the only kind of test that can expose a bad shared
constant: zero locks on the old value, locks on the correct one.</p>

<h2 id="root-cause-two-the-23-remap">Root cause two: the 2↔3 remap</h2>

<p>Fixing the sync constant surfaced the fourth layer. The shared DQPSK quadrant
slicer assigns the two negative-phase symbols the dibit values 3 and 2 where
TIA-102 says 2 and 3. The Phase 1 CQPSK path — verified on real air in #492 —
already documents and corrects exactly this transposition:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/radio/p25/phase1/receiver/cqpsk.go</span>
<span class="k">var</span> <span class="n">lsmDibitRemap</span> <span class="o">=</span> <span class="p">[</span><span class="m">4</span><span class="p">]</span><span class="kt">uint8</span><span class="p">{</span><span class="m">0</span><span class="p">,</span> <span class="m">1</span><span class="p">,</span> <span class="m">3</span><span class="p">,</span> <span class="m">2</span><span class="p">}</span> <span class="c">// swaps 2↔3 → canonical TIA-102</span>
</code></pre></div></div>

<p>Phase 2 was missing the equivalent remap. The swap is its own inverse, which made
for a satisfying sanity check: applying <code class="language-plaintext highlighter-rouge">[0,1,3,2]</code> to the transposed sync
<code class="language-plaintext highlighter-rouge">0x565956A6AA</code> yields exactly the authoritative <code class="language-plaintext highlighter-rouge">0x575D57F7FF</code>, and vice versa.
The failure signature is distinctive and worth remembering: <strong>superframes lock,
but every payload decodes to garbage</strong> — the regression test reproduces the field
symptom precisely, recovering <code class="language-plaintext highlighter-rouge">alg=0x75 key=0x555d</code> where <code class="language-plaintext highlighter-rouge">0x84/0x1234</code> was
encoded. The fix canonicalizes the receiver’s dibit output right after the slicer
(one point covers sync, ISCH, MAC FEC, and diagnostics together) and restores the
standard sync constant, leaving the shared demodulator — used by TETRA and Phase 1
CQPSK — untouched.</p>

<h2 id="the-numbers-and-the-gate">The numbers, and the gate</h2>

<p>A 7-day quantification from the completed-call webhook made the before/after
brutal and the residual honest:</p>

<table>
  <thead>
    <tr>
      <th>Path</th>
      <th>Encrypted calls</th>
      <th>Valid algorithm ID resolved</th>
      <th>Rate</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>P25 Phase 1</td>
      <td>2,739</td>
      <td>2,432 (AES-256)</td>
      <td><strong>89%</strong></td>
    </tr>
    <tr>
      <td>P25 Phase 2</td>
      <td>3,107</td>
      <td>15</td>
      <td><strong>0.5%</strong></td>
    </tr>
  </tbody>
</table>

<p>The Phase 1 column is the control group, and it explains why the asymmetry went
unnoticed for so long: Phase 1 recovers ALGID and key ID from the LDU2
encryption-sync fields mid-call, a decode path verified on air long before this
issue — so anyone spot-checking “does encryption metadata work?” against a Phase
1 call saw it working. The Phase 2 path had never produced a correct value, and
even after these fixes, the mid-call <code class="language-plaintext highlighter-rouge">KindCallSourceUpdate</code> that backfills a
Phase 2 call’s source and encrypted flag carries no algorithm or key — those
arrive only through the MAC_PTT chain this post walked.</p>

<p>Worse than “omitted”: the Phase 2 fields were populating with <strong>bit-error
values</strong> — a uniform algorithm-ID smear across 0x00–0xFF, a different key ID
every call. A wrong value published confidently is worse than an absent one.</p>

<p>The mitigation is a validity gate, <code class="language-plaintext highlighter-rouge">p25.AlgorithmKnown(id)</code>, checked against the
TIA-102 algorithm registry (0x80, 0x81, 0x83, 0x84, 0x85, 0x86, 0x89, 0x9F, 0xAA)
and applied <strong>at the composer</strong> — the single point that both the recorder
(webhooks, call history) and the engine (SSE, TUI) draw from, so every consumer is
covered at once. An out-of-set value is provably a mis-decode and is dropped; the
fields stay absent rather than lying. One deliberate scope call: the gate does
<em>not</em> whitelist the classified Type-1 block (0x00–0x41) — admitting 66 low values
would let a large slice of the smear straight through, and a real Type-1 sighting
is a one-line registry addition.</p>

<p>One process footnote: the reporter’s raw IQ capture sat as a release asset on a
fork, which the project’s offline-replay tooling couldn’t fetch — so every fix in
this chain had to be verified against spec-conformant synthetics and code-internal
cross-checks against the on-air-verified Phase 1 CQPSK path instead of the
reporter’s own air. Host captures somewhere plain; it matters.</p>

<h2 id="what-we-keep">What we keep</h2>

<ul>
  <li><strong>A flag and its metadata can have different truth values.</strong> <code class="language-plaintext highlighter-rouge">encrypted: true</code>
came from the grant; the algorithm ID needed the voice channel’s MAC layer. See
<a href="/reference/encrypted-call-handling/">encrypted call handling</a>
for how the two paths relate.</li>
  <li><strong>Silence of a success-only log line carries no information.</strong> The unconditional
per-call census — counters logged even at zero — is the single most transferable
technique in this story, and it’s now in the
<a href="/reference/diagnostic-playbook/">diagnostic playbook</a>.</li>
  <li><strong>A self-consistent synthetic proves nothing about air.</strong> The truncated sync
constant survived every round-trip test because encoder and decoder shared the
fiction. Regression tests for on-air constants must synthesize independently —
the pinned values live in
<a href="/reference/p25-onair-constants/">P25 on-air constants</a>.</li>
  <li><strong>A wrong theory can be a real bug.</strong> The carrier-recovery work was correct and
necessary — it just wasn’t <em>this</em> bug. The disproof (a TCXO source failing
identically) is as valuable as the fix.</li>
  <li><strong>Never publish a value you can’t validate.</strong> The registry gate turns a bit-error
smear back into honest absence, at the one choke point every consumer shares.</li>
</ul>

<h2 id="faq">FAQ</h2>

<p><strong>How can <code class="language-plaintext highlighter-rouge">encrypted: true</code> be reliable while the algorithm ID never arrives?</strong>
Because they come from different places. The flag is the grant’s ServiceOptions
“protected” bit, decoded on the control channel — a path that worked. The
algorithm and key IDs must be recovered from the voice channel’s MAC layer,
which had never worked. A flag and its metadata can have entirely different
truth values.</p>

<p><strong>The carrier-recovery theory fit perfectly — what disproved it?</strong>
The hardware split (Airspy TCXO decoded, RTL-SDR didn’t) matched the theory, the
fix measurably repaired synthetic offsets, and the field census still read
<code class="language-plaintext highlighter-rouge">superframes=0</code>. The clincher was the reporter re-running with the Airspy — a
low-offset TCXO source — as the voice follower and failing identically. When a
low-offset source fails the same way as a high-offset one, carrier recovery is
exonerated, and so is the hardware.</p>

<p><strong>Why didn’t unit tests catch the truncated sync constant?</strong>
Every round-trip test injected sync from the same wrong constant it decoded
with, so encoder and decoder agreed perfectly. The regression test that pinned
the fix synthesizes the sync word independently of the project’s own modulator —
the only kind of test that can expose a bad shared constant.</p>

<p><strong>What does the validity gate actually block?</strong>
Any algorithm ID outside the TIA-102 registry (0x80–0x86, 0x89, 0x9F, 0xAA). An
out-of-set value is provably a mis-decode and is dropped at the composer, the
one point both the recorder and the engine draw from. It deliberately does not
whitelist the classified Type-1 block (0x00–0x41): admitting 66 low values would
let a large slice of the bit-error smear straight through.</p>

<p><strong>Is a wrong published value really worse than an absent one?</strong>
Yes. An absent field says “unknown”; a smeared field says “algorithm 0x75, key
0x555d” with full confidence to every webhook, API consumer, and UI panel
downstream — and no consumer can tell it from a real decode. Honest absence is
recoverable; confident garbage propagates.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 3 of 22</strong> · ←
<a href="/blog/solution-postmortem/from-the-issue-tracker-02-talker-alias-hunt/">Part 2: The Talker-Alias Hunt — Three Wrong Transports and an Architectural Gate</a>
· Next →
<a href="/blog/solution-postmortem/from-the-issue-tracker-04-blog-v4-register-table/">Part 4: The Dongle That Heard Nothing — One Line in a Register Table</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="solution-postmortem" /><category term="from-the-issue-tracker" /><category term="p25" /><category term="phase2" /><category term="encryption" /><category term="debugging" /><category term="postmortem" /><summary type="html"><![CDATA[A P25 Phase 2 system flagged every encrypted call correctly but never reported which algorithm or key — because a fictional MAC opcode, a missing carrier loop, a 48-bit sync constant in a 40-bit field, and a swapped dibit map were stacked on top of each other.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Operator’s Cockpit, Part 12: The TUI Cockpit — The Same API in Your Terminal</title><link href="https://gophertrunk.org/blog/deep-dives/operator-cockpit-12-the-tui-cockpit/" rel="alternate" type="text/html" title="The Operator’s Cockpit, Part 12: The TUI Cockpit — The Same API in Your Terminal" /><published>2026-08-15T00:00:00-05:00</published><updated>2026-08-15T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/operator-cockpit-12-the-tui-cockpit</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/operator-cockpit-12-the-tui-cockpit/"><![CDATA[<p><em>Part 12 of <strong>The Operator’s Cockpit</strong>, the series on driving one GopherTrunk
daemon through one REST + SSE API from browser and terminal alike. Eleven posts
have built out a React console. This post proves the “and terminal” half of the
promise: a full-screen Bubbletea TUI that drives the <strong>exact same</strong> REST + SSE API
over SSH — no gRPC, no private socket, the same endpoints the browser hits.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> The TUI is an Elm-architecture Bubbletea program. Its root <code class="language-plaintext highlighter-rouge">Model</code>
holds a <code class="language-plaintext highlighter-rouge">SharedState</code> snapshot; <code class="language-plaintext highlighter-rouge">Init</code> kicks off a <strong>fan of polling commands</strong>
(health, systems, talkgroups, active calls, metrics, devices, scanner, hunt, …)
plus one <strong>long-lived SSE pump</strong>. <code class="language-plaintext highlighter-rouge">Update</code> is the reducer: root keys (quit,
help, palette, panel nav) win first, then poll/SSE messages fold into
<code class="language-plaintext highlighter-rouge">SharedState</code> and reschedule themselves, then the active panel gets the message.
The thirteen panels are pure renderers over <code class="language-plaintext highlighter-rouge">SharedState</code>. It reconnects SSE
with backoff, hides the <strong>same <code class="language-plaintext highlighter-rouge">web.tabs</code> panels</strong> the SPA hides, and gates
writes on the same <code class="language-plaintext highlighter-rouge">/api/v1/mutations</code> capability. Same daemon, same contract,
different renderer.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>It’s the same API, not a parallel one.</strong> The TUI calls the identical REST
endpoints and consumes the identical SSE event stream the browser does — the
through-line of the series, made literal.</li>
  <li><strong>One reducer, one SharedState.</strong> <code class="language-plaintext highlighter-rouge">Update</code> folds every poll and event into a
single snapshot; panels never fetch — they read <code class="language-plaintext highlighter-rouge">SharedState</code> and render.</li>
  <li><strong>Polls reschedule themselves; SSE is a pump.</strong> Each poll message re-arms its
own timer, and a single SSE goroutine feeds events into the reducer, reconnecting
with backoff on drop.</li>
  <li><strong>Consistency with the web is enforced, not hoped for.</strong> The same <code class="language-plaintext highlighter-rouge">web.tabs</code>
keys hide a panel in both UIs, and the same <code class="language-plaintext highlighter-rouge">/api/v1/mutations</code> snapshot gates
writes — the TUI and SPA can’t drift on what’s visible or writable.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Root model</td>
      <td>Elm-model reducer + chrome</td>
      <td><code class="language-plaintext highlighter-rouge">internal/tui/app.go</code> (<code class="language-plaintext highlighter-rouge">Model</code>)</td>
    </tr>
    <tr>
      <td>Polling fan</td>
      <td>one poll Cmd per read endpoint</td>
      <td><code class="language-plaintext highlighter-rouge">app.go</code> (<code class="language-plaintext highlighter-rouge">Init</code>, <code class="language-plaintext highlighter-rouge">cmdPoll*</code>)</td>
    </tr>
    <tr>
      <td>SSE pump</td>
      <td>long-lived event stream + reconnect</td>
      <td><code class="language-plaintext highlighter-rouge">internal/tui/client/sse.go</code> (<code class="language-plaintext highlighter-rouge">Stream</code> / <code class="language-plaintext highlighter-rouge">parseSSE</code>)</td>
    </tr>
    <tr>
      <td>SharedState</td>
      <td>the snapshot all panels read</td>
      <td><code class="language-plaintext highlighter-rouge">internal/tui/state/state.go</code></td>
    </tr>
    <tr>
      <td>Panels</td>
      <td>pure renderers over SharedState</td>
      <td><code class="language-plaintext highlighter-rouge">internal/tui/panels/</code> (e.g. <code class="language-plaintext highlighter-rouge">dashboard.go</code>)</td>
    </tr>
    <tr>
      <td>Global keys</td>
      <td>quit, help, palette, panel jumps</td>
      <td><code class="language-plaintext highlighter-rouge">internal/tui/keys.go</code></td>
    </tr>
    <tr>
      <td>Write gate</td>
      <td>AND of <code class="language-plaintext highlighter-rouge">--write</code> and daemon capability</td>
      <td><code class="language-plaintext highlighter-rouge">state.go</code> (<code class="language-plaintext highlighter-rouge">WriteEnabled</code>)</td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The Elm model</strong> — Init, Update, View, and why it fits a live console.</li>
  <li><strong>The polling fan</strong> — one self-rescheduling Cmd per read endpoint.</li>
  <li><strong>The SSE pump</strong> — the same event stream the browser gets, parsed in Go.</li>
  <li><strong>SharedState + panels</strong> — snapshot in the middle, renderers on the edge.</li>
  <li><strong>Parity with the web</strong> — same tabs hidden, same writes gated.</li>
</ul>

<h2 id="the-elm-model">The Elm model</h2>

<p>Bubbletea is The Elm Architecture in Go: a <code class="language-plaintext highlighter-rouge">Model</code>, an <code class="language-plaintext highlighter-rouge">Init</code> that returns startup
commands, an <code class="language-plaintext highlighter-rouge">Update</code> reducer that folds messages into new state and returns more
commands, and a <code class="language-plaintext highlighter-rouge">View</code> that renders the model to a string. GopherTrunk’s root
<code class="language-plaintext highlighter-rouge">Model</code> is exactly that — it holds the current panel selection, the thirteen
panels, and the <code class="language-plaintext highlighter-rouge">SharedState</code> every panel reads:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/app.go (shape)</span>
<span class="k">type</span> <span class="n">Model</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">cli</span>    <span class="o">*</span><span class="n">client</span><span class="o">.</span><span class="n">Client</span>
    <span class="n">active</span> <span class="n">state</span><span class="o">.</span><span class="n">PanelKind</span>
    <span class="n">panels</span> <span class="p">[]</span><span class="n">panels</span><span class="o">.</span><span class="n">Panel</span>
    <span class="n">shared</span> <span class="o">*</span><span class="n">state</span><span class="o">.</span><span class="n">SharedState</span>

    <span class="n">eventCh</span>    <span class="o">&lt;-</span><span class="k">chan</span> <span class="n">client</span><span class="o">.</span><span class="n">Event</span> <span class="c">// the live SSE feed</span>
    <span class="n">sseCancel</span>  <span class="k">func</span><span class="p">()</span>
    <span class="n">sseRetries</span> <span class="kt">int</span>
    <span class="c">// …palette, modals, tab hit-rects, toast</span>
<span class="p">}</span>

<span class="k">func</span> <span class="p">(</span><span class="n">m</span> <span class="o">*</span><span class="n">Model</span><span class="p">)</span> <span class="n">View</span><span class="p">()</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="n">tabs</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">renderTabs</span><span class="p">()</span>
    <span class="n">status</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">renderStatusBar</span><span class="p">()</span>
    <span class="n">body</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">panels</span><span class="p">[</span><span class="n">m</span><span class="o">.</span><span class="n">active</span><span class="p">]</span><span class="o">.</span><span class="n">View</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">width</span><span class="p">,</span> <span class="n">bodyH</span><span class="p">,</span> <span class="no">true</span><span class="p">,</span> <span class="n">m</span><span class="o">.</span><span class="n">shared</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">lipgloss</span><span class="o">.</span><span class="n">JoinVertical</span><span class="p">(</span><span class="n">lipgloss</span><span class="o">.</span><span class="n">Left</span><span class="p">,</span> <span class="n">tabs</span><span class="p">,</span> <span class="n">body</span><span class="p">,</span> <span class="n">status</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">View</code> composes chrome (a tab strip and a status bar) around the active panel’s
own render, using Lipgloss for layout. The panel gets the width, a body height,
and a pointer to <code class="language-plaintext highlighter-rouge">SharedState</code> — that’s the whole contract. Everything the panel
needs to draw is already in the snapshot; it never reaches for the network.</p>

<h2 id="the-polling-fan">The polling fan</h2>

<p><code class="language-plaintext highlighter-rouge">Init</code> is where the console comes alive. It returns a <code class="language-plaintext highlighter-rouge">tea.Batch</code> of one polling
command per read endpoint, plus the SSE connect — a fan of concurrent fetches
that mirrors, one-for-one, the set of things the browser polls:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/app.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">m</span> <span class="o">*</span><span class="n">Model</span><span class="p">)</span> <span class="n">Init</span><span class="p">()</span> <span class="n">tea</span><span class="o">.</span><span class="n">Cmd</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">tea</span><span class="o">.</span><span class="n">Batch</span><span class="p">(</span>
        <span class="n">cmdPollHealth</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollVersion</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
        <span class="n">cmdPollSystems</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollTalkgroups</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
        <span class="n">cmdPollActive</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollMetrics</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
        <span class="n">cmdPollHistory</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">,</span> <span class="n">client</span><span class="o">.</span><span class="n">HistoryFilter</span><span class="p">{</span><span class="n">Limit</span><span class="o">:</span> <span class="m">100</span><span class="p">}),</span>
        <span class="n">cmdPollDevices</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollScanner</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollHunt</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
        <span class="n">cmdPollAudio</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollRuntime</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
        <span class="n">cmdMutationStatus</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
        <span class="n">connectSSE</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span>
    <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each poll’s <em>result</em> message re-arms its own timer inside <code class="language-plaintext highlighter-rouge">Update</code>, so the fan is
self-sustaining — there’s no central scheduler, just each stream keeping itself
alive:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/app.go (shape) — inside Update</span>
<span class="k">case</span> <span class="n">pollActiveMsg</span><span class="o">:</span>
    <span class="k">if</span> <span class="n">msg</span><span class="o">.</span><span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">m</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">ActiveCalls</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">calls</span>
        <span class="n">m</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">LastPoll</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="n">m</span><span class="o">.</span><span class="n">toast</span><span class="p">(</span><span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"active: %v"</span><span class="p">,</span> <span class="n">msg</span><span class="o">.</span><span class="n">err</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="n">cmds</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">cmds</span><span class="p">,</span> <span class="n">scheduleAfter</span><span class="p">(</span><span class="n">pollActiveEvery</span><span class="p">,</span> <span class="n">cmdPollActive</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">)))</span>
</code></pre></div></div>

<p>That “fold the result, then reschedule myself” shape repeats for every endpoint.
The active-calls list refreshes on its own cadence, the systems list on its own,
metrics on theirs — each an independent, self-healing loop. A poll error becomes a
toast, not a crash, and the next tick tries again.</p>

<h2 id="the-sse-pump">The SSE pump</h2>

<p>Polling gives the console a steady heartbeat; the SSE stream gives it <em>reactions</em>.
The TUI subscribes to <code class="language-plaintext highlighter-rouge">GET /api/v1/events</code> — the identical Server-Sent-Events feed
the browser’s <code class="language-plaintext highlighter-rouge">EventSource</code> consumes — and parses it in Go. The parser is a
faithful little SSE implementation: dispatch on a blank line, accumulate <code class="language-plaintext highlighter-rouge">event:</code>
and <code class="language-plaintext highlighter-rouge">data:</code> fields, ignore <code class="language-plaintext highlighter-rouge">:</code> comments:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/client/sse.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">Client</span><span class="p">)</span> <span class="n">Stream</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">(</span><span class="o">&lt;-</span><span class="k">chan</span> <span class="n">Event</span><span class="p">,</span> <span class="o">&lt;-</span><span class="k">chan</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// …GET /api/v1/events with Accept: text/event-stream, no per-request timeout</span>
    <span class="c">// parseSSE(resp.Body, out) → one Event per dispatched block</span>
<span class="p">}</span>

<span class="c">// The data line is itself a JSON envelope {kind, timestamp, payload};</span>
<span class="c">// parseSSE decodes it and forwards the payload as Event.Raw so panels can</span>
<span class="c">// type-decode it — the same envelope the web UI consumes.</span>
</code></pre></div></div>

<p>Inside <code class="language-plaintext highlighter-rouge">Update</code>, a single event both lands in the ring buffers and triggers
<em>targeted</em> refreshes — a <code class="language-plaintext highlighter-rouge">call.start</code> re-polls active calls, an <code class="language-plaintext highlighter-rouge">sdr.attached</code>
re-polls devices — so the console reacts inside one SSE round-trip instead of
waiting for the next poll tick:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/app.go (shape) — inside Update</span>
<span class="k">case</span> <span class="n">eventMsg</span><span class="o">:</span>
    <span class="n">m</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">EventLog</span><span class="o">.</span><span class="p">(</span><span class="o">*</span><span class="n">RingBuf</span><span class="p">[</span><span class="n">client</span><span class="o">.</span><span class="n">Event</span><span class="p">])</span><span class="o">.</span><span class="n">Push</span><span class="p">(</span><span class="n">msg</span><span class="o">.</span><span class="n">ev</span><span class="p">)</span>
    <span class="k">switch</span> <span class="n">msg</span><span class="o">.</span><span class="n">ev</span><span class="o">.</span><span class="n">Kind</span> <span class="p">{</span>
    <span class="k">case</span> <span class="s">"call.start"</span><span class="p">,</span> <span class="s">"call.end"</span><span class="o">:</span>
        <span class="n">cmds</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">cmds</span><span class="p">,</span> <span class="n">cmdPollActive</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">),</span> <span class="n">cmdPollScanner</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">))</span>
    <span class="k">case</span> <span class="s">"sdr.attached"</span><span class="p">,</span> <span class="s">"sdr.detached"</span><span class="o">:</span>
        <span class="n">cmds</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">cmds</span><span class="p">,</span> <span class="n">cmdPollDevices</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">))</span>
    <span class="c">// …cchunt.*, cc.locked/lost, audio.state</span>
    <span class="p">}</span>
    <span class="n">cmds</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">cmds</span><span class="p">,</span> <span class="n">listenSSE</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">eventCh</span><span class="p">))</span> <span class="c">// keep pumping</span>

<span class="k">case</span> <span class="n">sseDownMsg</span><span class="o">:</span>
    <span class="n">m</span><span class="o">.</span><span class="n">sseRetries</span><span class="o">++</span>
    <span class="n">backoff</span> <span class="o">:=</span> <span class="n">min</span><span class="p">(</span><span class="m">1</span><span class="o">&lt;&lt;</span><span class="n">m</span><span class="o">.</span><span class="n">sseRetries</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">,</span> <span class="m">30</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
    <span class="n">m</span><span class="o">.</span><span class="n">toast</span><span class="p">(</span><span class="s">"event stream disconnected — reconnecting in "</span> <span class="o">+</span> <span class="n">backoff</span><span class="o">.</span><span class="n">String</span><span class="p">())</span>
    <span class="n">cmds</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">cmds</span><span class="p">,</span> <span class="n">tea</span><span class="o">.</span><span class="n">Tick</span><span class="p">(</span><span class="n">backoff</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Time</span><span class="p">)</span> <span class="n">tea</span><span class="o">.</span><span class="n">Msg</span> <span class="p">{</span> <span class="k">return</span> <span class="n">connectSSE</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">cli</span><span class="p">)()</span> <span class="p">}))</span>
</code></pre></div></div>

<p>The reconnection policy lives in the reducer, exactly as <code class="language-plaintext highlighter-rouge">sse.go</code>’s doc comment
promises (“reconnection is the caller’s responsibility”): a dropped stream becomes
an <code class="language-plaintext highlighter-rouge">sseDownMsg</code>, the retry count grows an exponential backoff capped at 30 s, and
a toast tells the operator what’s happening. It’s the same jittered-backoff
instinct the browser’s WebSocket clients use, expressed as Bubbletea messages.</p>

<figure class="lab-figure">
<svg viewBox="0 0 660 210" width="660" height="210" role="img" aria-label="The TUI reducer. A fan of self-rescheduling polling commands and one long-lived SSE pump both emit messages into the Update reducer, which folds them into a single SharedState snapshot. The active panel renders from SharedState. Root keys are handled before messages reach the panel; a dropped SSE stream schedules a backoff reconnect.">
  <rect x="12" y="40" width="130" height="40" rx="6" fill="none" stroke="currentColor" />
  <text x="77" y="57" text-anchor="middle" fill="currentColor" font-size="10">polling fan</text>
  <text x="77" y="71" text-anchor="middle" fill="var(--fg-muted)" font-size="8">self-reschedules</text>
  <rect x="12" y="130" width="130" height="40" rx="6" fill="none" stroke="currentColor" />
  <text x="77" y="147" text-anchor="middle" fill="currentColor" font-size="10">SSE pump</text>
  <text x="77" y="161" text-anchor="middle" fill="var(--fg-muted)" font-size="8">reconnect w/ backoff</text>
  <line x1="142" y1="60" x2="214" y2="98" stroke="currentColor" /><polygon points="214,94 224,99 213,102" fill="currentColor" />
  <line x1="142" y1="150" x2="214" y2="112" stroke="currentColor" /><polygon points="214,116 224,111 213,108" fill="currentColor" />
  <rect x="224" y="84" width="140" height="42" rx="6" fill="none" stroke="var(--accent)" />
  <text x="294" y="101" text-anchor="middle" fill="var(--accent)" font-size="11">Update reducer</text>
  <text x="294" y="115" text-anchor="middle" fill="var(--fg-muted)" font-size="8">root keys first</text>
  <line x1="364" y1="105" x2="410" y2="105" stroke="currentColor" /><polygon points="410,101 420,105 410,109" fill="currentColor" />
  <rect x="420" y="84" width="120" height="42" rx="6" fill="none" stroke="var(--accent)" />
  <text x="480" y="101" text-anchor="middle" fill="var(--accent)" font-size="10">SharedState</text>
  <text x="480" y="115" text-anchor="middle" fill="var(--fg-muted)" font-size="8">one snapshot</text>
  <line x1="540" y1="105" x2="586" y2="105" stroke="currentColor" /><polygon points="586,101 596,105 586,109" fill="currentColor" />
  <rect x="596" y="84" width="56" height="42" rx="6" fill="none" stroke="currentColor" />
  <text x="624" y="101" text-anchor="middle" fill="currentColor" font-size="9">panel</text>
  <text x="624" y="115" text-anchor="middle" fill="var(--fg-muted)" font-size="8">View()</text>
  <text x="330" y="194" text-anchor="middle" fill="var(--fg-muted)" font-size="10">panels never fetch — they read the snapshot the reducer maintains</text>
</svg>
<figcaption>Polls and the SSE pump feed one reducer; the reducer maintains one SharedState; panels render from it. The same shape as a Redux store fed by fetch + EventSource — in a terminal.</figcaption>
</figure>

<h2 id="sharedstate-and-the-panels">SharedState and the panels</h2>

<p>The reducer’s whole job is to keep one struct current. <code class="language-plaintext highlighter-rouge">SharedState</code> is the
snapshot of daemon-derived data all panels read — the exact analogue of the
browser’s shared store:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/state/state.go (shape)</span>
<span class="k">type</span> <span class="n">SharedState</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Health</span>      <span class="n">client</span><span class="o">.</span><span class="n">Health</span>
    <span class="n">Systems</span>     <span class="p">[]</span><span class="n">client</span><span class="o">.</span><span class="n">SystemDTO</span>
    <span class="n">Talkgroups</span>  <span class="p">[]</span><span class="n">client</span><span class="o">.</span><span class="n">TalkgroupDTO</span>
    <span class="n">ActiveCalls</span> <span class="p">[]</span><span class="n">client</span><span class="o">.</span><span class="n">ActiveCallDTO</span>
    <span class="n">Scanner</span>     <span class="n">client</span><span class="o">.</span><span class="n">ScannerStatusDTO</span>
    <span class="n">Runtime</span>     <span class="n">client</span><span class="o">.</span><span class="n">RuntimeDTO</span>
    <span class="n">EventLog</span>    <span class="n">RingReader</span><span class="p">[</span><span class="n">client</span><span class="o">.</span><span class="n">Event</span><span class="p">]</span>
    <span class="n">ToneAlerts</span>  <span class="n">RingReader</span><span class="p">[</span><span class="n">client</span><span class="o">.</span><span class="n">Event</span><span class="p">]</span>
    <span class="c">// …Devices, Hunt, Audio, Metrics, History + their *Err fields</span>
    <span class="n">WriteEnabled</span> <span class="kt">bool</span>                   <span class="c">// --write AND daemon capability</span>
    <span class="n">Mutations</span>    <span class="n">client</span><span class="o">.</span><span class="n">MutationStatus</span>  <span class="c">// per-subsystem writability</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The panels are correspondingly dumb — in the best way. The Dashboard panel is a
<em>pure renderer</em>: it owns no local state and simply lays four cards over
<code class="language-plaintext highlighter-rouge">SharedState</code>, collapsing the 2×2 grid to a vertical stack below 80 columns:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/panels/dashboard.go (shape)</span>
<span class="k">type</span> <span class="n">DashboardPanel</span> <span class="k">struct</span><span class="p">{}</span> <span class="c">// no fields — no state to get wrong</span>

<span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">DashboardPanel</span><span class="p">)</span> <span class="n">View</span><span class="p">(</span><span class="n">width</span><span class="p">,</span> <span class="n">height</span> <span class="kt">int</span><span class="p">,</span> <span class="n">_</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">s</span> <span class="o">*</span><span class="n">state</span><span class="o">.</span><span class="n">SharedState</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">width</span> <span class="o">&lt;</span> <span class="m">80</span> <span class="p">{</span> <span class="c">/* stack Health / Active / Events / Tones vertically */</span> <span class="p">}</span>
    <span class="c">// …else a 2×2 grid of dashboardCard(...) over s</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Because the snapshot is the single source and the panels don’t fetch, the panels
compose trivially: <code class="language-plaintext highlighter-rouge">Update</code> forwards each message to the active panel <em>after</em> it
has folded the poll/event data, so a panel always renders the freshest state. The
root handles the cross-cutting concerns — window resize, quit, help, the command
palette (<code class="language-plaintext highlighter-rouge">ctrl+p</code>), theme toggle (<code class="language-plaintext highlighter-rouge">ctrl+t</code>), and the numeric/tab panel jumps in
<code class="language-plaintext highlighter-rouge">keys.go</code> — before a message ever reaches a panel.</p>

<h2 id="parity-with-the-web-is-enforced">Parity with the web is enforced</h2>

<p>The series’ claim is “one API, two renderers,” and the TUI backs it with two
concrete parity mechanisms rather than good intentions.</p>

<p>First, <strong>the same tabs hide in both UIs.</strong> The runtime snapshot carries the
<code class="language-plaintext highlighter-rouge">web.tabs</code> hidden set, and <code class="language-plaintext highlighter-rouge">visiblePanels</code> filters the tab strip, cycling, jump
keys, and mouse hit-testing off it — using the <em>same keys</em> the SPA uses (a
panel’s <code class="language-plaintext highlighter-rouge">Key()</code> is its web route minus the leading slash):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/tui/app.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">m</span> <span class="o">*</span><span class="n">Model</span><span class="p">)</span> <span class="n">visiblePanels</span><span class="p">()</span> <span class="p">[]</span><span class="n">state</span><span class="o">.</span><span class="n">PanelKind</span> <span class="p">{</span>
    <span class="n">hidden</span> <span class="o">:=</span> <span class="n">setOf</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">Runtime</span><span class="o">.</span><span class="n">HiddenTabs</span><span class="p">)</span>  <span class="c">// same web.tabs keys as the SPA</span>
    <span class="c">// …keep every panel whose Key() isn't hidden; never strand the operator</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Second, <strong>the same capability gates writes.</strong> <code class="language-plaintext highlighter-rouge">WriteEnabled</code> is the AND of the
TUI’s <code class="language-plaintext highlighter-rouge">--write</code> flag and the daemon’s <code class="language-plaintext highlighter-rouge">/api/v1/mutations</code> <code class="language-plaintext highlighter-rouge">allow_mutations</code> — the
identical snapshot the browser reads in Part 10 — so a write keybinding either
fires the same mutation the web console would, or shows a toast explaining exactly
why it can’t. Neither UI can decide on its own what’s visible or writable; the
daemon is the authority, and both renderers obey it. The finished terminal surface
is documented on the <a href="/tui.html">TUI</a> operator page.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p><a href="/blog/deep-dives/operator-cockpit-13-reflect-driven-config-form/">Part 13</a>
digs into the one place the browser and terminal share <em>more</em> than an API — a
single reflect-driven schema that generates the config editor for both the web
form and a Bubbletea form, so the two config builders can’t drift from the struct
or from each other.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Does the TUI use a different backend than the web console?</strong>
No. It calls the identical REST endpoints and consumes the identical SSE event
stream (<code class="language-plaintext highlighter-rouge">GET /api/v1/events</code>) the browser uses. There’s no TUI-only API — it’s a
second renderer over the same contract.</p>

<p><strong>How does it stay live without a browser’s event loop?</strong>
Bubbletea’s message loop plays that role. A fan of polling commands re-arm their
own timers, and one long-lived SSE goroutine pushes events in as messages; the
<code class="language-plaintext highlighter-rouge">Update</code> reducer folds both into <code class="language-plaintext highlighter-rouge">SharedState</code>. A dropped stream schedules a
backoff reconnect.</p>

<p><strong>Are the panels fetching data themselves?</strong>
No — panels are pure renderers over <code class="language-plaintext highlighter-rouge">SharedState</code>. Only the root model fetches
(via commands) and folds results into the snapshot; the active panel reads that
snapshot to draw. It’s the terminal version of components reading a shared store.</p>

<p><strong>Why do the same panels hide in the TUI and the web UI?</strong>
Because both read the <code class="language-plaintext highlighter-rouge">web.tabs</code> hidden set using the same keys (a panel’s key is
its web route). Hiding a tab in config removes it from the SPA nav <em>and</em> the TUI
tab strip, so the two surfaces can’t disagree on what’s visible.</p>

<p><strong>Can the TUI change the daemon, or is it read-only?</strong>
It can write, but it’s gated: <code class="language-plaintext highlighter-rouge">WriteEnabled</code> is the AND of the <code class="language-plaintext highlighter-rouge">--write</code> flag and
the daemon’s <code class="language-plaintext highlighter-rouge">/api/v1/mutations</code> capability — the same snapshot the browser uses.
Without both, write keybindings show a “mutations disabled” toast instead of
firing.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/deep-dives/operator-cockpit-11-mobile-shell-pwa/">Part 11: The Mobile Shell, Command Palette &amp; PWA</a>
· Next →
<a href="/blog/deep-dives/operator-cockpit-13-reflect-driven-config-form/">Part 13: The Reflect-Driven Config Form</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="operator-cockpit" /><category term="tui" /><category term="bubbletea" /><category term="sse" /><category term="terminal" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk's Bubbletea TUI drives the identical REST + SSE API as the browser — an Elm-model root reducer fanning out polling commands plus a long-lived SSE pump into one SharedState, thirteen panels as pure renderers over it, and a tab strip that hides the same web.tabs panels the web SPA does.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running It For Real, Part 12: Docker &amp;amp; RTL-SDR USB Pass-Through</title><link href="https://gophertrunk.org/blog/deep-dives/running-it-for-real-12-docker-usb/" rel="alternate" type="text/html" title="Running It For Real, Part 12: Docker &amp;amp; RTL-SDR USB Pass-Through" /><published>2026-08-15T00:00:00-05:00</published><updated>2026-08-15T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/running-it-for-real-12-docker-usb</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/running-it-for-real-12-docker-usb/"><![CDATA[<p><em>Part 12 of <strong>Running It For Real</strong>. Everything so far assumed the daemon runs
wherever you launched it — a shell on your laptop. A real 24/7 service usually
runs in a container, restarted by the orchestrator, isolated from the host. The
catch is that a scanner is not a stateless web app: it needs a physical radio, and
a container by default sees no USB. This post is about crossing that boundary
cleanly — building the daemon as a tiny static image, then giving exactly one
RTL-SDR to the container without handing it the whole host. The pure-Go design
makes the first half almost trivial; the USB half is where the real operating
knowledge lives. See the
<a href="/learn/deployment/">Containers &amp; Deployment</a> module for
the first-principles version.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> The <code class="language-plaintext highlighter-rouge">Dockerfile</code> is a two-stage, zero-CGO build — stage one compiles
the daemon with <code class="language-plaintext highlighter-rouge">CGO_ENABLED=0</code> (no C toolchain, no <code class="language-plaintext highlighter-rouge">librtlsdr</code>/<code class="language-plaintext highlighter-rouge">libusb</code>), stage
two carries just the binary on <code class="language-plaintext highlighter-rouge">debian:bookworm-slim</code> as a non-root user. The
hard part is USB: a dongle needs <strong>three</strong> things to work inside a container —
host <strong>udev rules</strong> granting a non-root group access to the device node, a <strong>DVB
blacklist</strong> so the kernel doesn’t claim it first, and <strong>container privileges</strong>
that map the device (<code class="language-plaintext highlighter-rouge">devices:</code> / <code class="language-plaintext highlighter-rouge">--device</code>), join the right group
(<code class="language-plaintext highlighter-rouge">group_add</code>), and add just <code class="language-plaintext highlighter-rouge">DAC_OVERRIDE</code> so the non-root user can open the node.
The shipped <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> does all of it — no <code class="language-plaintext highlighter-rouge">--privileged</code> required.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>Zero CGO makes the image tiny and the build boring.</strong> No C libraries to install
or link, so the runtime stage is a slim base plus one static binary and
<code class="language-plaintext highlighter-rouge">ca-certificates</code>.</li>
  <li><strong>USB access is the host’s job first.</strong> The udev rule and DVB blacklist are host
configuration; no container flag can substitute for them.</li>
  <li><strong>Map one device, not the whole bus.</strong> <code class="language-plaintext highlighter-rouge">--device /dev/bus/usb/&lt;bus&gt;/&lt;dev&gt;</code> gives
the container exactly one dongle; <code class="language-plaintext highlighter-rouge">/dev/bus/usb</code> (or <code class="language-plaintext highlighter-rouge">--privileged</code>) is the
over-broad shortcut to avoid.</li>
  <li><strong>Least privilege still works.</strong> <code class="language-plaintext highlighter-rouge">cap_drop: ALL</code> plus <code class="language-plaintext highlighter-rouge">cap_add: DAC_OVERRIDE</code> and
a <code class="language-plaintext highlighter-rouge">group_add</code> is enough for a non-root container user to claim the radio — you
never need <code class="language-plaintext highlighter-rouge">--privileged</code>.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>Where it lives</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Build stage</td>
      <td><code class="language-plaintext highlighter-rouge">Dockerfile</code> (stage 1)</td>
      <td><code class="language-plaintext highlighter-rouge">CGO_ENABLED=0 go build</code>, trimmed + stripped</td>
    </tr>
    <tr>
      <td>Runtime stage</td>
      <td><code class="language-plaintext highlighter-rouge">Dockerfile</code> (stage 2)</td>
      <td>slim base, non-root <code class="language-plaintext highlighter-rouge">gopher</code>, <code class="language-plaintext highlighter-rouge">ca-certificates</code></td>
    </tr>
    <tr>
      <td>Host udev rule</td>
      <td><code class="language-plaintext highlighter-rouge">/etc/udev/rules.d/20-rtlsdr.rules</code></td>
      <td>grants a group access to the device node</td>
    </tr>
    <tr>
      <td>DVB blacklist</td>
      <td><code class="language-plaintext highlighter-rouge">/etc/modprobe.d/blacklist-dvb_usb_rtl28xxu.conf</code></td>
      <td>stops the kernel claiming the dongle</td>
    </tr>
    <tr>
      <td>Device mapping</td>
      <td><code class="language-plaintext highlighter-rouge">docker-compose.yml</code> <code class="language-plaintext highlighter-rouge">devices:</code></td>
      <td>maps <code class="language-plaintext highlighter-rouge">/dev/bus/usb/&lt;bus&gt;/&lt;dev&gt;</code> in</td>
    </tr>
    <tr>
      <td>Group + cap</td>
      <td><code class="language-plaintext highlighter-rouge">group_add: 46</code>, <code class="language-plaintext highlighter-rouge">cap_add: DAC_OVERRIDE</code></td>
      <td>lets the non-root user open the node</td>
    </tr>
    <tr>
      <td>Liveness</td>
      <td>compose <code class="language-plaintext highlighter-rouge">healthcheck</code> → <code class="language-plaintext highlighter-rouge">/api/v1/health</code></td>
      <td>orchestrator restarts a dead daemon</td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The zero-CGO image</strong> — why the build is two short stages and a slim base.</li>
  <li><strong>Why a container sees no radio</strong> — the boundary, stated plainly.</li>
  <li><strong>The three requirements</strong> — udev, DVB blacklist, container privileges.</li>
  <li><strong>Least privilege over <code class="language-plaintext highlighter-rouge">--privileged</code></strong> — the capability that’s actually needed.</li>
  <li><strong>Verifying the dongle crossed</strong> — the one command that proves it worked.</li>
</ul>

<h2 id="the-zero-cgo-image">The zero-CGO image</h2>

<p>Most SDR software drags a C toolchain and <code class="language-plaintext highlighter-rouge">libusb</code>/<code class="language-plaintext highlighter-rouge">librtlsdr</code> into its container,
which makes the image large and the build fragile. GopherTrunk’s pure-Go design
means neither. The <code class="language-plaintext highlighter-rouge">Dockerfile</code> is two stages: build, then carry the binary.</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Dockerfile (shape) — stage 1: pure-Go build, no C toolchain</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">golang:1.25-bookworm</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
<span class="k">WORKDIR</span><span class="s"> /src</span>
<span class="k">COPY</span><span class="s"> go.mod go.sum ./</span>
<span class="k">RUN </span>go mod download          <span class="c"># cache deps before the source</span>
<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">ARG</span><span class="s"> VERSION=docker</span>
<span class="k">ENV</span><span class="s"> CGO_ENABLED=0</span>
<span class="k">RUN </span>go build <span class="nt">-trimpath</span> <span class="se">\
</span>        <span class="nt">-ldflags</span> <span class="s2">"-s -w -X .../internal/version.Version=</span><span class="k">${</span><span class="nv">VERSION</span><span class="k">}</span><span class="s2">"</span> <span class="se">\
</span>        <span class="nt">-o</span> /out/gophertrunk ./cmd/gophertrunk

<span class="c"># stage 2: carry only the binary on a slim base</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">debian:bookworm-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">runtime</span>
<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="nt">--no-install-recommends</span> ca-certificates <span class="se">\
</span> <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists/<span class="k">*</span>
<span class="k">RUN </span>useradd <span class="nt">--system</span> <span class="nt">--create-home</span> <span class="nt">--shell</span> /usr/sbin/nologin gopher
<span class="k">USER</span><span class="s"> gopher</span>
<span class="k">COPY</span><span class="s"> --from=builder /out/gophertrunk /usr/local/bin/gophertrunk</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/usr/local/bin/gophertrunk"]</span>
<span class="k">CMD</span><span class="s"> ["run", "-config", "/etc/gophertrunk/config.yaml"]</span>
</code></pre></div></div>

<p>Three operating properties fall out of <code class="language-plaintext highlighter-rouge">CGO_ENABLED=0</code>. The runtime image needs
<strong>no SDR system libraries</strong> — the daemon talks to the dongle through a pure-Go
USBDEVFS backend, so there’s nothing to <code class="language-plaintext highlighter-rouge">apt-get install</code> but <code class="language-plaintext highlighter-rouge">ca-certificates</code>
(for the outbound HTTPS the broadcast feeds need). The build <strong>caches cleanly</strong> —
deps download in their own layer before the source is copied, so a code change
doesn’t re-fetch the module cache. And the daemon runs as a <strong>non-root user</strong>
(<code class="language-plaintext highlighter-rouge">gopher</code>) by default, which matters the moment we talk about USB permissions:
the container user has to be <em>granted</em> device access, it doesn’t have it for free.</p>

<h2 id="why-a-container-sees-no-radio">Why a container sees no radio</h2>

<p>Here’s the boundary, stated plainly: a container gets its own device namespace,
and by default that namespace contains no USB devices at all. The daemon inside
can enumerate all it likes and find nothing, because <code class="language-plaintext highlighter-rouge">/dev/bus/usb</code> simply isn’t
there. So “run it in Docker” for a scanner is really “run it in Docker <em>and</em> thread
one physical dongle across the isolation boundary” — and that thread has three
strands, two of which are host configuration that no container flag can replace.</p>

<figure class="lab-figure">
<svg viewBox="0 0 660 210" width="660" height="210" role="img" aria-label="A layered diagram of USB pass-through. At the bottom, the physical RTL-SDR dongle. Above it on the host: a udev rule sets the device node mode and group, and a DVB blacklist stops the kernel driver claiming the dongle. Above that, the container boundary, crossed by three things: the device mapping brings the node in, group_add joins the container user to the host group, and the DAC_OVERRIDE capability lets the non-root user open the node. At the top, the daemon's pure-Go USBDEVFS backend reads and writes the device node directly.">
  <rect x="200" y="176" width="260" height="26" rx="6" fill="none" stroke="currentColor" />
  <text x="330" y="193" text-anchor="middle" fill="currentColor" font-size="10">RTL-SDR dongle (/dev/bus/usb/003/002)</text>
  <rect x="40" y="120" width="580" height="44" rx="6" fill="none" stroke="var(--fg-muted)" />
  <text x="120" y="138" text-anchor="middle" fill="var(--fg-muted)" font-size="10">HOST</text>
  <text x="330" y="136" text-anchor="middle" fill="var(--fg-muted)" font-size="9">udev rule → node MODE/GROUP</text>
  <text x="330" y="150" text-anchor="middle" fill="var(--fg-muted)" font-size="9">DVB blacklist → kernel doesn't claim it</text>
  <line x1="330" y1="176" x2="330" y2="164" stroke="currentColor" /><polygon points="326,164 330,156 334,164" fill="currentColor" />
  <rect x="40" y="34" width="580" height="72" rx="8" fill="none" stroke="var(--accent)" />
  <text x="120" y="52" text-anchor="middle" fill="var(--accent)" font-size="10">CONTAINER</text>
  <rect x="90" y="60" width="140" height="34" rx="6" fill="none" stroke="currentColor" />
  <text x="160" y="80" text-anchor="middle" fill="currentColor" font-size="9">devices: mapping</text>
  <rect x="260" y="60" width="140" height="34" rx="6" fill="none" stroke="currentColor" />
  <text x="330" y="80" text-anchor="middle" fill="currentColor" font-size="9">group_add: 46</text>
  <rect x="430" y="60" width="140" height="34" rx="6" fill="none" stroke="currentColor" />
  <text x="500" y="76" text-anchor="middle" fill="currentColor" font-size="9">cap_add:</text>
  <text x="500" y="88" text-anchor="middle" fill="currentColor" font-size="9">DAC_OVERRIDE</text>
  <line x1="330" y1="120" x2="330" y2="106" stroke="var(--accent)" /><polygon points="326,106 330,98 334,106" fill="var(--accent)" />
  <text x="330" y="24" text-anchor="middle" fill="var(--accent)" font-size="10">daemon · pure-Go USBDEVFS backend</text>
</svg>
<figcaption>Three strands cross the boundary — the device mapping, the group, and one capability — but they only work on top of host configuration: the udev rule and the DVB blacklist.</figcaption>
</figure>

<h2 id="the-three-requirements">The three requirements</h2>

<p>The <a href="/hardening.html">hardening guide</a> and the
<a href="/install-linux.html">Linux install guide</a> spell these out;
here they are as one operating checklist.</p>

<p><strong>1. Host udev rules.</strong> The device node has to be openable by the group the
container user will join. On the host:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># /etc/udev/rules.d/20-rtlsdr.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", \
    MODE="0660", GROUP="plugdev"
</code></pre></div></div>

<p>then <code class="language-plaintext highlighter-rouge">sudo udevadm control --reload &amp;&amp; sudo udevadm trigger</code> and replug the
dongle. This is <em>host</em> config — it sets the permissions on the node before it’s
ever mapped into a container.</p>

<p><strong>2. DVB blacklist.</strong> An RTL-SDR presents as a DVB-T TV tuner, and the kernel’s
<code class="language-plaintext highlighter-rouge">dvb_usb_rtl28xxu</code> driver will grab it on plug-in. Stop that:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># /etc/modprobe.d/blacklist-dvb_usb_rtl28xxu.conf
blacklist dvb_usb_rtl28xxu
</code></pre></div></div>

<p>The daemon auto-detaches the DVB driver at open time, so this is belt-and-braces —
but one less moving part, and on a headless box you want the device clean before
the container ever touches it.</p>

<p><strong>3. Container privileges.</strong> Now, and only now, the container flags matter. The
shipped <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> maps exactly one device, joins the host group, and
adds exactly one capability:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># docker-compose.yml (shape)</span>
<span class="na">services</span><span class="pi">:</span>
  <span class="na">gophertrunk</span><span class="pi">:</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">devices</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">/dev/bus/usb/003/002:/dev/bus/usb/003/002"</span>  <span class="c1"># match your lsusb</span>
    <span class="na">group_add</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">46"</span>          <span class="c1"># plugdev GID on Debian — check getent group plugdev</span>
    <span class="na">cap_drop</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">ALL</span>
    <span class="na">cap_add</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">DAC_OVERRIDE</span>  <span class="c1"># let the non-root user open the device node</span>
    <span class="na">healthcheck</span><span class="pi">:</span>
      <span class="na">test</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">CMD"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">wget"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">--quiet"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">--spider"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">http://127.0.0.1:8080/api/v1/health"</span><span class="pi">]</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">30s</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">devices:</code> maps the specific node in; <code class="language-plaintext highlighter-rouge">group_add: 46</code> puts the container’s non-root
user in the same <code class="language-plaintext highlighter-rouge">plugdev</code> group your udev rule granted; <code class="language-plaintext highlighter-rouge">cap_add: DAC_OVERRIDE</code>
lets that user open the node. That’s the complete recipe.</p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>The USBDEVFS backend needs no kernel driver.</strong> The daemon reads and writes
<code class="language-plaintext highlighter-rouge">/dev/bus/usb/&lt;bus&gt;/&lt;dev&gt;</code> directly, which is exactly why mapping <em>that node</em> — not
installing a driver in the container — is all it takes. The pure-Go transport and
the container recipe are two sides of the same design choice.</li>
  <li><strong>Non-root by default forces the permission story to be explicit.</strong> Because the
image runs as <code class="language-plaintext highlighter-rouge">gopher</code>, not root, you can’t accidentally rely on root’s blanket
device access; the udev group + <code class="language-plaintext highlighter-rouge">DAC_OVERRIDE</code> path is the only way in, and it’s
the least-privilege one.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ca-certificates</code> is the only runtime dependency.</strong> It’s there for the outbound
HTTPS the broadcast feeds (Parts 9–11) need — the SDR path pulls in nothing.</li>
</ul>

<h2 id="least-privilege-over---privileged">Least privilege over <code class="language-plaintext highlighter-rouge">--privileged</code></h2>

<p>The temptation, when a dongle won’t show up, is to reach for <code class="language-plaintext highlighter-rouge">--privileged</code> or to
map all of <code class="language-plaintext highlighter-rouge">/dev/bus/usb</code>. Both work, and both are wrong for a service that runs
for months: <code class="language-plaintext highlighter-rouge">--privileged</code> hands the container the entire host device space and
most capabilities, turning a scanner into a much larger blast radius than a scanner
needs. The recipe above proves you don’t have to. A <code class="language-plaintext highlighter-rouge">cap_drop: ALL</code> container with
a single <code class="language-plaintext highlighter-rouge">DAC_OVERRIDE</code> and one mapped node can claim the radio and do nothing
else — the daemon inside can’t touch another device, can’t load a module, can’t
escalate. The broader <code class="language-plaintext highlighter-rouge">/dev/bus/usb</code> mapping is only for cases where the dongle’s
bus/device path shifts on every replug and you can’t pin it; even then, prefer a
udev <code class="language-plaintext highlighter-rouge">SYMLINK</code> and map the symlink over opening the whole bus.</p>

<p>One more operating wrinkle worth pre-loading: USB <strong>autosuspend</strong>. On a headless
host the kernel may power-manage an idle-looking dongle mid-stream, which the
daemon sees as <code class="language-plaintext highlighter-rouge">usb: device disconnected</code>. Pin it on the host with
<code class="language-plaintext highlighter-rouge">echo on &gt; /sys/bus/usb/devices/&lt;id&gt;/power/control</code> (or a udev <code class="language-plaintext highlighter-rouge">TEST</code> rule) so a
long-running container stream isn’t cut by power management it can’t see.</p>

<h2 id="verifying-the-dongle-crossed">Verifying the dongle crossed</h2>

<p>After <code class="language-plaintext highlighter-rouge">docker compose up -d</code>, one command settles whether all three strands
landed — you run the daemon’s own SDR enumeration <em>inside</em> the container:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec </span>gophertrunk gophertrunk sdr list
<span class="c"># should print the dongle: index, serial, tuner type, supported gains</span>
curl <span class="nt">-s</span> http://localhost:8080/api/v1/health   <span class="c"># daemon serving</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">sdr list</code> shows the dongle, the whole chain is good: host permissions, device
mapping, group, and capability. If it prints nothing, work the layers in order —
check <code class="language-plaintext highlighter-rouge">dmesg</code> on the host for a DVB driver claim (blacklist didn’t take),
<code class="language-plaintext highlighter-rouge">ls -l /dev/bus/usb/...</code> from <em>inside</em> the container (mapping or udev permission
wrong), and <code class="language-plaintext highlighter-rouge">getent group plugdev</code> against your <code class="language-plaintext highlighter-rouge">group_add</code> GID (group mismatch).
The compose <code class="language-plaintext highlighter-rouge">healthcheck</code> closes the loop for the orchestrator: it hits
<code class="language-plaintext highlighter-rouge">/api/v1/health</code> every 30 seconds, so a daemon that wedges gets restarted by
<code class="language-plaintext highlighter-rouge">restart: unless-stopped</code> without you watching — the containerized equivalent of
the watchdog we reach in the finale.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>Containers are one way to run a service; a native system daemon is the other.
<a href="/blog/deep-dives/running-it-for-real-13-systemd-windows/">Part 13</a>
takes the same daemon to a hardened systemd unit — the sandboxing directives that
box in a native process the way a container isolates a containerized one,
including the <code class="language-plaintext highlighter-rouge">DeviceAllow</code> line that’s the systemd analogue of everything we just
did — and to the Windows service, installer, and launcher for operators not on
Linux at all.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Do I need <code class="language-plaintext highlighter-rouge">libusb</code> or <code class="language-plaintext highlighter-rouge">librtlsdr</code> in the image?</strong>
No. The build is <code class="language-plaintext highlighter-rouge">CGO_ENABLED=0</code> and the daemon talks to the dongle through a
pure-Go USBDEVFS backend, so the runtime stage carries only the static binary and
<code class="language-plaintext highlighter-rouge">ca-certificates</code>. That’s the whole reason the image is small and the USB recipe is
“map a device node,” not “install a driver.”</p>

<p><strong>Why isn’t <code class="language-plaintext highlighter-rouge">--privileged</code> the simplest answer?</strong>
It works but it’s the wrong trade for a months-long service — it grants the container
the entire host device space and nearly every capability. A <code class="language-plaintext highlighter-rouge">cap_drop: ALL</code> container
with one mapped node, a <code class="language-plaintext highlighter-rouge">group_add</code>, and <code class="language-plaintext highlighter-rouge">DAC_OVERRIDE</code> claims exactly one radio and
nothing else. Least privilege costs three lines of config and bounds the blast radius.</p>

<p><strong>My container’s <code class="language-plaintext highlighter-rouge">sdr list</code> is empty — where do I start?</strong>
Layer by layer. <code class="language-plaintext highlighter-rouge">dmesg</code> on the host for a DVB driver claim (blacklist), <code class="language-plaintext highlighter-rouge">ls -l
/dev/bus/usb/...</code> inside the container (device mapping + udev mode), and <code class="language-plaintext highlighter-rouge">getent group
plugdev</code> against your <code class="language-plaintext highlighter-rouge">group_add</code> GID (group match). One of those three is almost
always the culprit; the udev rule and blacklist are host config no flag can replace.</p>

<p><strong>The stream drops after a while with <code class="language-plaintext highlighter-rouge">device disconnected</code> — why?</strong>
USB autosuspend. The kernel power-manages a dongle it thinks is idle, which the daemon
sees as a disconnect. Pin power on the host with <code class="language-plaintext highlighter-rouge">echo on &gt;
/sys/bus/usb/devices/&lt;id&gt;/power/control</code> or a udev rule so a long-running container
stream isn’t cut by power management inside a namespace it can’t reach.</p>

<p><strong>How does the orchestrator know the daemon is alive?</strong>
The compose <code class="language-plaintext highlighter-rouge">healthcheck</code> polls <code class="language-plaintext highlighter-rouge">/api/v1/health</code> every 30 seconds; a failing probe
plus <code class="language-plaintext highlighter-rouge">restart: unless-stopped</code> gets the container restarted automatically. That
endpoint reports more than “process up” — attached SDR count, active calls, DB
connectivity — which the finale (Part 14) uses to distinguish “running” from
“actually working.”</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/deep-dives/running-it-for-real-11-grant-webhooks/">Part 11: Grant Webhooks &amp; External Integrations</a>
· Next →
<a href="/blog/deep-dives/running-it-for-real-13-systemd-windows/">Part 13: systemd Hardening &amp; the Windows Installer</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="running-it-for-real" /><category term="docker" /><category term="usb" /><category term="deployment" /><category term="hardening" /><category term="go" /><summary type="html"><![CDATA[Containerizing the pure-Go daemon and passing a real RTL-SDR through the container boundary — the multi-stage zero-CGO build, the three things a dongle needs inside a container (host udev, DVB blacklist, device mapping), and why least-privilege beats --privileged.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Hunt, Part 12: Alias Harvesting — Following Traffic for Talker Aliases</title><link href="https://gophertrunk.org/blog/deep-dives/the-hunt-12-alias-harvesting/" rel="alternate" type="text/html" title="The Hunt, Part 12: Alias Harvesting — Following Traffic for Talker Aliases" /><published>2026-08-15T00:00:00-05:00</published><updated>2026-08-15T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/the-hunt-12-alias-harvesting</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/the-hunt-12-alias-harvesting/"><![CDATA[<p><em>Part 12 of <strong>The Hunt</strong>. Part 11 left us with a system that has named nothing
smaller than itself: talkgroups are decimals, units are decimals. But there is
one genuine, human name that travels over the air — the <strong>talker alias</strong>, the
display name a radio broadcasts for itself. This post is about harvesting those
aliases off our system’s traffic channels, and doing it the efficient way: by
decoding the signalling that rides each call, <strong>without</strong> ever recording the
voice. It also runs straight into the series’ oldest ghost — the Motorola alias
cipher we still can’t read.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> On a busy P25 Phase 2 system most grants never get a voice tuner, and
encrypted calls are torn down before hangtime — so the talker-alias decode
wired <em>inside</em> the voice composer almost never runs (issue #376). <code class="language-plaintext highlighter-rouge">internal/sigfollow</code>
fixes that by <strong>decoupling alias capture from voice following</strong>: a <code class="language-plaintext highlighter-rouge">Manager</code>
subscribes to the bus, and for each in-window Phase 2 grant it borrows a
<strong>signalling-only</strong> DDC tap on the wideband IQ broker, decodes the traffic
channel’s MAC PDUs through the <strong>same</strong> <code class="language-plaintext highlighter-rouge">MACDispatcher</code> the voice composer uses,
and publishes any completed talker alias. Two SDRs, no voice pool, aliases
harvested off the signalling stream — mirroring how SDRTrunk does it. And when
the alias is the proprietary Motorola FACCH-S kind, it harvests the
<strong>ciphertext</strong> as cryptanalysis ground truth.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>Alias capture is decoupled from voice.</strong> A signalling follow needs only to
decode the MAC signalling on the traffic channel — no vocoder, no recording — so
it runs on grants the voice pool never covers.</li>
  <li><strong>One dispatcher, two callers.</strong> <code class="language-plaintext highlighter-rouge">MACDispatcher</code> is the exact decode the voice
composer runs; the follower reuses it, so the two paths never diverge on what an
alias means.</li>
  <li><strong>RS parity gates trust.</strong> A mis-framed superframe decodes random bytes that
almost never verify, so only an RS-valid PDU is trusted to set a call’s source
— a wrong RID is worse than an absent one.</li>
  <li><strong>The Motorola alias is still unread — so harvest the ciphertext.</strong> The
proprietary alias cipher is unverified, so the decoded name is usually empty;
the follower logs the reassembled ciphertext (paired with source RID and
talkgroup) as the corpus the cryptanalysis needs.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Manager</code></td>
      <td>one signalling follow per in-window Phase 2 grant</td>
      <td><code class="language-plaintext highlighter-rouge">internal/sigfollow/manager.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">follow</code></td>
      <td>tune a tap, decode MAC signalling, idle out</td>
      <td><code class="language-plaintext highlighter-rouge">internal/sigfollow/manager.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MACDispatcher</code></td>
      <td>decode MAC PDUs, publish talker aliases</td>
      <td><code class="language-plaintext highlighter-rouge">internal/sigfollow/dispatcher.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">allocTunerLocked</code></td>
      <td>claim a free signalling tap covering the grant</td>
      <td><code class="language-plaintext highlighter-rouge">internal/sigfollow/manager.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">onVoiceStart</code> / <code class="language-plaintext highlighter-rouge">onVoiceEnd</code></td>
      <td>yield to a voice chain that harvests itself</td>
      <td><code class="language-plaintext highlighter-rouge">internal/sigfollow/manager.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">completeMotorolaAlias</code></td>
      <td>log the alias ciphertext for cryptanalysis</td>
      <td><code class="language-plaintext highlighter-rouge">internal/sigfollow/dispatcher.go</code></td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The alias that voice-following misses</strong> — why the composer’s decode rarely runs.</li>
  <li><strong>A signalling-only follow</strong> — a tap, a dispatcher, an idle timer.</li>
  <li><strong>The shared dispatcher</strong> — one decode for the composer and the follower.</li>
  <li><strong>RS parity as a trust gate</strong> — why a wrong RID is worse than none.</li>
  <li><strong>The ciphertext we can’t read</strong> — harvesting Mercury’s corpus.</li>
</ul>

<h2 id="the-alias-that-voice-following-misses">The alias that voice-following misses</h2>

<p>A P25 talker alias rides the traffic channel, interleaved with the voice as MAC
signalling (FACCH-S). The obvious place to decode it is inside the voice
composer — you’re already demodulating that channel to record the call, so read
the alias while you’re there. And that works, when it runs. The problem is how
<em>often</em> it runs. The package doc is blunt about it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sigfollow/dispatcher.go (shape) — package doc</span>
<span class="c">// On a busy multi-site Phase 2 system most grants never get a voice tuner (the</span>
<span class="c">// voice pool can't cover the full voice spread), and encrypted calls are torn</span>
<span class="c">// down before hangtime — so the talker-alias FACCH-S decode wired inside the</span>
<span class="c">// voice composer almost never runs (issue #376). SDRTrunk harvests these aliases</span>
<span class="c">// from the traffic channel's signalling stream with just two SDRs, without</span>
<span class="c">// following the call as voice. This package mirrors that.</span>
</code></pre></div></div>

<p>Two failure modes stack. First, the voice pool is finite: on a busy system there
are more simultaneous calls than voice tuners, so most grants are never followed
as voice at all, and their aliases are never seen. Second — and worse for the
interesting traffic — <strong>encrypted</strong> calls are the ones most likely to be torn
down before hangtime, and hangtime is exactly when the alias burst arrives. The
result is that the aliases you most want ride right past a voice-only decoder.</p>

<p>The fix is to stop coupling the two. You don’t need to <em>record</em> a call to <em>read</em>
its signalling — you only need to demodulate the channel’s MAC stream, which is
far cheaper than running a vocoder. So GopherTrunk spins up a decode that does
only that.</p>

<h2 id="a-signalling-only-follow">A signalling-only follow</h2>

<p><code class="language-plaintext highlighter-rouge">sigfollow.Manager</code> subscribes to the event bus and reacts to grants. For each
in-window Phase 2 grant that isn’t already being followed and isn’t covered by a
voice call, it borrows a <strong>signalling tap</strong> — a standalone wideband DDC tap that
is <em>not</em> registered in the voice pool — and starts a follow:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sigfollow/manager.go (shape) — onGrant</span>
<span class="k">func</span> <span class="p">(</span><span class="n">m</span> <span class="o">*</span><span class="n">Manager</span><span class="p">)</span> <span class="n">onGrant</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">g</span> <span class="n">trunking</span><span class="o">.</span><span class="n">Grant</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">g</span><span class="o">.</span><span class="n">Protocol</span> <span class="o">!=</span> <span class="s">"p25-phase2"</span> <span class="o">||</span> <span class="n">g</span><span class="o">.</span><span class="n">FrequencyHz</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="n">m</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">_</span><span class="p">,</span> <span class="n">following</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">active</span><span class="p">[</span><span class="n">g</span><span class="o">.</span><span class="n">FrequencyHz</span><span class="p">];</span> <span class="n">following</span> <span class="p">{</span>
        <span class="n">m</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">();</span> <span class="k">return</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">m</span><span class="o">.</span><span class="n">voiceFreqs</span><span class="p">[</span><span class="n">g</span><span class="o">.</span><span class="n">FrequencyHz</span><span class="p">]</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">m</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">();</span> <span class="k">return</span> <span class="c">// a voice chain already harvests aliases here</span>
    <span class="p">}</span>
    <span class="n">vt</span> <span class="o">:=</span> <span class="n">m</span><span class="o">.</span><span class="n">allocTunerLocked</span><span class="p">(</span><span class="n">g</span><span class="o">.</span><span class="n">FrequencyHz</span><span class="p">)</span> <span class="c">// free tap whose window covers the grant</span>
    <span class="k">if</span> <span class="n">vt</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">m</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">();</span> <span class="k">return</span> <span class="c">// outside every tap's window, or all taps busy</span>
    <span class="p">}</span>
    <span class="n">fctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithCancel</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>
    <span class="n">m</span><span class="o">.</span><span class="n">active</span><span class="p">[</span><span class="n">g</span><span class="o">.</span><span class="n">FrequencyHz</span><span class="p">]</span> <span class="o">=</span> <span class="n">cancel</span>
    <span class="n">m</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
    <span class="n">m</span><span class="o">.</span><span class="n">wg</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="k">go</span> <span class="n">m</span><span class="o">.</span><span class="n">follow</span><span class="p">(</span><span class="n">fctx</span><span class="p">,</span> <span class="n">vt</span><span class="p">,</span> <span class="n">g</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">follow</code> goroutine tunes the tap to the grant frequency, opens its IQ stream,
builds a Phase 2 receiver, and pipes decoded superframes into a <code class="language-plaintext highlighter-rouge">MACDispatcher</code>.
Crucially, it carries the grant’s own Phase 2 FEC configuration — trellis, RS,
interleave, scrambler mode, seed, soft-decision — so it decodes the traffic
channel with exactly the same parameters the control channel stamped onto the
grant:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sigfollow/manager.go (shape) — follow</span>
<span class="n">macCfg</span> <span class="o">:=</span> <span class="n">p25p2</span><span class="o">.</span><span class="n">MACDecodeConfig</span><span class="p">{</span>
    <span class="n">Trellis</span><span class="o">:</span> <span class="n">p25p2</span><span class="o">.</span><span class="n">TrellisMode</span><span class="p">(</span><span class="n">g</span><span class="o">.</span><span class="n">P25Phase2Decode</span><span class="o">.</span><span class="n">Trellis</span><span class="p">),</span>
    <span class="n">RS</span><span class="o">:</span>      <span class="n">p25p2</span><span class="o">.</span><span class="n">RSMode</span><span class="p">(</span><span class="n">g</span><span class="o">.</span><span class="n">P25Phase2Decode</span><span class="o">.</span><span class="n">RS</span><span class="p">),</span>
    <span class="c">// …Interleave, Scrambler, Seed, SoftDecision, Equalizer — all from the grant</span>
<span class="p">}</span>
<span class="n">dispatcher</span> <span class="o">:=</span> <span class="n">NewMACDispatcher</span><span class="p">(</span><span class="n">MACDispatcherOptions</span><span class="p">{</span>
    <span class="n">Bus</span><span class="o">:</span> <span class="n">m</span><span class="o">.</span><span class="n">bus</span><span class="p">,</span> <span class="n">System</span><span class="o">:</span> <span class="n">g</span><span class="o">.</span><span class="n">System</span><span class="p">,</span> <span class="n">Serial</span><span class="o">:</span> <span class="n">vt</span><span class="o">.</span><span class="n">Serial</span><span class="p">(),</span> <span class="n">LogPrefix</span><span class="o">:</span> <span class="s">"sigfollow"</span><span class="p">,</span>
    <span class="c">// No OnCallSource / OnCallEncryption: a signalling follow has no bound call to</span>
    <span class="c">// backfill, so it harvests talker aliases only.</span>
<span class="p">})</span>
</code></pre></div></div>

<p>The follow ends on one of three bounds: an <strong>idle timeout</strong> (4 s of no decoded
superframe — the call dropped, and aliases arrive within a few seconds of voice
end, so a short idle covers the gap), a <strong>max duration</strong> cap (30 s, so a stuck
decode can’t pin a tap forever), or context cancellation. That last one is how the
system yields gracefully: if a voice chain later starts recording the same
frequency, <code class="language-plaintext highlighter-rouge">onVoiceStart</code> cancels the redundant signalling follow because the
voice chain harvests aliases itself. No frequency is ever decoded twice for the
same reason.</p>

<figure class="lab-figure">
<svg viewBox="0 0 660 210" width="660" height="210" role="img" aria-label="The signalling follow flow. A Phase 2 grant on the event bus triggers the manager to allocate a free signalling DDC tap whose window covers the grant frequency. The follow goroutine tunes the tap, decodes the traffic channel's MAC PDUs through the shared MAC dispatcher, and publishes any completed talker alias back onto the event bus. A voice-call-start event on the same frequency cancels the redundant follow.">
  <rect x="10" y="88" width="110" height="40" rx="6" fill="none" stroke="var(--accent)" />
  <text x="65" y="104" text-anchor="middle" fill="var(--accent)" font-size="10">grant (bus)</text>
  <text x="65" y="118" text-anchor="middle" fill="var(--fg-muted)" font-size="9">p25-phase2</text>
  <line x1="120" y1="108" x2="150" y2="108" stroke="currentColor" /><polygon points="150,104 160,108 150,112" fill="currentColor" />
  <rect x="160" y="86" width="120" height="44" rx="6" fill="none" stroke="currentColor" />
  <text x="220" y="102" text-anchor="middle" fill="currentColor" font-size="10">alloc signalling tap</text>
  <text x="220" y="116" text-anchor="middle" fill="var(--fg-muted)" font-size="9">covers grant freq?</text>
  <line x1="280" y1="108" x2="310" y2="108" stroke="currentColor" /><polygon points="310,104 320,108 310,112" fill="currentColor" />
  <rect x="320" y="80" width="130" height="56" rx="6" fill="none" stroke="var(--accent)" />
  <text x="385" y="98" text-anchor="middle" fill="var(--accent)" font-size="10">follow: rx → MAC</text>
  <text x="385" y="112" text-anchor="middle" fill="var(--fg-muted)" font-size="9">no vocoder</text>
  <text x="385" y="124" text-anchor="middle" fill="var(--fg-muted)" font-size="9">idle 4s · cap 30s</text>
  <line x1="450" y1="108" x2="482" y2="108" stroke="currentColor" /><polygon points="482,104 492,108 482,112" fill="currentColor" />
  <rect x="492" y="86" width="150" height="44" rx="6" fill="none" stroke="var(--accent)" />
  <text x="567" y="104" text-anchor="middle" fill="var(--accent)" font-size="10">talker alias (bus)</text>
  <text x="567" y="118" text-anchor="middle" fill="var(--fg-muted)" font-size="9">+ alias ciphertext log</text>
  <rect x="320" y="158" width="130" height="30" rx="5" fill="none" stroke="var(--fg-muted)" />
  <text x="385" y="177" text-anchor="middle" fill="var(--fg-muted)" font-size="9">voice start → cancel</text>
  <line x1="385" y1="158" x2="385" y2="136" stroke="var(--fg-muted)" stroke-dasharray="4 3" /><polygon points="381,140 385,132 389,140" fill="var(--fg-muted)" />
</svg>
<figcaption>A Phase 2 grant borrows a signalling-only tap, decodes just the MAC stream, and publishes any alias — yielding the moment a voice chain takes over the same frequency.</figcaption>
</figure>

<h2 id="the-shared-dispatcher">The shared dispatcher</h2>

<p>The follow doesn’t reimplement alias decoding — it can’t be allowed to, because
then the aliases harvested off a traffic channel might disagree with the ones the
voice composer reads on the same channel. Both drive the <strong>same</strong> <code class="language-plaintext highlighter-rouge">MACDispatcher</code>,
which decodes every MAC PDU in a superframe and routes it: a group-voice PDU
carries the source RID, a MAC-PTT slot carries the encryption sync, and the alias
fragments reassemble into a name. The follower simply leaves the call-bound hooks
(<code class="language-plaintext highlighter-rouge">OnCallSource</code>, <code class="language-plaintext highlighter-rouge">OnCallEncryption</code>) nil, because a signalling follow has no bound
call to backfill — it publishes talker aliases and nothing else. One decode, two
callers, guaranteed agreement.</p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>The dispatcher is stateful per channel, so construct one per follow.</strong> The
alias assemblers buffer fragments per source unit for the life of the channel;
a fresh dispatcher per follow means a new call never inherits a stale
half-alias.</li>
  <li><strong>Hooks, not branches, carry the difference.</strong> The composer wires
<code class="language-plaintext highlighter-rouge">OnCallSource</code>/<code class="language-plaintext highlighter-rouge">OnCallEncryption</code> to its engine-backfill publishers; the
follower passes nil. The <em>decode</em> is identical — only what happens to a
call-bound PDU differs, and that’s a function pointer, not a code path.</li>
  <li><strong>The follower borrows taps, it doesn’t own SDRs.</strong> Signalling taps are DDC taps
on the wideband IQ broker — the same virtual-tuner mechanism the wideband engine
uses — so alias harvesting rides existing hardware, not a dedicated radio.</li>
</ul>

<h2 id="rs-parity-as-a-trust-gate">RS parity as a trust gate</h2>

<p>There is a sharp edge here that the code handles carefully. A mis-framed Phase 2
superframe doesn’t fail loudly — it decodes a stream of <em>random</em> bytes, and an
opcode byte that happens to land on the group-voice value would inject a
plausible-but-wrong source RID. That RID is indistinguishable downstream from a
real one, and it would poison the completed-call metadata. The only signal that
separates a genuine PDU from garbage is the outer Reed–Solomon parity, so the
dispatcher gates on it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sigfollow/dispatcher.go (shape) — Dispatch, group-voice PDU</span>
<span class="k">if</span> <span class="n">u</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">pdu</span><span class="o">.</span><span class="n">AsGroupVoiceChannelUser</span><span class="p">();</span> <span class="n">ok</span> <span class="p">{</span>
    <span class="c">// The outer RS parity is the only signal that separates a genuine</span>
    <span class="c">// GROUP_VOICE_CHANNEL_USER from garbage (the opcode alone can't), so only an</span>
    <span class="c">// RS-verified PDU is trusted to set the call's source. A wrong RID is worse</span>
    <span class="c">// than an absent one (issue #915).</span>
    <span class="k">if</span> <span class="n">dec</span><span class="o">.</span><span class="n">RSValid</span> <span class="o">&amp;&amp;</span> <span class="n">d</span><span class="o">.</span><span class="n">onCallSource</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">d</span><span class="o">.</span><span class="n">onCallSource</span><span class="p">(</span><span class="n">u</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">continue</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Dispatch</code> returns both a decoded-PDU count and an RS-valid count, and a caller
feeds that ratio into a per-call census: on a correctly framed, descrambled
channel nearly every PDU verifies, whereas <code class="language-plaintext highlighter-rouge">rs_valid=false</code> across the board is
the fingerprint of a mis-framed superframe rather than an unhandled opcode. It’s
the same discipline the <a href="/blog/deep-dives/the-hunt-11-naming-the-unknown/">naming post</a>
drew for talkgroups — refuse to assert what you can’t verify — applied to a
single source RID.</p>

<h2 id="the-ciphertext-we-cant-read">The ciphertext we can’t read</h2>

<p>And then there is the alias GopherTrunk <em>reassembles perfectly and still can’t
read</em>. The real Motorola FACCH-S alias runs through a proprietary per-byte
cipher, and that cipher is unverified — <code class="language-plaintext highlighter-rouge">motorola.CipherVerified</code> is false — so
the decoded name comes out empty on real traffic. Rather than throw the burst
away, the follower harvests the <strong>ciphertext</strong>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/sigfollow/dispatcher.go (shape) — completeMotorolaAlias</span>
<span class="k">func</span> <span class="p">(</span><span class="n">d</span> <span class="o">*</span><span class="n">MACDispatcher</span><span class="p">)</span> <span class="n">completeMotorolaAlias</span><span class="p">(</span><span class="n">res</span> <span class="n">p25p2</span><span class="o">.</span><span class="n">MotorolaAliasResult</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">res</span><span class="o">.</span><span class="n">Encoded</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">d</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="n">d</span><span class="o">.</span><span class="n">logPrefix</span><span class="o">+</span><span class="s">": p25p2 alias ciphertext"</span><span class="p">,</span>
            <span class="s">"system"</span><span class="p">,</span> <span class="n">d</span><span class="o">.</span><span class="n">system</span><span class="p">,</span> <span class="s">"serial"</span><span class="p">,</span> <span class="n">d</span><span class="o">.</span><span class="n">serial</span><span class="p">,</span>
            <span class="s">"rid"</span><span class="p">,</span> <span class="n">res</span><span class="o">.</span><span class="n">SourceID</span><span class="p">,</span> <span class="s">"talkgroup"</span><span class="p">,</span> <span class="n">res</span><span class="o">.</span><span class="n">TalkgroupID</span><span class="p">,</span>
            <span class="s">"encoded_hex"</span><span class="p">,</span> <span class="n">hex</span><span class="o">.</span><span class="n">EncodeToString</span><span class="p">(</span><span class="n">res</span><span class="o">.</span><span class="n">Encoded</span><span class="p">),</span>
            <span class="s">"crc_ok"</span><span class="p">,</span> <span class="n">res</span><span class="o">.</span><span class="n">CRCOK</span><span class="p">,</span> <span class="s">"reliable"</span><span class="p">,</span> <span class="n">res</span><span class="o">.</span><span class="n">Reliable</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">d</span><span class="o">.</span><span class="n">publishTalkerAlias</span><span class="p">(</span><span class="n">res</span><span class="o">.</span><span class="n">SourceID</span><span class="p">,</span> <span class="n">res</span><span class="o">.</span><span class="n">Alias</span><span class="p">,</span> <span class="n">res</span><span class="o">.</span><span class="n">Reliable</span><span class="p">)</span> <span class="c">// empty until the cipher is cracked</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That log line is the whole point. The reassembled ciphertext — the encoded alias
bytes paired with the known source RID and talkgroup — is exactly the record the
cipher cryptanalysis needs, and the repo’s chosen-plaintext capture procedure
names GopherTrunk as a valid capture receiver. Surfacing it here means an operator
can harvest the <code class="language-plaintext highlighter-rouge">rid,talkgroup,encoded_hex,alias</code> corpus from live air with
GopherTrunk alone, instead of falling back to SDRTrunk. This is the same emitter
the <a href="/blog/deep-dives/protocol-decoders-01-anatomy-of-a-cc-decoder/">Protocol Decoders</a>
series planted as its central mystery — the field that reassembles cleanly and
decodes to noise — and the same one the Crypto Lab series calls <em>Mercury</em>. The
hunt doesn’t crack it; it feeds the machine that will.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>We’ve now pulled everything the air will give us: a mapped, named system, activity
counts, whatever aliases and ciphertext its traffic carried. The find is complete.
<a href="/blog/deep-dives/the-hunt-13-exporting-your-finds/">Part 13</a>
is about getting it <em>out</em> — exporting the <code class="language-plaintext highlighter-rouge">DiscoveredSystem</code> to a RadioReference
submission package, a TrunkRecorder config, GopherTrunk’s own import bundle, and a
SigMF-tagged capture bundle — so the work leaves your machine.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why not just decode aliases inside the voice recorder?</strong>
Because on a busy system most grants never get a voice tuner, and encrypted calls
— the interesting ones — are torn down before the hangtime alias burst. A
voice-only decode misses the aliases you most want. A signalling follow decodes
the MAC stream without recording, so it runs on grants the voice pool never
covers.</p>

<p><strong>Does harvesting aliases need extra hardware?</strong>
It needs a signalling tap, which is a DDC tap on an existing wideband IQ broker —
the same virtual-tuner mechanism the wideband engine uses — not a dedicated
radio. Two SDRs can run a whole system’s control-plus-signalling harvest, which is
how SDRTrunk does it too.</p>

<p><strong>Why gate the source RID on RS parity?</strong>
A mis-framed Phase 2 superframe decodes random bytes, and a stray opcode match
would inject a wrong-but-plausible RID that’s indistinguishable downstream from a
real one. Only the outer RS parity separates a genuine PDU from garbage, so only
an RS-verified PDU sets a call’s source. A wrong RID is worse than none.</p>

<p><strong>Why does a Motorola alias come out empty?</strong>
Its per-byte cipher is unverified (<code class="language-plaintext highlighter-rouge">motorola.CipherVerified</code> is false), so the
decode can’t be trusted to produce a real name. Rather than emit a wrong one, the
follower publishes an empty alias and logs the reassembled <strong>ciphertext</strong> — the
corpus the cryptanalysis needs. It’s the <em>Mercury</em> cipher from the decoder and
crypto series.</p>

<p><strong>What stops two decoders fighting over one frequency?</strong>
The manager tracks active follows and voice-followed frequencies. A grant already
being followed is ignored; a grant on a voice-followed frequency is skipped; and
<code class="language-plaintext highlighter-rouge">onVoiceStart</code> cancels a signalling follow the moment a voice chain takes the same
channel, because the voice chain harvests aliases itself.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 12 of 14</strong> · ←
<a href="/blog/deep-dives/the-hunt-11-naming-the-unknown/">Part 11: Naming the Unknown</a>
· Next →
<a href="/blog/deep-dives/the-hunt-13-exporting-your-finds/">Part 13: Exporting Your Finds — RadioReference, TrunkRecorder, SigMF</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="the-hunt" /><category term="p25" /><category term="alias" /><category term="signalling" /><category term="crypto" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk harvests P25 Phase 2 talker aliases off a traffic channel's signalling stream without recording voice — a signalling-only DDC tap per in-window grant, the shared MAC dispatcher the voice composer also uses, RS-parity gating, and harvesting the Motorola alias ciphertext for cryptanalysis.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From the Issue Tracker, Part 2: The Talker-Alias Hunt — Three Wrong Transports and an Architectural Gate</title><link href="https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-02-talker-alias-hunt/" rel="alternate" type="text/html" title="From the Issue Tracker, Part 2: The Talker-Alias Hunt — Three Wrong Transports and an Architectural Gate" /><published>2026-08-14T00:00:00-05:00</published><updated>2026-08-14T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-02-talker-alias-hunt</id><content type="html" xml:base="https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-02-talker-alias-hunt/"><![CDATA[<p><em>Part 2 of <strong>From the Issue Tracker</strong>, postmortems of GopherTrunk bugs that fought
back. <a href="/blog/solution-postmortem/from-the-issue-tracker-01-first-p25-lock/">Part 1</a>
ended with the first control-channel lock and a warning about self-consistent tests.
This part picks up two days later, on the same system, with a subtler kind of bug:
everything decoding, nothing wrong in any log — and a field the reporter could see
in another program, blank in ours, for a month
(<a href="https://github.com/MattCheramie/GopherTrunk/issues/376">#376</a>).</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> Radio IDs populated fine, but the talker alias stayed blank for every
radio — while SDRTrunk showed aliases on the same system, even on encrypted calls.
Three successive transport theories (standard voice LCOs, a control-channel vendor
TSBK, a speculative Phase 2 opcode) each shipped a decoder for a message format
that doesn’t exist on air. A per-(opcode, MFID) census with raw payload dumps
proved the alias was <em>not</em> on the control channel; SDRTrunk’s decode logs then
pinned the two real transports. The final root cause was architectural: alias
decode lived inside the voice composer, so it only ran when a call won a voice
tuner — which, on a busy system, almost never happens. The fix was a
signalling-only follower that decodes traffic-channel signalling without
following the call as voice.</p>
</blockquote>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Fact</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Issue</td>
      <td><a href="https://github.com/MattCheramie/GopherTrunk/issues/376">#376</a> — 31 comments, a month of theories</td>
    </tr>
    <tr>
      <td>Symptom</td>
      <td>RIDs populate; <code class="language-plaintext highlighter-rouge">talker_alias</code> blank for every radio, while SDRTrunk shows aliases on the same system — even on encrypted calls</td>
    </tr>
    <tr>
      <td>Wrong theories</td>
      <td>Standard voice LCOs 0x15/0x16/0x17 → Motorola voice-LC + a control-channel vendor TSBK → a speculative Phase 2 opcode 0x82</td>
    </tr>
    <tr>
      <td>The diagnostic</td>
      <td>Per-(opcode, MFID) census with capped raw payload dumps, then SDRTrunk decode logs as ground truth</td>
    </tr>
    <tr>
      <td>Real transports</td>
      <td>Phase 1: TDULC terminator (MFID 0x90, LCO 0x15 header + 0x17 blocks); Phase 2: FACCH-S during call hangtime</td>
    </tr>
    <tr>
      <td>Real root cause</td>
      <td>Alias decode lived inside the voice composer — it only ran when a call won a voice tuner</td>
    </tr>
    <tr>
      <td>Fix</td>
      <td>PR #762: <code class="language-plaintext highlighter-rouge">internal/sigfollow</code>, a signalling-only follower on wideband DDC taps, no voice path required</td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The report</strong> — a modest feature request becomes a month-long hunt.</li>
  <li><strong>Three wrong transports</strong> — three shipped decoders for messages that don’t exist on air.</li>
  <li><strong>Detours that paid rent</strong> — the fictional <code class="language-plaintext highlighter-rouge">OpMACPTT</code> and the dead Phase 2 path.</li>
  <li><strong>The diagnostic that cracked it: a census, then the bytes</strong> — instrumenting instead of guessing.</li>
  <li><strong>Ground truth from another decoder</strong> — SDRTrunk’s logs pin both real transports.</li>
  <li><strong>The real root cause was architectural</strong> — metadata welded to media capture.</li>
  <li><strong>The fix: a signalling-only follower</strong> — decoding signalling without following voice.</li>
  <li><strong>What we keep</strong> — the durable rules and their Field Guide entries.</li>
</ul>

<h2 id="the-report">The report</h2>

<p>The issue opened modestly: source RIDs were showing up in grant payloads
(<code class="language-plaintext highlighter-rouge">TG 20202 ← 207545</code>), could radios become first-class entities with alias support?
The entity work shipped quickly — an alias table, <code class="language-plaintext highlighter-rouge">/api/v1/rids</code> endpoints, a
Radio IDs panel. Then the field note that turned a feature request into a
month-long hunt:</p>

<blockquote>
  <p>SDRTrunk currently displays Talker Alias on MMR, including encrypted Ambulance
talkgroups.</p>
</blockquote>

<p>The aliases were on the air. RID tracking confirmed the plumbing worked — one radio
showed 18 observed calls with its last talkgroup — but <code class="language-plaintext highlighter-rouge">talker_alias</code> stayed blank
for every radio, run after run, build after build. Something was transmitting
aliases that GopherTrunk structurally could not see.</p>

<h2 id="three-wrong-transports">Three wrong transports</h2>

<p>The hunt shipped three alias decoders before discovering none of them matched the
air.</p>

<p><strong>Theory 1: the standard voice-channel LCOs.</strong> TIA-102.AABF defines talker-alias
link-control opcodes 0x15/0x16/0x17 on the voice channel. A reassembler for the
standard form landed and produced nothing — the field test showed RIDs populating
and aliases blank. Motorola systems don’t use the standard form.</p>

<p><strong>Theory 2: the Motorola voice-LC form — plus a control-channel working model.</strong> The
next round implemented Motorola’s vendor variant (LCO 0x15 header + 0x17 data
blocks, MFID 0x90) on the LDU1 voice path, alongside a working model that assumed a
plain-ASCII vendor TSBK on the control channel. Zero fragments were ever observed —
including on the clean site where decode quality was excellent, which mattered: a
missing message on a clean channel is evidence about the <em>transport</em>, not the RF.</p>

<p><strong>Theory 3: a speculative Phase 2 opcode.</strong> The Phase 2 MAC path carried an alias
placeholder keyed to opcode 0x82 with plain-ASCII payloads — a guess, and another
decoder for a message nobody transmits.</p>

<p>Each theory was plausible, each shipped with passing tests, and each was falsified
only by field data. The common flaw: all three described what the alias <em>might</em> look
like, with no captured bytes saying what it <em>did</em> look like.</p>

<h2 id="detours-that-paid-rent">Detours that paid rent</h2>

<p>Two side discoveries along the way were real bugs in their own right:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">OpMACPTT</code> was a fiction.</strong> The Phase 2 MAC dispatcher named opcode 0x01 “MAC
PTT” — a constant that exists in no spec. Opcode 0x01 is
<code class="language-plaintext highlighter-rouge">GROUP_VOICE_CHANNEL_USER_ABBREVIATED</code>, the in-call broadcast carrying the source
RID and service options on the traffic channel. Real PDUs were being parsed as
“MAC PTT” and discarded, which is why encrypted-talkgroup grants surfaced as
<code class="language-plaintext highlighter-rouge">src=0 enc=false</code> while SDRTrunk showed both correctly. Fixing it required a
<code class="language-plaintext highlighter-rouge">KindCallSourceUpdate</code> backfill path so metadata arriving mid-call patches the
active call.</li>
  <li><strong>The Phase 2 alias path was dead code on hybrid systems.</strong> MMR runs a Phase 1
control channel with Phase 2 TDMA traffic channels — the most common P25 hybrid
layout. The Phase 1 CC tagged every grant <code class="language-plaintext highlighter-rouge">Protocol="p25"</code>, so the composer never
entered the Phase 2 voice chain at all: none of the newly added MAC dispatch ever
executed. The fix propagates an <code class="language-plaintext highlighter-rouge">AccessTDMA</code> flag from the TDMA identifier update
through the band plan, so TDMA-channel grants publish as <code class="language-plaintext highlighter-rouge">p25-phase2</code> with their
scrambler seed derived from (WACN, SystemID, NAC).</li>
</ul>

<p>Both were found because the reporter grepped for the promised log lines and reported
their <em>absence</em> — a null result stated precisely enough to act on.</p>

<h2 id="the-diagnostic-that-cracked-it-a-census-then-the-bytes">The diagnostic that cracked it: a census, then the bytes</h2>

<p>After three wrong transports, the next step was deliberately not a fourth theory. It
was instrumentation: one Info line per distinct <code class="language-plaintext highlighter-rouge">(opcode, MFID)</code> pair the control
channel emits that GopherTrunk doesn’t dispatch —</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>p25: unhandled tsbk mfid=144 opcode=Opcode(16) nac=353
p25: unhandled tsbk mfid=0 opcode=Opcode(30) nac=353
</code></pre></div></div>

<p>— followed, one round later, by raw payload dumps capped at 8 samples per pair:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>p25: unhandled tsbk payload mfid=144 opcode=0x16 lb=false payload=c23f3040ffff0000 nac=353
</code></pre></div></div>

<p>Two implementation details mattered more than they look. First, the census prints
the <strong>numeric</strong> opcode, because Go’s stringer had been mislabeling vendor opcodes
with standard-namespace names — Motorola opcode 0x15 printed as
<code class="language-plaintext highlighter-rouge">UnitToUnitAnswerRequest</code>, sending everyone reading the logs down the wrong row of
the wrong table. Second, the payloads could be cross-checked against ground truth:
SDRTrunk supplied known RID↔alias pairs (<code class="language-plaintext highlighter-rouge">202438 MBR02 2438</code>, <code class="language-plaintext highlighter-rouge">206806 AGP 6806</code>,
<code class="language-plaintext highlighter-rouge">203530 ZR5 3530</code>), and the Motorola alias format embeds the 24-bit RID — so
<code class="language-plaintext highlighter-rouge">printf '%06x'</code> turned each known radio into a grep pattern (<code class="language-plaintext highlighter-rouge">202438</code> → <code class="language-plaintext highlighter-rouge">031ad6</code>).</p>

<p>The census answered the question definitively, in both directions:</p>

<ul>
  <li><strong>No alias anywhere on the control channel.</strong> No payload contained a known RID or
alias ASCII. A control-channel <code class="language-plaintext highlighter-rouge">mfid=0 opcode=0x15</code> hit turned out to be
<code class="language-plaintext highlighter-rouge">SNDCP_DAT_PAGE_REQ</code> — same number as the alias LCO, unrelated namespace, pure red
herring.</li>
  <li><strong>Two of the unknown opcodes were decodable wins.</strong> Motorola MFID 0x90 opcodes
0x02 and 0x03 decoded as <strong>patch-group voice grants</strong> — e.g. payload
<code class="language-plaintext highlighter-rouge">401001006904e2cc</code> carried the encryption bit, a channel, super-group 105, and
source RID 320204. GopherTrunk had been silently dropping those calls; wiring them
through the grant path (PR #692) fixed <code class="language-plaintext highlighter-rouge">src=0 enc=false</code> on patch calls and made
the super-group traffic followable.</li>
</ul>

<p>A diagnostic built to find one thing found two others and ruled out a theory — the
hallmark of instrumenting instead of guessing.</p>

<h2 id="ground-truth-from-another-decoder">Ground truth from another decoder</h2>

<p>With the control channel exonerated, the reporter went to SDRTrunk’s decode logs on
the same system — roughly 600 alias-bearing traffic-channel messages per hour — and
came back with the actual bytes of both real transports:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAC:361/x169 TDULC MOTOROLA TALKER ALIAS HEADER TG:30583 SEQUENCE:9 BLOCKS TO FOLLOW:7
    FORMAT:1-UNICODE MSG:159077770701009902
TS1 FACCH-S HANGTIME MOTOROLA TALKER ALIAS HEADER TG:20208 RADIO:ISSI 781824.356.200062
    SEQUENCE:0 BLOCKS TO FOLLOW:2 FORMAT:1-UNICODE MSG:9190114EF002010006BEE00164030D7E24CC8
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Phase 1</th>
      <th>Phase 2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Carrier</td>
      <td><strong>TDULC</strong> (terminator link control) — not LDU1</td>
      <td><strong>FACCH-S</strong> during call hangtime — not a TSBK</td>
    </tr>
    <tr>
      <td>Framing</td>
      <td>MFID 0x90, LCO 0x15 header + N× 0x17 blocks</td>
      <td>header bytes <code class="language-plaintext highlighter-rouge">91 90 11 …</code>, data blocks <code class="language-plaintext highlighter-rouge">95 90 11 …</code></td>
    </tr>
    <tr>
      <td>RID binding</td>
      <td>header carries the talkgroup only</td>
      <td>header carries the source RID inline — self-contained</td>
    </tr>
    <tr>
      <td>Payload</td>
      <td>Motorola substitution cipher, UTF-16 BE, CRC-16</td>
      <td>same cipher, framed <code class="language-plaintext highlighter-rouge">WACN(20) · System(12) · RadioID(24) · alias · CRC-16</code></td>
    </tr>
  </tbody>
</table>

<p>Two details explain months of confusion. The Phase 1 alias rides the <em>terminator</em> —
a decoder that only scans LDU1 link control returns before ever seeing it. And the
consistent <code class="language-plaintext highlighter-rouge">BEE00164…</code> prefix in every fragment is not noise: it’s the system’s own
identity (WACN BEE00, SysID 164) leading the encoded frame. The aliases decode on
encrypted calls because the alias link control sits entirely outside the AES voice
payload.</p>

<h2 id="the-real-root-cause-was-architectural">The real root cause was architectural</h2>

<p>Even with the correct transports known, one more test run produced zero aliases —
voice chains started, calls followed, nothing else. The reporter then read the
source and found the actual root cause, and it wasn’t a parser:</p>

<p><strong>Alias decode lived inside the voice composer.</strong> The FACCH-S dispatch ran only when
a grant was followed to a voice tuner and the Phase 2 voice chain spun up. On a busy
multi-site system that gate almost never opens:</p>

<ul>
  <li>most grants get <code class="language-plaintext highlighter-rouge">no voice device available</code> — the voice pool can’t cover the
system’s full frequency spread, so the composer never runs for them;</li>
  <li>encrypted calls are torn down at <code class="language-plaintext highlighter-rouge">reason=encrypted</code> the moment encryption is
detected — <em>before hangtime, which is exactly when the alias is transmitted</em>;</li>
  <li>and even clear, fully followed calls surfaced no FACCH signalling decode.</li>
</ul>

<p>SDRTrunk harvests aliases from the traffic channel’s <em>signalling</em> stream, without
following the call as voice — which is why it collects them across a busy system
with two dongles, encrypted or not. GopherTrunk had welded metadata extraction to
media capture, so the metadata inherited every constraint of recording: tuner
availability, encryption policy, call priority. The blank column was never a decode
bug. It was a dependency graph bug.</p>

<h2 id="the-fix-a-signalling-only-follower">The fix: a signalling-only follower</h2>

<p>PR #762 landed <code class="language-plaintext highlighter-rouge">internal/sigfollow</code>: a follower that subscribes to the event bus
and, for each in-window Phase 2 grant, opens a <strong>signalling-only DDC tap</strong> on the
wideband IQ broker — decoding FACCH-S MAC PDUs and publishing aliases with no voice
tuner, no audio path, and no interaction with the encrypted-call teardown.</p>

<p>The design points that made it stick:</p>

<ul>
  <li><strong>One dispatcher, two callers.</strong> The FACCH-S MAC dispatch (alias reassembly plus
the per-PDU census) became a shared <code class="language-plaintext highlighter-rouge">MACDispatcher</code> used by both the voice
composer and the follower, so the two paths cannot drift apart on what they
decode.</li>
  <li><strong>Opt-in and bounded.</strong> <code class="language-plaintext highlighter-rouge">signalling_taps: N</code> per <code class="language-plaintext highlighter-rouge">role: wideband</code> device (0
disables; 2–4 suits a busy multi-site system); each tap is an independent DDC, so
cost scales linearly and visibly.</li>
  <li><strong>No double work.</strong> Per-frequency dedupe plus a voice-call skip mean a channel
already being recorded isn’t followed twice.</li>
  <li><strong>Fail-safe by default.</strong> The FACCH-S bit packing was still unverified against
captures when the follower shipped, so the decoder publishes nothing unless the
frame yields a coherent radio identity — a wrong offset produces silence, not a
garbled alias bound to a real radio.</li>
</ul>

<p>Decoded aliases bind onto the RID through the existing affiliation tracker, so the
column the issue opened about fills in with no further wiring.</p>

<h2 id="what-we-keep">What we keep</h2>

<ul>
  <li><strong>Never gate metadata extraction behind media capture.</strong> Identity, encryption
state, and aliases ride signalling; if decoding them requires winning a voice
tuner, a busy system guarantees you lose them. The follower pattern and the
encrypted-hangtime interaction are written up in
<a href="/reference/encrypted-call-handling/">encrypted call handling</a>.</li>
  <li><strong>When theories keep missing, stop theorizing and take a census.</strong> One log line
per unhandled <code class="language-plaintext highlighter-rouge">(opcode, MFID)</code> plus capped payload dumps falsified a theory,
found two droppable-call bugs, and named the next step — the pattern is in the
<a href="/reference/diagnostic-playbook/">diagnostic playbook</a>.</li>
  <li><strong>Log numeric opcodes.</strong> A stringer that maps vendor opcodes onto
standard-namespace names is actively misleading; <code class="language-plaintext highlighter-rouge">0x15</code> meant three different
things in this one thread.</li>
  <li><strong>Another decoder’s output is legitimate ground truth.</strong> SDRTrunk’s decode logs
pinned both real transports — TDULC and FACCH-S — after three GopherTrunk
decoders had been written for formats that don’t exist on air. The
system-identity fields that anchored the fragments (<code class="language-plaintext highlighter-rouge">WACN | SysID | RID</code>) are
covered in
<a href="/reference/p25-site-identity-semantics/">P25 site identity semantics</a>.</li>
  <li><strong>A precise null result is a contribution.</strong> “The promised log line did not fire”
found dead code twice. Grep patterns belong in the fix announcement, so the field
tester can report absence as sharply as presence.</li>
</ul>

<h2 id="faq">FAQ</h2>

<p><strong>Why could SDRTrunk see aliases GopherTrunk couldn’t?</strong>
It harvests them from the traffic channel’s signalling stream without following
the call as voice. GopherTrunk’s alias decode was gated behind winning a voice
tuner — a gate that a busy multi-site system keeps closed for most grants, and
that the encrypted-call teardown slams shut before hangtime, which is exactly
when the alias is transmitted.</p>

<p><strong>Why do talker aliases work on encrypted calls at all?</strong>
The alias link control rides outside the AES voice payload. Encryption protects
the speech; the signalling around it — including the Motorola alias header and
data blocks — stays decodable, which is why SDRTrunk showed aliases on encrypted
ambulance talkgroups.</p>

<p><strong>Why did three plausible transport theories all miss?</strong>
All three described what the alias <em>might</em> look like with no captured bytes
saying what it <em>did</em> look like. The standard TIA forms aren’t what Motorola
systems transmit; the vendor form doesn’t ride LDU1 or the control channel. The
Phase 1 alias rides the <em>terminator</em> (TDULC) and the Phase 2 alias rides FACCH-S
during hangtime — neither of which the shipped decoders ever looked at.</p>

<p><strong>What did the census find besides ruling out the control channel?</strong>
Two real bugs. Motorola MFID 0x90 opcodes 0x02/0x03 decoded as patch-group voice
grants that GopherTrunk had been silently dropping, and the payload dumps
exposed that Go’s stringer was mislabeling vendor opcodes with standard-namespace
names — which is why the census prints numeric opcodes.</p>

<p><strong>Does the follower double-decode channels that are already being recorded?</strong>
No. Per-frequency dedupe plus a voice-call skip mean a channel already followed
as voice isn’t tapped twice, and <code class="language-plaintext highlighter-rouge">signalling_taps: N</code> bounds the total cost per
wideband device.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 2 of 22</strong> · ←
<a href="/blog/solution-postmortem/from-the-issue-tracker-01-first-p25-lock/">Part 1: The First P25 Lock — Eleven Fixes Between ‘Trying’ and ‘Locked’</a>
· Next →
<a href="/blog/solution-postmortem/from-the-issue-tracker-03-phase2-encryption-metadata/">Part 3: Encrypted, Says Who — Four Layers Between a Flag and Its Metadata</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="solution-postmortem" /><category term="from-the-issue-tracker" /><category term="p25" /><category term="phase2" /><category term="metadata" /><category term="debugging" /><category term="postmortem" /><summary type="html"><![CDATA[A month of chasing blank P25 talker aliases through three wrong transport theories, until a per-opcode census and SDRTrunk ground truth revealed the aliases were riding traffic-channel signalling — and GopherTrunk's decoder was architecturally gated behind winning a voice tuner.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Operator’s Cockpit, Part 11: The Mobile Shell, Command Palette &amp;amp; PWA</title><link href="https://gophertrunk.org/blog/deep-dives/operator-cockpit-11-mobile-shell-pwa/" rel="alternate" type="text/html" title="The Operator’s Cockpit, Part 11: The Mobile Shell, Command Palette &amp;amp; PWA" /><published>2026-08-14T00:00:00-05:00</published><updated>2026-08-14T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/operator-cockpit-11-mobile-shell-pwa</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/operator-cockpit-11-mobile-shell-pwa/"><![CDATA[<p><em>Part 11 of <strong>The Operator’s Cockpit</strong>, the series on driving one GopherTrunk
daemon through one REST + SSE API from browser and terminal alike. The last four
posts filled the console with panels. This post is about the *frame</em> around them:
the layout that turns the same SPA into a desktop console and a phone app, the
command palette that reaches any panel in two keystrokes, and the PWA packaging
that lets you install the whole thing on your phone and point it at a Raspberry Pi
in the field.*</p>

<blockquote>
  <p><strong>TL;DR:</strong> One <code class="language-plaintext highlighter-rouge">AppShell</code> renders four navigation surfaces — a desktop sidebar,
a mobile top bar, a mobile bottom nav, and a full drawer — all driven by a
single <strong>nav registry</strong>, so every panel is reachable on every device. A global
<strong>⌘K / Ctrl-K command palette</strong> fuzzy-jumps to any panel and is the discovery
safety net for the deep ones. The SPA ships as an <strong>installable PWA</strong>: Vite’s
PWA plugin emits a manifest and a Workbox service worker that precaches the
whole bundle for offline-first loading — with a <strong>navigate-fallback denylist</strong>
so the console’s service worker never shadows the daemon’s sibling apps (Config
Builder, Signal Lab, RF Scope, Crypto Lab).</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>One shell, four nav surfaces.</strong> <code class="language-plaintext highlighter-rouge">AppShell</code> composes a sidebar, top bar, bottom
nav, and drawer; all four read the same registry, so a hidden or new tab appears
consistently everywhere.</li>
  <li><strong>The command palette is the universal shortcut.</strong> ⌘K opens a fuzzy switcher
over every nav item — the fastest path to any panel, and how the deep panels
stay discoverable without cluttering the primary nav.</li>
  <li><strong>The console installs like an app.</strong> Vite-Plugin-PWA emits the manifest and a
Workbox SW that precaches the bundle, so it opens instantly and runs offline
(the connect screen still needs a live daemon).</li>
  <li><strong>The service worker knows its lane.</strong> A navigate-fallback denylist keeps the
main console’s SW from answering <code class="language-plaintext highlighter-rouge">/config/</code>, <code class="language-plaintext highlighter-rouge">/siglab/</code>, <code class="language-plaintext highlighter-rouge">/rfscope/</code>,
<code class="language-plaintext highlighter-rouge">/cryptolab/</code>, or <code class="language-plaintext highlighter-rouge">/api/</code> with its own cached <code class="language-plaintext highlighter-rouge">index.html</code>.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>App shell</td>
      <td>composes all nav surfaces around the routed page</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/components/shell/AppShell.tsx</code></td>
    </tr>
    <tr>
      <td>Bottom nav</td>
      <td>mobile primary tabs + “More”</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/components/shell/BottomNav.tsx</code></td>
    </tr>
    <tr>
      <td>Mobile drawer</td>
      <td>full nav sheet behind “More”</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/components/shell/MobileDrawer.tsx</code></td>
    </tr>
    <tr>
      <td>Top bar</td>
      <td>title, live pill, palette trigger, hamburger</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/components/shell/TopBar.tsx</code></td>
    </tr>
    <tr>
      <td>Command palette</td>
      <td>⌘K fuzzy quick-switcher</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/components/CommandPalette.tsx</code></td>
    </tr>
    <tr>
      <td>PWA manifest + SW</td>
      <td>installable, offline-first packaging</td>
      <td><code class="language-plaintext highlighter-rouge">web/vite.config.ts</code> (<code class="language-plaintext highlighter-rouge">VitePWA</code>)</td>
    </tr>
    <tr>
      <td>SW denylist</td>
      <td>don’t shadow sibling consoles</td>
      <td><code class="language-plaintext highlighter-rouge">web/src/lib/swDenylist.ts</code></td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>One shell, four nav surfaces</strong> — desktop and phone from one layout.</li>
  <li><strong>The registry as the single source</strong> — why nav is data, not markup.</li>
  <li><strong>The command palette</strong> — ⌘K, fuzzy ranking, keyboard-first.</li>
  <li><strong>Installing as a PWA</strong> — manifest, precache, offline-first.</li>
  <li><strong>The denylist</strong> — how the service worker avoids shadowing siblings.</li>
</ul>

<h2 id="one-shell-four-nav-surfaces">One shell, four nav surfaces</h2>

<p><code class="language-plaintext highlighter-rouge">AppShell</code> is pure chrome. It owns no connection or data state — it composes the
navigation surfaces around whatever page the router hands it as <code class="language-plaintext highlighter-rouge">children</code>, and
manages exactly three bits of local UI state: the drawer, the palette, and the
sidebar’s collapsed flag.</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// web/src/components/shell/AppShell.tsx (shape)</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nx">AppShell</span><span class="p">({</span> <span class="nx">children</span> <span class="p">}:</span> <span class="nx">Props</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">drawerOpen</span><span class="p">,</span> <span class="nx">setDrawerOpen</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">paletteOpen</span><span class="p">,</span> <span class="nx">setPaletteOpen</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">isDesktop</span> <span class="o">=</span> <span class="nx">useIsDesktop</span><span class="p">();</span>

  <span class="c1">// Global ⌘K / Ctrl-K toggles the palette from anywhere.</span>
  <span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="cm">/* window keydown → setPaletteOpen(o =&gt; !o) */</span> <span class="p">},</span> <span class="p">[]);</span>
  <span class="c1">// The drawer is mobile-only; close it if the viewport grows to desktop.</span>
  <span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nx">isDesktop</span> <span class="o">&amp;&amp;</span> <span class="nx">drawerOpen</span><span class="p">)</span> <span class="nx">setDrawerOpen</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span> <span class="p">},</span> <span class="p">[</span><span class="nx">isDesktop</span><span class="p">,</span> <span class="nx">drawerOpen</span><span class="p">]);</span>

  <span class="k">return</span> <span class="p">(</span>
    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="p">=</span><span class="s">"min-h-full flex"</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nc">Sidebar</span> <span class="na">collapsed</span><span class="p">=</span><span class="si">{</span><span class="nx">collapsed</span><span class="si">}</span> <span class="na">onToggleCollapse</span><span class="p">=</span><span class="si">{</span><span class="nx">toggleCollapse</span><span class="si">}</span> <span class="p">/&gt;</span> <span class="si">{</span><span class="cm">/* desktop */</span><span class="si">}</span>
      <span class="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="p">=</span><span class="s">"flex-1 flex flex-col min-w-0"</span><span class="p">&gt;</span>
        <span class="p">&lt;</span><span class="nc">TopBar</span> <span class="na">onOpenMore</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">setDrawerOpen</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span><span class="si">}</span> <span class="na">onOpenPalette</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">setPaletteOpen</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span><span class="si">}</span> <span class="p">/&gt;</span>
        <span class="p">&lt;</span><span class="nt">main</span> <span class="na">id</span><span class="p">=</span><span class="s">"main"</span> <span class="na">className</span><span class="p">=</span><span class="s">"flex-1 p-3 sm:p-4 pb-24 sm:pb-4"</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">children</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">main</span><span class="p">&gt;</span>
      <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nc">BottomNav</span> <span class="na">onOpenMore</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">setDrawerOpen</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span><span class="si">}</span> <span class="p">/&gt;</span>       <span class="si">{</span><span class="cm">/* mobile */</span><span class="si">}</span>
      <span class="p">&lt;</span><span class="nc">MobileDrawer</span> <span class="na">open</span><span class="p">=</span><span class="si">{</span><span class="nx">drawerOpen</span><span class="si">}</span> <span class="na">onClose</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">setDrawerOpen</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span><span class="si">}</span> <span class="p">/&gt;</span>
      <span class="p">&lt;</span><span class="nc">CommandPalette</span> <span class="na">open</span><span class="p">=</span><span class="si">{</span><span class="nx">paletteOpen</span><span class="si">}</span> <span class="na">onClose</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">setPaletteOpen</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span><span class="si">}</span> <span class="p">/&gt;</span>
      <span class="p">&lt;</span><span class="nc">ToastViewport</span> <span class="p">/&gt;</span>
    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
  <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The responsiveness is entirely Tailwind breakpoints, not JS branching: the
<code class="language-plaintext highlighter-rouge">Sidebar</code> is hidden below <code class="language-plaintext highlighter-rouge">sm</code>, the <code class="language-plaintext highlighter-rouge">BottomNav</code> and <code class="language-plaintext highlighter-rouge">TopBar</code> hamburger are hidden
at <code class="language-plaintext highlighter-rouge">sm</code> and up. The same DOM serves both; CSS decides which surface shows. That’s
why a phone rotating to landscape, or a browser window growing, just works — and
why the drawer-close effect exists at all, to tidy up the one case CSS can’t (a
mobile-only overlay left open when the viewport crosses into desktop).</p>

<p>Note the accessibility bones baked into the frame: a “Skip to content” link that
appears on focus, a semantic <code class="language-plaintext highlighter-rouge">&lt;main id="main"&gt;</code>, and <code class="language-plaintext highlighter-rouge">pb-24</code> bottom padding so the
fixed bottom nav never covers the last row of a scrolling panel. The shell is
small, but it carries the whole app’s keyboard and screen-reader story.</p>

<h2 id="the-registry-as-the-single-source">The registry as the single source</h2>

<p>None of these four surfaces hard-code a list of panels. They all read a shared
<strong>nav registry</strong>, and each surface asks it a slightly different question. The
bottom nav takes the primary items and filters hidden ones:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// web/src/components/shell/BottomNav.tsx (shape)</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nx">BottomNav</span><span class="p">({</span> <span class="nx">onOpenMore</span> <span class="p">}:</span> <span class="nx">Props</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">hidden</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="p">(</span><span class="nx">useShared</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">hiddenTabs</span><span class="p">));</span>
  <span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="nx">PRIMARY_ITEMS</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">i</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">hidden</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">tabKey</span><span class="p">(</span><span class="nx">i</span><span class="p">)));</span>
  <span class="c1">// …render four primaries + a fifth "More" button that opens the drawer</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The drawer asks for the <em>grouped</em> view (<code class="language-plaintext highlighter-rouge">useVisibleNavGroups</code>), the palette asks
for the <em>flat</em> visible list (<code class="language-plaintext highlighter-rouge">useVisibleNavItems</code>), and the top bar looks up the
current route’s label in <code class="language-plaintext highlighter-rouge">NAV_ITEMS</code>. One data source, four projections. The
payoff is the same one the whole series keeps hitting: a tab hidden via config
(<code class="language-plaintext highlighter-rouge">web.tabs</code>) disappears from <em>every</em> surface at once, and a new panel is a new
registry entry, not four edits. It’s the exact discipline the TUI applies to its
own tab strip — the same <code class="language-plaintext highlighter-rouge">web.tabs</code> keys hide a panel in both UIs, a thread we
pick up in Part 12.</p>

<figure class="lab-figure">
<svg viewBox="0 0 660 200" width="660" height="200" role="img" aria-label="One nav registry feeds four navigation surfaces. The registry is the single source; the desktop sidebar reads the grouped view, the mobile bottom nav reads the primary items, the mobile drawer reads the grouped view, and the command palette reads the flat visible list. A tab hidden by config disappears from all four at once.">
  <rect x="250" y="16" width="160" height="42" rx="6" fill="none" stroke="var(--accent)" />
  <text x="330" y="34" text-anchor="middle" fill="var(--accent)" font-size="11">nav registry</text>
  <text x="330" y="49" text-anchor="middle" fill="var(--fg-muted)" font-size="8">single source of nav</text>
  <line x1="290" y1="58" x2="120" y2="112" stroke="currentColor" /><polygon points="120,108 110,112 122,116" fill="currentColor" />
  <line x1="315" y1="58" x2="270" y2="112" stroke="currentColor" /><polygon points="270,108 262,114 274,116" fill="currentColor" />
  <line x1="345" y1="58" x2="400" y2="112" stroke="currentColor" /><polygon points="398,108 406,114 394,116" fill="currentColor" />
  <line x1="370" y1="58" x2="540" y2="112" stroke="currentColor" /><polygon points="538,108 550,116 534,116" fill="currentColor" />
  <rect x="42" y="114" width="140" height="40" rx="6" fill="none" stroke="currentColor" />
  <text x="112" y="131" text-anchor="middle" fill="currentColor" font-size="9">desktop sidebar</text>
  <text x="112" y="145" text-anchor="middle" fill="var(--fg-muted)" font-size="8">grouped view</text>
  <rect x="196" y="114" width="140" height="40" rx="6" fill="none" stroke="currentColor" />
  <text x="266" y="131" text-anchor="middle" fill="currentColor" font-size="9">bottom nav</text>
  <text x="266" y="145" text-anchor="middle" fill="var(--fg-muted)" font-size="8">primaries + More</text>
  <rect x="350" y="114" width="140" height="40" rx="6" fill="none" stroke="currentColor" />
  <text x="420" y="131" text-anchor="middle" fill="currentColor" font-size="9">mobile drawer</text>
  <text x="420" y="145" text-anchor="middle" fill="var(--fg-muted)" font-size="8">grouped view</text>
  <rect x="504" y="114" width="140" height="40" rx="6" fill="none" stroke="var(--accent)" />
  <text x="574" y="131" text-anchor="middle" fill="var(--accent)" font-size="9">command palette</text>
  <text x="574" y="145" text-anchor="middle" fill="var(--fg-muted)" font-size="8">flat visible list</text>
  <text x="330" y="184" text-anchor="middle" fill="var(--fg-muted)" font-size="10">hide a tab via web.tabs → it disappears from all four surfaces at once</text>
</svg>
<figcaption>Navigation is data, not markup. Four surfaces are four projections of one registry, so a hidden or new tab is consistent everywhere.</figcaption>
</figure>

<h2 id="the-command-palette">The command palette</h2>

<p>The palette is the fastest path to any panel and the discovery net for the deep
ones — the panels that don’t earn a slot in the primary nav still turn up the
moment you type. It’s a ⌘K-triggered dialog over the flat visible nav list, with
arrow keys to move, Enter to activate, Escape to close, and a focus trap so the
keyboard stays inside it:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// web/src/components/CommandPalette.tsx (shape)</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nx">CommandPalette</span><span class="p">({</span> <span class="nx">open</span><span class="p">,</span> <span class="nx">onClose</span> <span class="p">}:</span> <span class="nx">Props</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="nx">useVisibleNavItems</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">useMemo</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">filterItems</span><span class="p">(</span><span class="nx">items</span><span class="p">,</span> <span class="nx">query</span><span class="p">),</span> <span class="p">[</span><span class="nx">items</span><span class="p">,</span> <span class="nx">query</span><span class="p">]);</span>
  <span class="c1">// …ArrowUp/Down move `active`; Enter → choose(results[active]); Esc closes</span>
  <span class="kd">function</span> <span class="nx">choose</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">onClose</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">item</span><span class="p">.</span><span class="nx">external</span><span class="p">)</span> <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">item</span><span class="p">.</span><span class="nx">to</span><span class="p">,</span> <span class="dl">"</span><span class="s2">_blank</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">noopener</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">else</span> <span class="nx">navigate</span><span class="p">(</span><span class="nx">item</span><span class="p">.</span><span class="nx">to</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// filterItems ranks: label startsWith (3) &gt; label includes (2) &gt; keyword hit (1).</span>
<span class="kd">function</span> <span class="nx">filterItems</span><span class="p">(</span><span class="nx">items</span><span class="p">,</span> <span class="nx">query</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// …empty query returns everything in registry order; else score + sort desc</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The ranking is intentionally simple and predictable: a label that <em>starts with</em>
your query beats one that merely <em>contains</em> it, which beats a keyword match. Empty
query lists everything in registry order, so ⌘K with no typing is a full index.
External items (the sibling consoles) open in a new tab; internal ones navigate in
place. It’s a small component, but it’s the single control that makes a
twenty-something-panel console feel small — you never hunt through nav, you type
two letters.</p>

<h2 id="installing-as-a-pwa">Installing as a PWA</h2>

<p>The console is a static bundle with no runtime Node dependency and no CDN fetches,
which is exactly what makes it installable. Vite-Plugin-PWA emits the manifest and
a Workbox service worker at build time:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// web/vite.config.ts (shape)</span>
<span class="nx">VitePWA</span><span class="p">({</span>
  <span class="na">registerType</span><span class="p">:</span> <span class="dl">"</span><span class="s2">autoUpdate</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">manifest</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">GopherTrunk</span><span class="dl">"</span><span class="p">,</span> <span class="na">short_name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">GopherTrunk</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">display</span><span class="p">:</span> <span class="dl">"</span><span class="s2">standalone</span><span class="dl">"</span><span class="p">,</span> <span class="na">start_url</span><span class="p">:</span> <span class="dl">"</span><span class="s2">./</span><span class="dl">"</span><span class="p">,</span> <span class="na">scope</span><span class="p">:</span> <span class="dl">"</span><span class="s2">./</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">theme_color</span><span class="p">:</span> <span class="dl">"</span><span class="s2">#0f172a</span><span class="dl">"</span><span class="p">,</span> <span class="na">background_color</span><span class="p">:</span> <span class="dl">"</span><span class="s2">#0f172a</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">icons</span><span class="p">:</span> <span class="p">[</span> <span class="cm">/* favicon.svg, purpose any + maskable */</span> <span class="p">],</span>
  <span class="p">},</span>
  <span class="na">workbox</span><span class="p">:</span> <span class="p">{</span>
    <span class="c1">// Precache the whole SPA so it installs offline-first and loads instantly.</span>
    <span class="na">globPatterns</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">**/*.{js,css,html,svg,png,ico,webmanifest}</span><span class="dl">"</span><span class="p">],</span>
    <span class="na">navigateFallbackDenylist</span><span class="p">:</span> <span class="nx">SW_NAVIGATE_FALLBACK_DENYLIST</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">display: "standalone"</code> and the relative <code class="language-plaintext highlighter-rouge">start_url</code>/<code class="language-plaintext highlighter-rouge">scope</code> are what let a phone
“Add to Home Screen” the console and launch it chromeless, pointed at whatever
daemon it was installed from. The Workbox <code class="language-plaintext highlighter-rouge">globPatterns</code> precache the entire
bundle, so the UI itself loads instantly and works without a network round-trip
for its assets — the connect screen still needs a live daemon to do anything
useful, but the <em>app</em> is always there. The base path defaults to <code class="language-plaintext highlighter-rouge">./</code>, so the
same <code class="language-plaintext highlighter-rouge">dist/</code> works opened via <code class="language-plaintext highlighter-rouge">file://</code>, hosted at a static path, or rooted at
<code class="language-plaintext highlighter-rouge">/</code>. One build, every deployment shape.</p>

<h3 id="how-that-principle-shaped-the-front-end">How that principle shaped the front-end</h3>

<ul>
  <li><strong>Everything is inlined at build.</strong> No CDN scripts, no external fonts — the
precache list is meaningful because the whole app is genuinely self-contained.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">autoUpdate</code> keeps installs fresh.</strong> A new daemon build ships a new SW that
updates the installed app on next load, so a field phone doesn’t drift.</li>
  <li><strong>API responses are never cached.</strong> The SW precaches assets only; <code class="language-plaintext highlighter-rouge">/api/*</code> and
<code class="language-plaintext highlighter-rouge">/metrics</code> are always fetched live, so the console never shows stale daemon
state from a cache.</li>
</ul>

<h2 id="the-denylist-knowing-its-lane">The denylist: knowing its lane</h2>

<p>There’s a sharp edge to a service worker scoped at <code class="language-plaintext highlighter-rouge">/</code>: by default its
<code class="language-plaintext highlighter-rouge">navigateFallback</code> answers <em>any</em> navigation with the cached <code class="language-plaintext highlighter-rouge">index.html</code>. But the
daemon mounts sibling consoles at subpaths — Config Builder at <code class="language-plaintext highlighter-rouge">/config/</code>, Signal
Lab at <code class="language-plaintext highlighter-rouge">/siglab/</code>, RF Scope at <code class="language-plaintext highlighter-rouge">/rfscope/</code>, Crypto Lab at <code class="language-plaintext highlighter-rouge">/cryptolab/</code>. Without a
guard, opening one of those in a new tab would get the <em>main</em> console’s
<code class="language-plaintext highlighter-rouge">index.html</code>, and the tab would boot at a route React Router doesn’t know — a
blank page. The denylist is the fix:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// web/src/lib/swDenylist.ts (shape)</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">SW_NAVIGATE_FALLBACK_DENYLIST</span><span class="p">:</span> <span class="nb">RegExp</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="sr">/^</span><span class="se">\/</span><span class="sr">api</span><span class="se">\/</span><span class="sr">/</span><span class="p">,</span> <span class="sr">/^</span><span class="se">\/</span><span class="sr">metrics/</span><span class="p">,</span>
  <span class="sr">/^</span><span class="se">\/</span><span class="sr">config</span><span class="se">\/</span><span class="sr">/</span><span class="p">,</span> <span class="sr">/^</span><span class="se">\/</span><span class="sr">siglab</span><span class="se">\/</span><span class="sr">/</span><span class="p">,</span> <span class="sr">/^</span><span class="se">\/</span><span class="sr">rfscope</span><span class="se">\/</span><span class="sr">/</span><span class="p">,</span> <span class="sr">/^</span><span class="se">\/</span><span class="sr">cryptolab</span><span class="se">\/</span><span class="sr">/</span><span class="p">,</span>
<span class="p">];</span>
</code></pre></div></div>

<p>Any navigation matching one of these is <em>not</em> served the main SPA’s fallback — it
goes to the network, so the sibling app loads. This list is kept in sync with the
external console links in the nav registry, and a unit test (<code class="language-plaintext highlighter-rouge">swDenylist.test.ts</code>)
enforces that sync so a new sibling console can’t be added without teaching the
service worker to stay out of its way. It’s a tiny file that encodes an important
boundary: one console’s offline cache must never eat another console’s front door.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p><a href="/blog/deep-dives/operator-cockpit-12-the-tui-cockpit/">Part 12</a>
leaves the browser entirely for the terminal — the Bubbletea TUI that drives the
<em>same</em> REST + SSE API from an SSH session. It’s the other half of the series’
through-line made concrete: an Elm-model reducer, a polling fan plus an SSE pump,
and a tab strip that hides the same panels the web <code class="language-plaintext highlighter-rouge">web.tabs</code> config hides here.</p>

<h2 id="faq">FAQ</h2>

<p><strong>How does the same UI serve a desktop and a phone?</strong>
<code class="language-plaintext highlighter-rouge">AppShell</code> renders all four navigation surfaces into one DOM; Tailwind breakpoints
show the sidebar on desktop and the top bar + bottom nav + drawer on mobile. There
is almost no JS branching — CSS decides which surface is visible, so resizing or
rotating just works.</p>

<p><strong>What powers the command palette?</strong>
The shared nav registry. ⌘K opens a fuzzy quick-switcher over every visible nav
item, ranked by how the query matches the label (startsWith &gt; includes &gt; keyword).
It’s the fastest route to any panel and how the deep panels stay discoverable.</p>

<p><strong>Does the PWA work offline?</strong>
The app <em>shell</em> does — Workbox precaches the whole bundle, so the console loads
instantly and runs without a network round-trip for its assets. But it’s an
operator console for a live daemon: the connect screen still needs a reachable
daemon to show real data. API responses are never cached.</p>

<p><strong>Why is there a service-worker denylist?</strong>
Because the console’s SW is scoped at <code class="language-plaintext highlighter-rouge">/</code>, and the daemon mounts sibling apps at
<code class="language-plaintext highlighter-rouge">/config/</code>, <code class="language-plaintext highlighter-rouge">/siglab/</code>, <code class="language-plaintext highlighter-rouge">/rfscope/</code>, <code class="language-plaintext highlighter-rouge">/cryptolab/</code>. The denylist stops the SW from
answering those navigations (and <code class="language-plaintext highlighter-rouge">/api/</code>, <code class="language-plaintext highlighter-rouge">/metrics</code>) with its own cached
<code class="language-plaintext highlighter-rouge">index.html</code>, which would otherwise open those tabs blank.</p>

<p><strong>Can I install it pointed at any daemon?</strong>
Yes — <code class="language-plaintext highlighter-rouge">start_url</code>/<code class="language-plaintext highlighter-rouge">scope</code> are relative (<code class="language-plaintext highlighter-rouge">./</code>), so installing from a given daemon’s
web console launches the app pointed back at that daemon. The bundle is fully
self-contained, so it also works served from any static path or even <code class="language-plaintext highlighter-rouge">file://</code>.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 11 of 14</strong> · ←
<a href="/blog/deep-dives/operator-cockpit-10-write-mode/">Part 10: Write Mode — Mutating Config Safely</a>
· Next →
<a href="/blog/deep-dives/operator-cockpit-12-the-tui-cockpit/">Part 12: The TUI Cockpit — The Same API in Your Terminal</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="operator-cockpit" /><category term="pwa" /><category term="react" /><category term="responsive" /><category term="service-worker" /><category term="go" /><summary type="html"><![CDATA[How one React shell serves a desktop sidebar and a phone's bottom nav from a single nav registry, adds a ⌘K command palette as the fastest path to any panel, and installs as an offline-first PWA on a phone — with a service-worker denylist so the console never shadows its sibling apps.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Recording, Composition &amp;amp; Streaming, Part 14: The Aggregator Backends — Broadcastify, RdioScanner, OpenMHz, Icecast &amp;amp; Webhook</title><link href="https://gophertrunk.org/blog/deep-dives/recording-streaming-14-aggregator-backends/" rel="alternate" type="text/html" title="Recording, Composition &amp;amp; Streaming, Part 14: The Aggregator Backends — Broadcastify, RdioScanner, OpenMHz, Icecast &amp;amp; Webhook" /><published>2026-08-14T00:00:00-05:00</published><updated>2026-08-14T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/recording-streaming-14-aggregator-backends</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/recording-streaming-14-aggregator-backends/"><![CDATA[<p><em>Part 14 — the finale — of <strong>Recording, Composition &amp; Streaming</strong>. Thirteen posts
ago our 3 p.m. dispatch on talkgroup 101 was raw IQ; twelve posts ago it was PCM;
last post the broadcast Manager fanned it out to a set of opaque <code class="language-plaintext highlighter-rouge">Backend</code>s. This
post opens each box — the actual HTTP and TCP conversations that carry a call to
Broadcastify, RdioScanner, OpenMHz, an Icecast server, and a generic webhook —
and then closes the series with the thing that makes all of it trustworthy: a
pure-Go, zero-CGO test suite that stands the whole path up in memory.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> Five backends, five wire protocols behind one <code class="language-plaintext highlighter-rouge">Send(ctx, *Call)
error</code> interface. <strong>Broadcastify</strong> is a two-step handshake: POST metadata, get a
one-time upload URL, PUT the MP3 to it. <strong>RdioScanner</strong> and <strong>OpenMHz</strong> are
single multipart/form-data POSTs built by a shared <code class="language-plaintext highlighter-rouge">buildMultipart</code> helper.
<strong>Icecast</strong> is different in kind — a persistent, paced source socket topped up
with pre-encoded silence so it never starves. The <strong>webhook</strong> is a stable JSON
schema, audio opt-in. All five are verified end to end with <code class="language-plaintext highlighter-rouge">httptest</code> servers,
in-memory WAVs, and a temp SQLite call log — no CGO, no network, no fixtures.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>Broadcastify uploads in two steps</strong> because the audio never touches the API
host: the metadata POST returns a one-time URL (often on object storage) that
the MP3 is <code class="language-plaintext highlighter-rouge">PUT</code> to directly.</li>
  <li><strong>RdioScanner and OpenMHz share the multipart machinery</strong> but not the field
names — <code class="language-plaintext highlighter-rouge">buildMultipart</code> writes the body, each backend supplies its own field
set (RdioScanner’s <code class="language-plaintext highlighter-rouge">key</code>/<code class="language-plaintext highlighter-rouge">system</code>/<code class="language-plaintext highlighter-rouge">dateTime</code>, OpenMHz’s <code class="language-plaintext highlighter-rouge">source_list</code>/<code class="language-plaintext highlighter-rouge">patch_list</code>).</li>
  <li><strong>Icecast is a live stream, not an upload.</strong> A background goroutine holds a
source connection open and a ticker writes paced chunks; between calls it cycles
one second of pre-encoded silence so the server never times the source out.</li>
  <li><strong>The whole system is pure Go.</strong> MP3 encode, WAV I/O, and SQLite are all
CGO-free, so the tests run the real encode/decode/persist path in memory against
<code class="language-plaintext highlighter-rouge">httptest</code> backends — the strongest possible evidence the wire formats are right.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Backend</th>
      <th>Wire shape</th>
      <th>Key functions / fields</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Broadcastify</td>
      <td>POST metadata → one-time URL → PUT audio</td>
      <td><code class="language-plaintext highlighter-rouge">requestUploadURL</code> · <code class="language-plaintext highlighter-rouge">parseBroadcastifyUploadURL</code> · <code class="language-plaintext highlighter-rouge">putAudio</code></td>
    </tr>
    <tr>
      <td>RdioScanner</td>
      <td>one multipart POST to <code class="language-plaintext highlighter-rouge">/api/call-upload</code></td>
      <td><code class="language-plaintext highlighter-rouge">buildMultipart</code> · <code class="language-plaintext highlighter-rouge">key</code>/<code class="language-plaintext highlighter-rouge">system</code>/<code class="language-plaintext highlighter-rouge">dateTime</code> fields</td>
    </tr>
    <tr>
      <td>OpenMHz</td>
      <td>one multipart POST to <code class="language-plaintext highlighter-rouge">/&lt;shortName&gt;/upload</code></td>
      <td><code class="language-plaintext highlighter-rouge">source_list</code> · <code class="language-plaintext highlighter-rouge">patch_list</code> JSON parts</td>
    </tr>
    <tr>
      <td>Icecast</td>
      <td>persistent paced source socket</td>
      <td><code class="language-plaintext highlighter-rouge">runStream</code> · <code class="language-plaintext highlighter-rouge">handshake</code> · <code class="language-plaintext highlighter-rouge">takeChunk</code> (silence)</td>
    </tr>
    <tr>
      <td>Webhook</td>
      <td>one <code class="language-plaintext highlighter-rouge">application/json</code> POST</td>
      <td><code class="language-plaintext highlighter-rouge">webhookPayload</code> · <code class="language-plaintext highlighter-rouge">callType</code> · <code class="language-plaintext highlighter-rouge">rfc3339In</code> · <code class="language-plaintext highlighter-rouge">IncludeAudio</code></td>
    </tr>
    <tr>
      <td>Shared</td>
      <td>multipart body + filenames + timestamps</td>
      <td><code class="language-plaintext highlighter-rouge">buildMultipart</code> · <code class="language-plaintext highlighter-rouge">audioFilename</code> · <code class="language-plaintext highlighter-rouge">rfc3339In</code></td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>Broadcastify</strong> — why one upload is two HTTP round trips.</li>
  <li><strong>RdioScanner &amp; OpenMHz</strong> — one multipart helper, two field vocabularies.</li>
  <li><strong>Icecast</strong> — a paced source connection kept alive with cycled silence.</li>
  <li><strong>The webhook</strong> — a stable JSON schema and a call-type classifier.</li>
  <li><strong>The pure-Go coda</strong> — how zero-CGO makes the whole path testable in memory.</li>
</ul>

<h2 id="broadcastify-two-round-trips-for-one-upload">Broadcastify: two round trips for one upload</h2>

<p>Every other audio backend POSTs the MP3 in a single request. Broadcastify Calls
splits the upload in two, and the reason is architectural: the API host answers
metadata, but the <em>audio</em> goes somewhere else — typically object storage behind a
signed, one-time URL. So <code class="language-plaintext highlighter-rouge">Send</code> does a metadata POST first, reads back a URL, and
PUTs the audio to it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/broadcastify.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">b</span> <span class="o">*</span><span class="n">broadcastifyBackend</span><span class="p">)</span> <span class="n">Send</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">c</span> <span class="o">*</span><span class="n">Call</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">audio</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">c</span><span class="o">.</span><span class="n">MP3</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"%s: encode mp3: %w"</span><span class="p">,</span> <span class="n">b</span><span class="o">.</span><span class="n">name</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">uploadURL</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">b</span><span class="o">.</span><span class="n">requestUploadURL</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">c</span><span class="p">)</span> <span class="c">// step 1: metadata POST</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">err</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">b</span><span class="o">.</span><span class="n">putAudio</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">uploadURL</span><span class="p">,</span> <span class="n">audio</span><span class="p">)</span> <span class="c">// step 2: PUT to the one-time URL</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Step one, <code class="language-plaintext highlighter-rouge">requestUploadURL</code>, is a <code class="language-plaintext highlighter-rouge">application/x-www-form-urlencoded</code> POST
carrying <code class="language-plaintext highlighter-rouge">apiKey</code>, <code class="language-plaintext highlighter-rouge">systemId</code>, <code class="language-plaintext highlighter-rouge">callDuration</code>, <code class="language-plaintext highlighter-rouge">ts</code> (the Unix start time), <code class="language-plaintext highlighter-rouge">tg</code>,
<code class="language-plaintext highlighter-rouge">src</code>, and <code class="language-plaintext highlighter-rouge">freq</code>. The API answers with a small text body, and
<code class="language-plaintext highlighter-rouge">parseBroadcastifyUploadURL</code> teases the URL out of it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/broadcastify.go (shape)</span>
<span class="k">func</span> <span class="n">parseBroadcastifyUploadURL</span><span class="p">(</span><span class="n">body</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">fields</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">Fields</span><span class="p">(</span><span class="n">strings</span><span class="o">.</span><span class="n">TrimSpace</span><span class="p">(</span><span class="n">body</span><span class="p">))</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">fields</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"broadcastify: empty metadata response"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">fields</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">==</span> <span class="s">"0"</span> <span class="o">&amp;&amp;</span> <span class="nb">len</span><span class="p">(</span><span class="n">fields</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="m">2</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fields</span><span class="p">[</span><span class="m">1</span><span class="p">],</span> <span class="no">nil</span> <span class="c">// "0 &lt;url&gt;" — success + the upload URL</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">strings</span><span class="o">.</span><span class="n">HasPrefix</span><span class="p">(</span><span class="n">fields</span><span class="p">[</span><span class="m">0</span><span class="p">],</span> <span class="s">"http"</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fields</span><span class="p">[</span><span class="m">0</span><span class="p">],</span> <span class="no">nil</span> <span class="c">// bare URL</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"broadcastify: metadata response rejected: %s"</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">"0 &lt;url&gt;"</code> convention — a <code class="language-plaintext highlighter-rouge">0</code> status token, whitespace, then the URL — is the
Broadcastify Calls protocol’s success shape; anything else is treated as a
rejection and surfaced as an error (which the Manager will then retry). Step two,
<code class="language-plaintext highlighter-rouge">putAudio</code>, is a plain <code class="language-plaintext highlighter-rouge">PUT</code> of the MP3 bytes with <code class="language-plaintext highlighter-rouge">Content-Type: audio/mpeg</code> and
an explicit <code class="language-plaintext highlighter-rouge">ContentLength</code>, to the URL step one handed back. A non-2xx on either
leg returns an error, and the Manager’s <code class="language-plaintext highlighter-rouge">sendWithRetry</code> handles the backoff.</p>

<figure class="lab-figure">
<svg viewBox="0 0 640 210" width="640" height="210" role="img" aria-label="Sequence diagram of the Broadcastify two-step upload. GopherTrunk POSTs form-encoded metadata to the Calls API, which replies with a status zero and a one-time upload URL. GopherTrunk then PUTs the MP3 audio bytes directly to that upload URL, which replies 200 OK.">
  <line x1="70" y1="30" x2="70" y2="190" stroke="var(--fg-muted)" />
  <text x="70" y="22" text-anchor="middle" fill="var(--accent)" font-size="9">GopherTrunk</text>
  <line x1="330" y1="30" x2="330" y2="190" stroke="var(--fg-muted)" />
  <text x="330" y="22" text-anchor="middle" fill="currentColor" font-size="9">Calls API</text>
  <line x1="560" y1="30" x2="560" y2="190" stroke="var(--fg-muted)" />
  <text x="560" y="22" text-anchor="middle" fill="currentColor" font-size="9">upload URL</text>
  <line x1="70" y1="54" x2="330" y2="54" stroke="currentColor" /><polygon points="330,50 340,54 330,58" fill="currentColor" />
  <text x="200" y="48" text-anchor="middle" fill="currentColor" font-size="8">POST metadata (apiKey, systemId, tg, ts…)</text>
  <line x1="330" y1="86" x2="70" y2="86" stroke="var(--accent)" stroke-dasharray="4 3" /><polygon points="80,82 70,86 80,90" fill="var(--accent)" />
  <text x="200" y="80" text-anchor="middle" fill="var(--accent)" font-size="8">"0 &lt;upload-url&gt;"</text>
  <line x1="70" y1="130" x2="560" y2="130" stroke="currentColor" /><polygon points="560,126 570,130 560,134" fill="currentColor" />
  <text x="315" y="124" text-anchor="middle" fill="currentColor" font-size="8">PUT audio/mpeg (the MP3 bytes)</text>
  <line x1="560" y1="162" x2="70" y2="162" stroke="var(--accent)" stroke-dasharray="4 3" /><polygon points="80,158 70,162 80,166" fill="var(--accent)" />
  <text x="315" y="156" text-anchor="middle" fill="var(--accent)" font-size="8">200 OK</text>
  <text x="315" y="184" text-anchor="middle" fill="var(--fg-muted)" font-size="8">audio never transits the API host — it goes straight to storage</text>
</svg>
<figcaption>Broadcastify Calls: the metadata POST returns a one-time URL, and the MP3 is PUT to that URL directly — two round trips, one logical upload.</figcaption>
</figure>

<h2 id="rdioscanner-and-openmhz-one-multipart-helper-two-vocabularies">RdioScanner and OpenMHz: one multipart helper, two vocabularies</h2>

<p>Both of these are single <code class="language-plaintext highlighter-rouge">multipart/form-data</code> POSTs, and both build the body
with the same tiny helper. <code class="language-plaintext highlighter-rouge">buildMultipart</code> (<code class="language-plaintext highlighter-rouge">internal/broadcast/multipart.go</code>)
takes a slice of <code class="language-plaintext highlighter-rouge">multipartField</code> — a text field carries <code class="language-plaintext highlighter-rouge">Value</code>, a file part
carries <code class="language-plaintext highlighter-rouge">Filename</code> + <code class="language-plaintext highlighter-rouge">Data</code> — and returns the encoded buffer plus the
<code class="language-plaintext highlighter-rouge">Content-Type</code> header with its generated boundary:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/multipart.go (shape)</span>
<span class="k">type</span> <span class="n">multipartField</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Name</span>     <span class="kt">string</span>
    <span class="n">Value</span>    <span class="kt">string</span> <span class="c">// text field</span>
    <span class="n">Filename</span> <span class="kt">string</span> <span class="c">// set → written as a file part</span>
    <span class="n">Data</span>     <span class="p">[]</span><span class="kt">byte</span> <span class="c">// the file bytes</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">buildMultipart</span><span class="p">(</span><span class="n">fields</span> <span class="p">[]</span><span class="n">multipartField</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">bytes</span><span class="o">.</span><span class="n">Buffer</span><span class="p">,</span> <span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// …CreateFormFile for parts with a Filename, WriteField otherwise</span>
<span class="p">}</span>
</code></pre></div></div>

<p>What differs is the <strong>field vocabulary</strong> each service expects. RdioScanner wants
<code class="language-plaintext highlighter-rouge">key</code>, <code class="language-plaintext highlighter-rouge">system</code>, <code class="language-plaintext highlighter-rouge">dateTime</code>, <code class="language-plaintext highlighter-rouge">talkgroup</code>, <code class="language-plaintext highlighter-rouge">source</code>, <code class="language-plaintext highlighter-rouge">frequency</code>, and the audio as
<code class="language-plaintext highlighter-rouge">audio</code>; OpenMHz wants <code class="language-plaintext highlighter-rouge">api_key</code>, <code class="language-plaintext highlighter-rouge">freq</code>, <code class="language-plaintext highlighter-rouge">start_time</code>, <code class="language-plaintext highlighter-rouge">stop_time</code>,
<code class="language-plaintext highlighter-rouge">call_length</code>, <code class="language-plaintext highlighter-rouge">talkgroup_num</code>, and — the interesting ones —
<code class="language-plaintext highlighter-rouge">source_list</code> and <code class="language-plaintext highlighter-rouge">patch_list</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/openmhz.go (shape)</span>
<span class="n">sourceList</span> <span class="o">:=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">`[{"src":%d,"time":%d,"pos":0}]`</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">Source</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">StartedAt</span><span class="o">.</span><span class="n">Unix</span><span class="p">())</span>
<span class="n">patchList</span> <span class="o">:=</span> <span class="s">"[]"</span>
<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">PatchedGroups</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
    <span class="c">// "[201,212]" — the patched talkgroups on this call</span>
<span class="p">}</span>
<span class="n">fields</span> <span class="o">:=</span> <span class="p">[]</span><span class="n">multipartField</span><span class="p">{</span>
    <span class="p">{</span><span class="n">Name</span><span class="o">:</span> <span class="s">"api_key"</span><span class="p">,</span> <span class="n">Value</span><span class="o">:</span> <span class="n">b</span><span class="o">.</span><span class="n">apiKey</span><span class="p">},</span>
    <span class="p">{</span><span class="n">Name</span><span class="o">:</span> <span class="s">"talkgroup_num"</span><span class="p">,</span> <span class="n">Value</span><span class="o">:</span> <span class="n">strconv</span><span class="o">.</span><span class="n">FormatUint</span><span class="p">(</span><span class="kt">uint64</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Talkgroup</span><span class="p">),</span> <span class="m">10</span><span class="p">)},</span>
    <span class="p">{</span><span class="n">Name</span><span class="o">:</span> <span class="s">"source_list"</span><span class="p">,</span> <span class="n">Value</span><span class="o">:</span> <span class="n">sourceList</span><span class="p">},</span>
    <span class="p">{</span><span class="n">Name</span><span class="o">:</span> <span class="s">"patch_list"</span><span class="p">,</span> <span class="n">Value</span><span class="o">:</span> <span class="n">patchList</span><span class="p">},</span>
    <span class="p">{</span><span class="n">Name</span><span class="o">:</span> <span class="s">"call"</span><span class="p">,</span> <span class="n">Filename</span><span class="o">:</span> <span class="n">audioFilename</span><span class="p">(</span><span class="n">c</span><span class="p">,</span> <span class="s">"mp3"</span><span class="p">),</span> <span class="n">Data</span><span class="o">:</span> <span class="n">audio</span><span class="p">},</span>
    <span class="c">// …freq, start_time, stop_time, call_length, emergency</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">source_list</code> is a JSON array OpenMHz expects even when there is only the single
granting unit to report — GopherTrunk sends the one source it knows with <code class="language-plaintext highlighter-rouge">pos:0</code>.
<code class="language-plaintext highlighter-rouge">patch_list</code> carries the patched talkgroups when the call is a patch, <code class="language-plaintext highlighter-rouge">[]</code>
otherwise. Both backends name their audio part with the shared <code class="language-plaintext highlighter-rouge">audioFilename</code>
helper (<code class="language-plaintext highlighter-rouge">&lt;talkgroup&gt;-&lt;unixstart&gt;.mp3</code>), and RdioScanner stamps its <code class="language-plaintext highlighter-rouge">dateTime</code>
with <code class="language-plaintext highlighter-rouge">rfc3339In</code> so the timestamp renders in the operator’s configured display
timezone with an explicit offset — the same helper the webhook uses, so a call
carries one consistent wall-clock time across every feed.</p>

<h2 id="icecast-a-live-stream-that-never-starves">Icecast: a live stream that never starves</h2>

<p>Icecast is the odd one out. The other four backends <em>upload a finished call</em>;
Icecast maintains a <em>continuous live audio stream</em> that a listener can tune into
at any moment. That changes everything about its shape. There is no per-call
request — instead, <code class="language-plaintext highlighter-rouge">NewIcecast</code> starts a background goroutine that holds a source
connection open, and <code class="language-plaintext highlighter-rouge">Send</code> just <strong>appends</strong> the call’s MP3 onto a shared queue:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/icecast.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">b</span> <span class="o">*</span><span class="n">icecastBackend</span><span class="p">)</span> <span class="n">Send</span><span class="p">(</span><span class="n">_</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">c</span> <span class="o">*</span><span class="n">Call</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="n">audio</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">c</span><span class="o">.</span><span class="n">MP3</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"%s: encode mp3: %w"</span><span class="p">,</span> <span class="n">b</span><span class="o">.</span><span class="n">name</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="n">b</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>
    <span class="k">defer</span> <span class="n">b</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">queue</span><span class="p">)</span><span class="o">+</span><span class="nb">len</span><span class="p">(</span><span class="n">audio</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">b</span><span class="o">.</span><span class="n">maxQueue</span> <span class="p">{</span>
        <span class="n">b</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Warn</span><span class="p">(</span><span class="s">"broadcast: icecast queue full, dropping call"</span><span class="p">,</span> <span class="s">"tg"</span><span class="p">,</span> <span class="n">c</span><span class="o">.</span><span class="n">Talkgroup</span><span class="p">)</span>
        <span class="k">return</span> <span class="no">nil</span> <span class="c">// best-effort: drop, don't fail (no pointless Manager retry)</span>
    <span class="p">}</span>
    <span class="n">b</span><span class="o">.</span><span class="n">queue</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">queue</span><span class="p">,</span> <span class="n">audio</span><span class="o">...</span><span class="p">)</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note <code class="language-plaintext highlighter-rouge">Send</code> returns <code class="language-plaintext highlighter-rouge">nil</code> even when it drops — a live feed is best-effort, and
returning an error would trigger Manager retries that make no sense for streaming
audio. The real work is in <code class="language-plaintext highlighter-rouge">runStream</code>, which dials the server, does the Icecast
source <code class="language-plaintext highlighter-rouge">handshake</code> (a <code class="language-plaintext highlighter-rouge">SOURCE &lt;mount&gt; HTTP/1.0</code> request with basic auth and
<code class="language-plaintext highlighter-rouge">Ice-*</code> headers), then loops on a ticker writing paced chunks:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/icecast.go (shape)</span>
<span class="n">ticker</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">NewTicker</span><span class="p">(</span><span class="n">icecastTick</span><span class="p">)</span> <span class="c">// 200ms</span>
<span class="n">chunk</span> <span class="o">:=</span> <span class="n">b</span><span class="o">.</span><span class="n">bytesPerSec</span> <span class="o">*</span> <span class="kt">int</span><span class="p">(</span><span class="n">icecastTick</span><span class="p">)</span> <span class="o">/</span> <span class="kt">int</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
<span class="n">silenceOff</span> <span class="o">:=</span> <span class="m">0</span>
<span class="k">for</span> <span class="p">{</span>
    <span class="k">select</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="n">ctx</span><span class="o">.</span><span class="n">Done</span><span class="p">()</span><span class="o">:</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="n">ticker</span><span class="o">.</span><span class="n">C</span><span class="o">:</span>
    <span class="p">}</span>
    <span class="n">payload</span> <span class="o">:=</span> <span class="n">b</span><span class="o">.</span><span class="n">takeChunk</span><span class="p">(</span><span class="n">chunk</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">silenceOff</span><span class="p">)</span>
    <span class="n">conn</span><span class="o">.</span><span class="n">SetWriteDeadline</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="m">10</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">))</span>
    <span class="k">if</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">conn</span><span class="o">.</span><span class="n">Write</span><span class="p">(</span><span class="n">payload</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">err</span> <span class="c">// triggers reconnect in run()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The pacing is the point. Icecast expects a source to feed audio at roughly its
real byte rate; feed too fast and buffers bloat, too slow and the server times
the source out. So each tick writes exactly <code class="language-plaintext highlighter-rouge">bytesPerSec * tick</code> bytes. When there
is real call audio queued, <code class="language-plaintext highlighter-rouge">takeChunk</code> serves it; when the queue is empty — the
gap between calls — it <strong>pads the chunk with pre-encoded silence</strong>, cycling
through one second of MP3-encoded digital silence over and over:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/icecast.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">b</span> <span class="o">*</span><span class="n">icecastBackend</span><span class="p">)</span> <span class="n">takeChunk</span><span class="p">(</span><span class="n">n</span> <span class="kt">int</span><span class="p">,</span> <span class="n">silenceOff</span> <span class="o">*</span><span class="kt">int</span><span class="p">)</span> <span class="p">[]</span><span class="kt">byte</span> <span class="p">{</span>
    <span class="n">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="c">// …drain up to n bytes of real queued call audio under the lock</span>
    <span class="k">for</span> <span class="nb">len</span><span class="p">(</span><span class="n">out</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">n</span> <span class="p">{</span> <span class="c">// shortfall → top up with cycled silence</span>
        <span class="k">if</span> <span class="o">*</span><span class="n">silenceOff</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">silence</span><span class="p">)</span> <span class="p">{</span>
            <span class="o">*</span><span class="n">silenceOff</span> <span class="o">=</span> <span class="m">0</span> <span class="c">// wrap around the one-second silence buffer</span>
        <span class="p">}</span>
        <span class="c">// …append b.silence[silenceOff:end], advance silenceOff</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">out</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The silence is encoded once at construction (<code class="language-plaintext highlighter-rouge">mp3.Encode(make([]int16, rate),
rate)</code>) and reused forever, so the pacer costs nothing between calls. If the
connection drops, <code class="language-plaintext highlighter-rouge">run</code> reconnects after a fixed backoff and re-handshakes —
the source stays up across network blips without dropping the listener’s stream.</p>

<figure class="lab-figure">
<svg viewBox="0 0 640 210" width="640" height="210" role="img" aria-label="The Icecast pacer. Send appends call MP3 onto a shared queue. A ticker fires every 200 milliseconds; takeChunk pulls a fixed-size chunk, draining real call audio from the queue first and topping any shortfall up from a cycling one-second silence buffer, then writes the chunk to the source socket connected to the Icecast server.">
  <rect x="16" y="30" width="120" height="30" rx="6" fill="none" stroke="var(--accent)" />
  <text x="76" y="49" text-anchor="middle" fill="var(--accent)" font-size="9">Send(call)</text>
  <line x1="76" y1="60" x2="76" y2="86" stroke="currentColor" /><polygon points="72,86 76,96 80,86" fill="currentColor" />
  <rect x="16" y="96" width="120" height="34" rx="6" fill="none" stroke="currentColor" />
  <text x="76" y="112" text-anchor="middle" fill="currentColor" font-size="9">queue []byte</text>
  <text x="76" y="124" text-anchor="middle" fill="var(--fg-muted)" font-size="8">MP3 of each call</text>
  <rect x="16" y="150" width="120" height="34" rx="6" fill="none" stroke="var(--fg-muted)" />
  <text x="76" y="166" text-anchor="middle" fill="var(--fg-muted)" font-size="9">silence buffer</text>
  <text x="76" y="178" text-anchor="middle" fill="var(--fg-muted)" font-size="8">1s, cycled</text>
  <line x1="136" y1="113" x2="250" y2="105" stroke="currentColor" /><polygon points="250,101 260,105 250,109" fill="currentColor" />
  <line x1="136" y1="167" x2="250" y2="120" stroke="var(--fg-muted)" stroke-dasharray="4 3" /><polygon points="250,116 260,120 249,124" fill="var(--fg-muted)" />
  <rect x="260" y="86" width="130" height="52" rx="6" fill="none" stroke="var(--accent)" />
  <text x="325" y="106" text-anchor="middle" fill="var(--accent)" font-size="9">takeChunk(n)</text>
  <text x="325" y="119" text-anchor="middle" fill="var(--fg-muted)" font-size="8">real audio first,</text>
  <text x="325" y="130" text-anchor="middle" fill="var(--fg-muted)" font-size="8">pad with silence</text>
  <text x="325" y="72" text-anchor="middle" fill="var(--fg-muted)" font-size="8">ticker · every 200ms</text>
  <line x1="325" y1="80" x2="325" y2="86" stroke="var(--fg-muted)" />
  <line x1="390" y1="112" x2="440" y2="112" stroke="currentColor" /><polygon points="440,108 450,112 440,116" fill="currentColor" />
  <rect x="450" y="90" width="80" height="44" rx="6" fill="none" stroke="currentColor" />
  <text x="490" y="110" text-anchor="middle" fill="currentColor" font-size="9">source</text>
  <text x="490" y="122" text-anchor="middle" fill="currentColor" font-size="9">socket</text>
  <line x1="530" y1="112" x2="566" y2="112" stroke="currentColor" /><polygon points="566,108 576,112 566,116" fill="currentColor" />
  <rect x="576" y="90" width="56" height="44" rx="6" fill="none" stroke="var(--fg-muted)" />
  <text x="604" y="110" text-anchor="middle" fill="var(--fg-muted)" font-size="8">Icecast</text>
  <text x="604" y="122" text-anchor="middle" fill="var(--fg-muted)" font-size="8">server</text>
</svg>
<figcaption>The Icecast pacer writes a fixed-size chunk every tick, draining real call audio when there is any and topping up the shortfall with cycled silence so the source connection never starves.</figcaption>
</figure>

<h2 id="the-webhook-a-stable-schema-audio-opt-in">The webhook: a stable schema, audio opt-in</h2>

<p>The generic webhook is the escape hatch — the backend for everyone whose sink
isn’t one of the four named services. It POSTs one <code class="language-plaintext highlighter-rouge">application/json</code> object per
call against a documented, stable schema (<code class="language-plaintext highlighter-rouge">webhookPayload</code>) so a downstream
consumer can rely on it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/webhook.go (shape)</span>
<span class="k">type</span> <span class="n">webhookPayload</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Event</span>       <span class="kt">string</span>   <span class="s">`json:"event"`</span> <span class="c">// always "call"</span>
    <span class="n">System</span>      <span class="kt">string</span>   <span class="s">`json:"system"`</span>
    <span class="n">Protocol</span>    <span class="kt">string</span>   <span class="s">`json:"protocol"`</span>
    <span class="n">CallType</span>    <span class="kt">string</span>   <span class="s">`json:"call_type"`</span> <span class="c">// "group" | "unit" | "data"</span>
    <span class="n">Talkgroup</span>   <span class="kt">uint32</span>   <span class="s">`json:"talkgroup"`</span>
    <span class="n">Source</span>      <span class="kt">uint32</span>   <span class="s">`json:"source,omitempty"`</span>
    <span class="n">FrequencyHz</span> <span class="kt">uint32</span>   <span class="s">`json:"frequency_hz"`</span>
    <span class="c">// …P25 site identity (channel_id, rfss_id, site_id, nac), encryption,</span>
    <span class="c">//   emergency, patched_groups — all omitempty so presence means "known"</span>
    <span class="n">StartedAt</span>   <span class="kt">string</span>   <span class="s">`json:"started_at"`</span> <span class="c">// rfc3339In</span>
    <span class="n">EndedAt</span>     <span class="kt">string</span>   <span class="s">`json:"ended_at"`</span>
    <span class="n">DurationMs</span>  <span class="kt">int64</span>    <span class="s">`json:"duration_ms"`</span>
    <span class="n">AudioBase64</span> <span class="kt">string</span>   <span class="s">`json:"audio_base64,omitempty"`</span> <span class="c">// only when opted in</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two design details are worth calling out. <code class="language-plaintext highlighter-rouge">callType</code> classifies the call so a
consumer never mistakes a unit-to-unit call’s destination radio ID for a
talkgroup — a group call reads <code class="language-plaintext highlighter-rouge">"group"</code>, an individual call <code class="language-plaintext highlighter-rouge">"unit"</code>, a data
grant <code class="language-plaintext highlighter-rouge">"data"</code>. And audio is <strong>opt-in</strong>: unless <code class="language-plaintext highlighter-rouge">IncludeAudio</code> is set, <code class="language-plaintext highlighter-rouge">Send</code>
never calls <code class="language-plaintext highlighter-rouge">c.MP3()</code>, so a metadata-only webhook pays no encode cost and the
payload stays lightweight. When it <em>is</em> set, the MP3 is base64-encoded into
<code class="language-plaintext highlighter-rouge">audio_base64</code> alongside an <code class="language-plaintext highlighter-rouge">audio_format</code>. Fields that don’t apply to a given
call — site identity on non-P25, encryption params on a clear call — are dropped
via <code class="language-plaintext highlighter-rouge">omitempty</code>, so a consumer can treat presence as “this value is known”.</p>

<h2 id="the-coda-pure-go-is-what-makes-it-testable">The coda: pure Go is what makes it testable</h2>

<p>The reason this series could describe each wire format with confidence is that
every one of them is exercised by a real test — and those tests run because
GopherTrunk’s entire output half is <strong>pure Go, zero CGO</strong>. The MP3 encoder
(Shine), the WAV reader/writer, and the SQLite call log are all CGO-free, so a
test can stand up the real encode → upload → persist path entirely in memory with
nothing mocked but the network endpoint.</p>

<p>The pattern repeats across the suite:</p>

<ul>
  <li><strong>In-memory WAV.</strong> <code class="language-plaintext highlighter-rouge">internal/voice/wav_test.go</code> writes and header-patches a WAV
through an in-memory <code class="language-plaintext highlighter-rouge">io.WriteSeeker</code> — no tempfile, no disk. The broadcast
tests’ <code class="language-plaintext highlighter-rouge">writeWAV</code> helper (<code class="language-plaintext highlighter-rouge">internal/broadcast/broadcast_test.go</code>) makes a real
short WAV that the real encoder turns into real MP3 bytes.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">httptest</code> backends.</strong> <code class="language-plaintext highlighter-rouge">internal/broadcast/backends_test.go</code> stands up
<code class="language-plaintext highlighter-rouge">httptest.NewServer</code> handlers that assert on exactly what this post described —
<code class="language-plaintext highlighter-rouge">TestBroadcastifyTwoStepUpload</code> checks the metadata form <em>and</em> that the second
leg is a <code class="language-plaintext highlighter-rouge">PUT</code> whose body begins with the MP3 frame sync <code class="language-plaintext highlighter-rouge">0xFF</code>;
<code class="language-plaintext highlighter-rouge">TestRdioScannerUpload</code> and <code class="language-plaintext highlighter-rouge">TestOpenMHzUpload</code> parse the multipart body and
check the field names; <code class="language-plaintext highlighter-rouge">TestIcecastSourceHandshakeAndStream</code> speaks the raw
source protocol over a <code class="language-plaintext highlighter-rouge">net.Listener</code>.</li>
  <li><strong>Behavioural Manager tests.</strong> <code class="language-plaintext highlighter-rouge">internal/broadcast/manager_test.go</code> drives the
full Part 13 machine with a <code class="language-plaintext highlighter-rouge">fakeBackend</code>: <code class="language-plaintext highlighter-rouge">TestManagerSkipsStreamFalseTalkgroup</code>
and <code class="language-plaintext highlighter-rouge">TestManagerDropsShortCalls</code> pin the two gates,
<code class="language-plaintext highlighter-rouge">TestManagerRetriesTransientFailure</code> and <code class="language-plaintext highlighter-rouge">TestManagerGivesUpAfterMaxRetries</code>
pin the backoff budget.</li>
  <li><strong>Timezone and schema.</strong> <code class="language-plaintext highlighter-rouge">internal/broadcast/timezone_test.go</code> pins <code class="language-plaintext highlighter-rouge">rfc3339In</code>
to a fixed offset; <code class="language-plaintext highlighter-rouge">internal/broadcast/webhook_test.go</code> decodes the posted JSON
back into a <code class="language-plaintext highlighter-rouge">webhookPayload</code> and checks the site identity and <code class="language-plaintext highlighter-rouge">call_type</code>.</li>
  <li><strong>Temp SQLite.</strong> <code class="language-plaintext highlighter-rouge">internal/storage/calllog_test.go</code> opens a real database at
<code class="language-plaintext highlighter-rouge">t.TempDir()/calls.db</code> and asserts the start/end rows — the same pure-Go SQLite
the daemon uses, no external server.</li>
</ul>

<p>That is the quiet payoff of the whole architecture. Because nothing links C,
<code class="language-plaintext highlighter-rouge">go test ./...</code> builds and runs the real recording, encoding, and streaming code
on any machine with a Go toolchain — no libsndfile, no lame, no sqlite3 dev
package. The wire formats in this post aren’t described from a spec; they’re
described from tests that send the real bytes and check what a real server
receives.</p>

<h2 id="the-3-pm-dispatch-delivered">The 3 p.m. dispatch, delivered</h2>

<p>Fourteen posts ago we picked up one call — the 3 p.m. dispatch on talkgroup 101 —
as it arrived from the vocoders as PCM. The recorder wrote its crash-safe WAV; the
call log indexed it; loudness leveled the distributed copy; the <code class="language-plaintext highlighter-rouge">CallComplete</code>
event carried its path to the broadcast Manager; the Manager gated it on
<code class="language-plaintext highlighter-rouge">Stream</code> and <code class="language-plaintext highlighter-rouge">MinDuration</code>, encoded it to MP3 once, and handed it to a worker.
That worker calls <code class="language-plaintext highlighter-rouge">broadcastifyBackend.Send</code>. A form POST goes out with the
talkgroup, the source, the frequency, and the start time; Broadcastify answers
<code class="language-plaintext highlighter-rouge">"0 &lt;url&gt;"</code>; the MP3 bytes <code class="language-plaintext highlighter-rouge">PUT</code> to that URL; a <code class="language-plaintext highlighter-rouge">200</code> comes back; <code class="language-plaintext highlighter-rouge">sent["broadcastify"]</code>
ticks to one. The call is live in the feed. That is the whole output half, end to
end — four independent subsystems, one bus, and a call that made it all the way
out.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why does Broadcastify need two requests when everyone else uses one?</strong>
Because the audio doesn’t go to the API host. The metadata POST returns a
one-time upload URL — usually pointing at object storage — and the MP3 is <code class="language-plaintext highlighter-rouge">PUT</code>
directly to it. <code class="language-plaintext highlighter-rouge">parseBroadcastifyUploadURL</code> reads the <code class="language-plaintext highlighter-rouge">"0 &lt;url&gt;"</code> success
response; a rejection returns an error the Manager retries.</p>

<p><strong>Do RdioScanner and OpenMHz share code?</strong>
They share <code class="language-plaintext highlighter-rouge">buildMultipart</code> and <code class="language-plaintext highlighter-rouge">audioFilename</code> but not their field sets. Each
backend assembles its own <code class="language-plaintext highlighter-rouge">[]multipartField</code> — RdioScanner’s <code class="language-plaintext highlighter-rouge">key</code>/<code class="language-plaintext highlighter-rouge">system</code>/
<code class="language-plaintext highlighter-rouge">dateTime</code>, OpenMHz’s <code class="language-plaintext highlighter-rouge">source_list</code>/<code class="language-plaintext highlighter-rouge">patch_list</code>/<code class="language-plaintext highlighter-rouge">talkgroup_num</code> — and hands it to
the same encoder. The helper writes the body; the backend owns the vocabulary.</p>

<p><strong>Why does the Icecast backend send silence between calls?</strong>
An Icecast source connection must feed audio continuously or the server times it
out and drops the mount. Between calls there is no audio, so the pacer tops each
timed chunk up from a one-second buffer of pre-encoded silence, cycling it. This
keeps the source alive and the listener’s stream unbroken without re-encoding.</p>

<p><strong>How can the tests be sure the uploads are correct without hitting real servers?</strong>
They stand up <code class="language-plaintext highlighter-rouge">httptest</code> servers that assert on the exact bytes each backend
sends — the two-step Broadcastify handshake, the multipart field names, the raw
Icecast source protocol — while the encode and WAV paths run for real in memory.
Because the whole stack is pure Go with zero CGO, <code class="language-plaintext highlighter-rouge">go test ./...</code> exercises the
genuine code path on any machine with a Go toolchain.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 14 of 14</strong> · ←
<a href="/blog/deep-dives/recording-streaming-13-broadcast-manager/">Part 13: Outbound Streaming — The Broadcast Manager</a>
· This is the finale — back to the
<a href="/blog/series/recording-streaming/">series index</a>.</p>

<p><em>Where to next? The pure-Go, zero-CGO story that made this series’ tests possible
runs deeper in the SDR Internals series — see
<a href="/blog/series/sdr-internals/">SDR Internals</a> ch. 14,
“APIs, Testing &amp; the Pure-Go Story.”</em></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="streaming" /><category term="broadcast" /><category term="http" /><category term="icecast" /><category term="testing" /><category term="go" /><summary type="html"><![CDATA[A closing tour of every outbound destination's real wire protocol — Broadcastify's two-step metadata-then-PUT upload, the multipart POSTs for RdioScanner and OpenMHz, Icecast's paced silence-topped source connection, and the generic JSON webhook — capped by the pure-Go, zero-CGO testing story that verifies them all.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running It For Real, Part 11: Grant Webhooks &amp;amp; External Integrations</title><link href="https://gophertrunk.org/blog/deep-dives/running-it-for-real-11-grant-webhooks/" rel="alternate" type="text/html" title="Running It For Real, Part 11: Grant Webhooks &amp;amp; External Integrations" /><published>2026-08-14T00:00:00-05:00</published><updated>2026-08-14T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/running-it-for-real-11-grant-webhooks</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/running-it-for-real-11-grant-webhooks/"><![CDATA[<p><em>Part 11 of <strong>Running It For Real</strong>. The four audio backends deliver a finished
call — minutes after the fact, once the voice decode has landed. But some
integrations can’t wait that long: a dashboard that lights a talkgroup the instant
it keys up, a home-automation rule that flips a light when your agency dispatches,
an alerter that pages you on an emergency grant. Those want the *grant</em> — the
control-channel event that says “talkgroup T is now on frequency F” — pushed the
moment it’s decoded, before any audio exists. On the laptop you’d poll an endpoint.
On a 24/7 service you want a push that survives a wedged consumer without ever
touching the decoder behind it. That’s the grant webhook.*</p>

<blockquote>
  <p><strong>TL;DR:</strong> <code class="language-plaintext highlighter-rouge">GrantWebhook</code> POSTs one JSON object per decoded control-channel grant
to your endpoint as it lands — the <strong>push form of <code class="language-plaintext highlighter-rouge">GET /api/v1/grants</code></strong>, same
schema, plus an <code class="language-plaintext highlighter-rouge">"event": "grant"</code> discriminator and a decode timestamp. It
subscribes to the bus at construction (so grants decoded before <code class="language-plaintext highlighter-rouge">Run</code> starts
aren’t lost), delivers on a single worker with retry/backoff, and — the load-bearing
property — sits behind a <strong>bounded 256-deep queue that drops the overflow rather
than blocking the event loop and the decoder behind it</strong>. It carries no audio: a
grant is a control-channel fact, not a recording, and the source RID is reported
exactly as decoded (never backfilled), which makes it distinct from the
completed-call webhook.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>Fires at call setup, not call end.</strong> The grant webhook pushes the instant the
control channel decodes a grant — seconds before the completed-call webhook, which
waits for the voice decode.</li>
  <li><strong>One schema across three surfaces.</strong> The payload mirrors the <code class="language-plaintext highlighter-rouge">GrantDTO</code> that
<code class="language-plaintext highlighter-rouge">GET /api/v1/grants</code> and the <code class="language-plaintext highlighter-rouge">KindGrant</code> SSE stream already publish, so a consumer
reads one shape whether it snapshots, subscribes, or receives a push.</li>
  <li><strong>A wedged consumer can’t hurt the decoder.</strong> The bounded queue drops overflow
grants and increments a counter; it never applies backpressure to the event loop.</li>
  <li><strong>Source RID is reported, not invented.</strong> A grant whose TSBK carried <code class="language-plaintext highlighter-rouge">src=0</code> is
POSTed with <code class="language-plaintext highlighter-rouge">source_id=0</code> — the feed reports what the control channel actually saw,
unlike the completed-call webhook whose source depends on a separate voice decode.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>Where it lives</th>
      <th>Operator lever</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Config</td>
      <td><code class="language-plaintext highlighter-rouge">GrantWebhookConfig{URL, AuthHeader, Systems}</code></td>
      <td><code class="language-plaintext highlighter-rouge">broadcast.grant_webhook[]</code></td>
    </tr>
    <tr>
      <td>Subscription</td>
      <td><code class="language-plaintext highlighter-rouge">NewGrantWebhook</code> (subscribes at construction)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>Delivery loop</td>
      <td><code class="language-plaintext highlighter-rouge">worker</code> → <code class="language-plaintext highlighter-rouge">sendWithRetry</code> → <code class="language-plaintext highlighter-rouge">post</code></td>
      <td><code class="language-plaintext highlighter-rouge">MaxRetries</code>, <code class="language-plaintext highlighter-rouge">RetryBase</code></td>
    </tr>
    <tr>
      <td>Bounded queue</td>
      <td><code class="language-plaintext highlighter-rouge">jobs</code> chan, depth <code class="language-plaintext highlighter-rouge">grantWebhookQueueDepth</code> = 256</td>
      <td>(drops on overflow)</td>
    </tr>
    <tr>
      <td>Auth</td>
      <td><code class="language-plaintext highlighter-rouge">AuthHeader</code> → verbatim <code class="language-plaintext highlighter-rouge">Authorization</code> header</td>
      <td><code class="language-plaintext highlighter-rouge">auth_header: "Bearer …"</code></td>
    </tr>
    <tr>
      <td>Schema</td>
      <td><code class="language-plaintext highlighter-rouge">grantWebhookPayload</code> (mirrors <code class="language-plaintext highlighter-rouge">api.GrantDTO</code>)</td>
      <td>—</td>
    </tr>
    <tr>
      <td>Health</td>
      <td><code class="language-plaintext highlighter-rouge">Stats()</code> → <code class="language-plaintext highlighter-rouge">queued</code>/<code class="language-plaintext highlighter-rouge">dropped</code>/<code class="language-plaintext highlighter-rouge">sent</code>/<code class="language-plaintext highlighter-rouge">failed</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET /api/v1/broadcast</code></td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>Grant vs call</strong> — two webhooks, two moments, two truths about the source.</li>
  <li><strong>The schema</strong> — one shape shared with the REST snapshot and the SSE stream.</li>
  <li><strong>The bounded queue</strong> — the rule that keeps a bad consumer off the decoder.</li>
  <li><strong>Delivery</strong> — subscribe-at-construction, single worker, retry/backoff.</li>
  <li><strong>Operating it</strong> — auth, filtering, and the counters that flag a dead endpoint.</li>
</ul>

<h2 id="grant-vs-call-two-webhooks-two-moments">Grant vs call: two webhooks, two moments</h2>

<p>GopherTrunk has two JSON webhooks and it’s worth being precise about the
difference, because they answer different questions. The <strong>completed-call
webhook</strong> (covered in <a href="/blog/series/recording-streaming/">Recording, Composition &amp;
Streaming</a> Part 14)
fires when a call <em>finishes</em> — it can carry the audio, base64-encoded, opt-in. The
<strong>grant webhook</strong> fires when the control channel <em>decodes a grant</em> — at call setup,
before a single voice frame exists. If you’re driving a live board or a
low-latency alert, you want the grant; if you’re archiving finished audio to a
custom sink, you want the call.</p>

<p>That timing difference produces a subtle but real semantic difference in the
source radio ID, and the code comments it deliberately:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/grantwebhook.go (shape)</span>
<span class="c">// Grants are surfaced exactly as decoded off the control channel: the source</span>
<span class="c">// RID rides the grant at call-setup time for every protocol, and a grant whose</span>
<span class="c">// TSBK carried src=0 is POSTed with source_id=0 (never backfilled), so the feed</span>
<span class="c">// reports what the control channel actually saw — the way SDRtrunk's grant log</span>
<span class="c">// does. That makes it distinct from the completed-call webhook, whose source</span>
<span class="c">// RID depends on a separate voice-side decode landing.</span>
</code></pre></div></div>

<p>So the grant webhook is <em>honest about the control channel</em>: <code class="language-plaintext highlighter-rouge">source_id=0</code> means
“the grant didn’t carry a source,” not “we don’t know yet.” The completed-call
webhook, by contrast, may fill the source from the voice-side decode. Neither is
wrong — they’re reporting different facts at different moments, and knowing which
you’re consuming keeps a dashboard from mislabelling a call.</p>

<figure class="lab-figure">
<svg viewBox="0 0 660 150" width="660" height="150" role="img" aria-label="A timeline of one call. At call setup the control channel decodes a grant and the grant webhook fires immediately with source-id as decoded. Voice frames then flow for the duration of the call. At call end the completed-call webhook fires with the finished recording and optional audio. The grant webhook is early and audio-free; the call webhook is late and can carry audio.">
  <line x1="40" y1="80" x2="620" y2="80" stroke="var(--fg-muted)" />
  <polygon points="620,76 630,80 620,84" fill="var(--fg-muted)" />
  <line x1="120" y1="70" x2="120" y2="90" stroke="var(--accent)" />
  <text x="120" y="60" text-anchor="middle" fill="var(--accent)" font-size="10">grant decoded</text>
  <rect x="60" y="98" width="120" height="30" rx="6" fill="none" stroke="var(--accent)" />
  <text x="120" y="117" text-anchor="middle" fill="var(--accent)" font-size="9">grant webhook →</text>
  <text x="330" y="72" text-anchor="middle" fill="var(--fg-muted)" font-size="9">voice frames flow (seconds to minutes)</text>
  <line x1="200" y1="80" x2="470" y2="80" stroke="currentColor" stroke-dasharray="3 3" />
  <line x1="520" y1="70" x2="520" y2="90" stroke="currentColor" />
  <text x="520" y="60" text-anchor="middle" fill="currentColor" font-size="10">call ends</text>
  <rect x="470" y="98" width="140" height="30" rx="6" fill="none" stroke="currentColor" />
  <text x="540" y="113" text-anchor="middle" fill="currentColor" font-size="9">call webhook →</text>
  <text x="540" y="124" text-anchor="middle" fill="var(--fg-muted)" font-size="8">+ optional audio</text>
</svg>
<figcaption>The grant webhook fires at call setup with the control channel's view; the completed-call webhook fires at call end with the finished recording. Same call, two moments, two truths.</figcaption>
</figure>

<h2 id="one-schema-three-surfaces">One schema, three surfaces</h2>

<p>The grant payload isn’t a bespoke shape — it’s the same <code class="language-plaintext highlighter-rouge">GrantDTO</code> that the REST
snapshot and the live SSE stream already publish, plus a discriminator and a
timestamp. That’s a deliberate integration property: a consumer that already
parses <code class="language-plaintext highlighter-rouge">GET /api/v1/grants</code> needs no new code to consume the push:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/grantwebhook.go (shape)</span>
<span class="k">type</span> <span class="n">grantWebhookPayload</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Event</span>       <span class="kt">string</span> <span class="s">`json:"event"`</span> <span class="c">// always "grant"</span>
    <span class="n">System</span>      <span class="kt">string</span> <span class="s">`json:"system"`</span>
    <span class="n">Protocol</span>    <span class="kt">string</span> <span class="s">`json:"protocol"`</span>
    <span class="n">GroupID</span>     <span class="kt">uint32</span> <span class="s">`json:"group_id"`</span>
    <span class="n">SourceID</span>    <span class="kt">uint32</span> <span class="s">`json:"source_id"`</span>
    <span class="n">FrequencyHz</span> <span class="kt">uint32</span> <span class="s">`json:"frequency_hz"`</span>
    <span class="n">ChannelID</span>   <span class="kt">uint8</span>  <span class="s">`json:"channel_id,omitempty"`</span>
    <span class="n">RFSSID</span>      <span class="kt">uint8</span>  <span class="s">`json:"rfss_id,omitempty"`</span>
    <span class="n">SiteID</span>      <span class="kt">uint8</span>  <span class="s">`json:"site_id,omitempty"`</span>
    <span class="n">NAC</span>         <span class="kt">uint16</span> <span class="s">`json:"nac,omitempty"`</span>
    <span class="n">Encrypted</span>   <span class="kt">bool</span>   <span class="s">`json:"encrypted,omitempty"`</span>
    <span class="n">Emergency</span>   <span class="kt">bool</span>   <span class="s">`json:"emergency,omitempty"`</span>
    <span class="n">DataCall</span>    <span class="kt">bool</span>   <span class="s">`json:"data_call,omitempty"`</span>
    <span class="n">Individual</span>  <span class="kt">bool</span>   <span class="s">`json:"individual,omitempty"`</span>
    <span class="n">AlgorithmID</span> <span class="kt">uint8</span>  <span class="s">`json:"algorithm_id,omitempty"`</span>
    <span class="n">KeyID</span>       <span class="kt">uint16</span> <span class="s">`json:"key_id,omitempty"`</span>
    <span class="n">At</span>          <span class="kt">string</span> <span class="s">`json:"at"`</span> <span class="c">// RFC3339 decode time</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two fields carry most of the automation value. <code class="language-plaintext highlighter-rouge">Emergency</code> is <code class="language-plaintext highlighter-rouge">true</code> when the
grant flagged an emergency — the natural trigger for a page or an alert.
<code class="language-plaintext highlighter-rouge">Encrypted</code> (with <code class="language-plaintext highlighter-rouge">AlgorithmID</code> / <code class="language-plaintext highlighter-rouge">KeyID</code>) lets a dashboard mark a call it can hear
about but can’t hear. The P25 site-identity fields (<code class="language-plaintext highlighter-rouge">nac</code>, <code class="language-plaintext highlighter-rouge">rfss_id</code>, <code class="language-plaintext highlighter-rouge">site_id</code>)
are <code class="language-plaintext highlighter-rouge">omitempty</code>, so their <em>presence</em> means “this value is known” — a consumer
treats a missing <code class="language-plaintext highlighter-rouge">nac</code> as “not a P25 grant,” not “NAC zero.” And <code class="language-plaintext highlighter-rouge">at</code> is an
offset-bearing RFC3339 in the operator’s configured timezone, so a grant’s
timestamp is unambiguous even across zones — the same timezone helper the audio
webhooks and the call log use, so one call reads with one wall-clock everywhere.</p>

<h2 id="the-bounded-queue-the-rule-that-protects-the-decoder">The bounded queue: the rule that protects the decoder</h2>

<p>This is the part that makes a grant webhook safe on a 24/7 daemon rather than a
liability. A busy control channel emits <strong>thousands of grants an hour</strong>. If your
webhook endpoint wedges — the receiving service restarts, its disk fills, the
network partitions — a naive design would block on the POST, and that backpressure
would climb straight back through the event loop into the decoder. So the grant
webhook is built to fail <em>forward</em>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/grantwebhook.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">w</span> <span class="o">*</span><span class="n">GrantWebhook</span><span class="p">)</span> <span class="n">enqueue</span><span class="p">(</span><span class="n">g</span> <span class="n">trunking</span><span class="o">.</span><span class="n">Grant</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">w</span><span class="o">.</span><span class="n">filter</span><span class="o">.</span><span class="n">Accepts</span><span class="p">(</span><span class="n">g</span><span class="o">.</span><span class="n">System</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="n">p</span> <span class="o">:=</span> <span class="n">grantWebhookPayloadFrom</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">at</span><span class="p">,</span> <span class="n">w</span><span class="o">.</span><span class="n">loc</span><span class="p">)</span>
    <span class="k">select</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">w</span><span class="o">.</span><span class="n">jobs</span> <span class="o">&lt;-</span> <span class="n">p</span><span class="o">:</span> <span class="c">// room in the 256-deep queue</span>
        <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">();</span> <span class="n">w</span><span class="o">.</span><span class="n">queued</span><span class="o">++</span><span class="p">;</span> <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
    <span class="k">default</span><span class="o">:</span> <span class="c">// queue full — a wedged endpoint — drop the OLDEST discipline</span>
        <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">();</span> <span class="n">w</span><span class="o">.</span><span class="n">dropped</span><span class="o">++</span><span class="p">;</span> <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
        <span class="n">w</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Warn</span><span class="p">(</span><span class="s">"broadcast/grantwebhook: queue full, dropping grant"</span><span class="p">,</span>
            <span class="s">"name"</span><span class="p">,</span> <span class="n">w</span><span class="o">.</span><span class="n">name</span><span class="p">,</span> <span class="s">"system"</span><span class="p">,</span> <span class="n">g</span><span class="o">.</span><span class="n">System</span><span class="p">,</span> <span class="s">"tg"</span><span class="p">,</span> <span class="n">g</span><span class="o">.</span><span class="n">GroupID</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">default</code> branch is the whole point: a non-blocking send onto a bounded
<code class="language-plaintext highlighter-rouge">jobs</code> channel means a full queue <strong>drops the grant and moves on</strong> instead of
stalling. This is the exact same discipline the call Manager uses — bound the
backlog, shed load under pressure, never let an outbound sink apply backpressure
to decode. The queue is 256 deep, which absorbs a brief endpoint hiccup without
dropping anything; past that, a chronically dead endpoint costs you dropped grants
(counted, logged) but <em>never</em> a missed control-channel decode or a stalled
recorder.</p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong>Subscribe at construction, not at <code class="language-plaintext highlighter-rouge">Run</code>.</strong> <code class="language-plaintext highlighter-rouge">NewGrantWebhook</code> calls
<code class="language-plaintext highlighter-rouge">Bus.Subscribe()</code> and starts the worker before returning, so grants decoded in the
window between wiring the sink and calling <code class="language-plaintext highlighter-rouge">Run</code> are queued, not lost. Start-up
ordering can’t drop the first grants of a session.</li>
  <li><strong>One worker, serial delivery.</strong> A single goroutine drains <code class="language-plaintext highlighter-rouge">jobs</code> and calls
<code class="language-plaintext highlighter-rouge">sendWithRetry</code>, so grants are delivered in order and a slow endpoint can’t spawn
unbounded in-flight POSTs — the queue is the only buffer, and it’s bounded.</li>
  <li><strong>Drop is a counter, not a panic.</strong> Overflow increments <code class="language-plaintext highlighter-rouge">dropped</code> and warns;
there’s no error path back to the caller, because the caller is the event loop and
it must never block.</li>
</ul>

<h2 id="delivery-subscribe-retry-back-off">Delivery: subscribe, retry, back off</h2>

<p>Once a grant is on the queue, delivery is the same retry discipline you saw for the
audio backends, scaled to a single worker. <code class="language-plaintext highlighter-rouge">sendWithRetry</code> attempts the POST up to
<code class="language-plaintext highlighter-rouge">maxRetries+1</code> times (default 4) with exponential backoff (default 2s, doubling),
each attempt bounded by a 30-second timeout:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/grantwebhook.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">w</span> <span class="o">*</span><span class="n">GrantWebhook</span><span class="p">)</span> <span class="n">sendWithRetry</span><span class="p">(</span><span class="n">p</span> <span class="n">grantWebhookPayload</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">backoff</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">retryBase</span>
    <span class="k">for</span> <span class="n">attempt</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">attempt</span> <span class="o">&lt;=</span> <span class="n">w</span><span class="o">.</span><span class="n">maxRetries</span><span class="p">;</span> <span class="n">attempt</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">(),</span> <span class="m">30</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
        <span class="n">err</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>
        <span class="n">cancel</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">();</span> <span class="n">w</span><span class="o">.</span><span class="n">sent</span><span class="o">++</span><span class="p">;</span> <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
            <span class="k">return</span>
        <span class="p">}</span>
        <span class="c">// …warn, then sleep backoff and double it</span>
    <span class="p">}</span>
    <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Lock</span><span class="p">();</span> <span class="n">w</span><span class="o">.</span><span class="n">failed</span><span class="o">++</span><span class="p">;</span> <span class="n">w</span><span class="o">.</span><span class="n">mu</span><span class="o">.</span><span class="n">Unlock</span><span class="p">()</span>
    <span class="n">w</span><span class="o">.</span><span class="n">log</span><span class="o">.</span><span class="n">Error</span><span class="p">(</span><span class="s">"broadcast/grantwebhook: giving up on grant"</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There’s a design tension worth naming here. The worker is <em>single</em> and delivery is
<em>serial</em>, so while <code class="language-plaintext highlighter-rouge">sendWithRetry</code> is backing off (2s + 4s + 8s = up to 14 seconds
for one wedged grant), it isn’t draining the queue. That’s intentional: it couples
“the endpoint is slow” to “the queue fills,” which trips the bounded-queue drop —
so a dead endpoint self-limits to dropped grants rather than an ever-growing
backlog of doomed retries. The retry budget handles a <em>flaky</em> endpoint; the bounded
queue handles a <em>dead</em> one. Together they mean the grant webhook needs no operator
intervention when a consumer misbehaves.</p>

<h2 id="operating-it-auth-filtering-and-health">Operating it: auth, filtering, and health</h2>

<p>The config surface is small. <code class="language-plaintext highlighter-rouge">url</code> is required; <code class="language-plaintext highlighter-rouge">auth_header</code> is sent verbatim as
the <code class="language-plaintext highlighter-rouge">Authorization</code> header — so <code class="language-plaintext highlighter-rouge">auth_header: "Bearer &lt;token&gt;"</code> authenticates to
your endpoint, and (as with every credential in this series) it belongs in an
environment file the systemd unit references, not inline in <code class="language-plaintext highlighter-rouge">config.yaml</code>. The
<code class="language-plaintext highlighter-rouge">systems</code> list scopes the feed to named trunked systems; empty pushes every
system. A feed with <code class="language-plaintext highlighter-rouge">enabled: false</code> is parsed but skipped, so you can stage a
webhook before turning it on.</p>

<p>Health reads through the same door as the audio backends —
<code class="language-plaintext highlighter-rouge">GET /api/v1/broadcast</code>, which returns <code class="language-plaintext highlighter-rouge">{"enabled": false}</code> when nothing is
configured so a UI renders a stable shape — and the grant webhook contributes its
own four counters:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/broadcast/grantwebhook.go (shape)</span>
<span class="k">type</span> <span class="n">GrantWebhookStats</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Name</span>    <span class="kt">string</span> <span class="s">`json:"name"`</span>
    <span class="n">Queued</span>  <span class="kt">int</span>    <span class="s">`json:"queued"`</span>
    <span class="n">Dropped</span> <span class="kt">int</span>    <span class="s">`json:"dropped"`</span> <span class="c">// overflow — a wedged endpoint</span>
    <span class="n">Sent</span>    <span class="kt">int</span>    <span class="s">`json:"sent"`</span>
    <span class="n">Failed</span>  <span class="kt">int</span>    <span class="s">`json:"failed"`</span>  <span class="c">// exhausted retries</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The operating read: <code class="language-plaintext highlighter-rouge">sent</code> climbing with your grant rate is healthy. A rising
<code class="language-plaintext highlighter-rouge">dropped</code> means the endpoint is chronically slow and the queue is overflowing — the
consumer can’t keep up. A rising <code class="language-plaintext highlighter-rouge">failed</code> means individual POSTs are exhausting
their retries — usually a wrong <code class="language-plaintext highlighter-rouge">auth_header</code> or a 4xx from the endpoint. As
everywhere in this series, these are numbers you <em>alert on</em>, because a grant
webhook that stops firing is otherwise completely silent — the decoder keeps
decoding, the counters just stop moving.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>We’ve now built the whole outbound surface — audio to four aggregators, grants to
a webhook — on top of a daemon that so far runs wherever you launch it. The last
three posts make <em>where it runs</em> robust.
<a href="/blog/deep-dives/running-it-for-real-12-docker-usb/">Part 12</a>
containerizes the daemon and passes an RTL-SDR through to it — the udev rules, the
<code class="language-plaintext highlighter-rouge">--device</code> mapping, and the USB-reset dance that separate a container that sees a
dongle from one that just thinks it should. Cross-reference the
<a href="/learn/deployment/">Containers &amp; Deployment</a> module for
first principles.</p>

<h2 id="faq">FAQ</h2>

<p><strong>When exactly does the grant webhook fire relative to the call?</strong>
At call setup — the instant the control channel decodes the grant, before any voice
frame is processed and seconds before the completed-call webhook. If you want the
earliest possible signal that a talkgroup keyed up (for a live board or a
low-latency alert), it’s the grant webhook, not the call webhook.</p>

<p><strong>Why does a grant sometimes have <code class="language-plaintext highlighter-rouge">source_id: 0</code>?</strong>
Because the grant genuinely carried no source RID. The feed reports the control
channel exactly as decoded and never backfills, so <code class="language-plaintext highlighter-rouge">source_id: 0</code> means “not
present in the grant,” not “unknown, check back later.” The completed-call webhook may
fill the source from the voice decode instead — a different fact at a different
moment.</p>

<p><strong>What happens to grants if my endpoint goes down?</strong>
Individual POSTs retry up to four times with exponential backoff. If the endpoint
stays down, the single delivery worker stalls on backoff, the 256-deep queue fills,
and further grants are dropped (counted in <code class="language-plaintext highlighter-rouge">dropped</code>, logged as warnings) — never
queued unbounded and never allowed to stall the decoder. When the endpoint recovers,
delivery resumes; the dropped grants are gone, by design.</p>

<p><strong>Can I consume the same data without a webhook?</strong>
Yes — that’s the point of the shared schema. <code class="language-plaintext highlighter-rouge">GET /api/v1/grants</code> gives the snapshot
and the <code class="language-plaintext highlighter-rouge">KindGrant</code> SSE stream gives a live feed, both in the same <code class="language-plaintext highlighter-rouge">GrantDTO</code> shape
plus the webhook’s <code class="language-plaintext highlighter-rouge">event</code>/<code class="language-plaintext highlighter-rouge">at</code> fields. The webhook is the push option for consumers
that can’t poll or subscribe.</p>

<p><strong>Does the grant webhook carry audio?</strong>
No. A grant is a control-channel event with no recording attached — audio doesn’t
exist yet at call setup. If you need the audio, use the completed-call webhook with
<code class="language-plaintext highlighter-rouge">include_audio: true</code>, which base64-embeds the finished MP3.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 11 of 14</strong> · ←
<a href="/blog/deep-dives/running-it-for-real-10-broadcast-backends-ii/">Part 10: Broadcast Backends II — RdioScanner, OpenMHz, Icecast</a>
· Next →
<a href="/blog/deep-dives/running-it-for-real-12-docker-usb/">Part 12: Docker &amp; RTL-SDR USB Pass-Through</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="running-it-for-real" /><category term="broadcast" /><category term="webhook" /><category term="integrations" /><category term="operations" /><category term="go" /><summary type="html"><![CDATA[Firing an HTTP webhook the instant a control-channel grant is decoded — the push form of GET /api/v1/grants for home automation, dashboards, and alerting, with a bounded queue that drops before it ever stalls the decoder, a shared JSON schema, and per-grant retry/backoff.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Hunt, Part 11: Naming the Unknown</title><link href="https://gophertrunk.org/blog/deep-dives/the-hunt-11-naming-the-unknown/" rel="alternate" type="text/html" title="The Hunt, Part 11: Naming the Unknown" /><published>2026-08-14T00:00:00-05:00</published><updated>2026-08-14T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/deep-dives/the-hunt-11-naming-the-unknown</id><content type="html" xml:base="https://gophertrunk.org/blog/deep-dives/the-hunt-11-naming-the-unknown/"><![CDATA[<p><em>Part 11 of <strong>The Hunt</strong>. Our carrier is fully mapped now — sites, control
channels, a band plan, a fistful of talkgroups — and it reproduces from a
recording. What it still lacks is a <strong>name</strong>. The system prints as
<code class="language-plaintext highlighter-rouge">Unknown-p25-BEE00-2A9</code>; every talkgroup is a bare decimal. This post is about
the last, most human step: turning those numbers into labels. It is also the
most honest one in the series, because naming a blind discovery is exactly as
much as the signal actually tells you — and not one word more.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> A discovery arrives as numbers, and GopherTrunk names as much of it
as the RF can justify. A system gets a <strong>synthesized handle</strong> from its protocol
and identity (<code class="language-plaintext highlighter-rouge">Unknown-p25-&lt;WACN&gt;-&lt;SYSID&gt;</code>) until an operator assigns a real
<code class="language-plaintext highlighter-rouge">Name</code>. Every classified carrier gets a <strong>Name / Service / Purpose</strong> triple —
<code class="language-plaintext highlighter-rouge">Service</code>/<code class="language-plaintext highlighter-rouge">Purpose</code> from the frequency allocation table, <code class="language-plaintext highlighter-rouge">Name</code> from the
decoded protocol, else a best-guess from the signal reference catalog, else the
modulation class. But talkgroups stay <strong>numeric</strong>: a blind decode sees a
talkgroup’s id and activity, never its meaning, so those fields are left blank
for the operator or RadioReference to fill. Naming is aliasing, and the honesty
is knowing where aliasing stops.</p>
</blockquote>

<p><strong>Key takeaways</strong></p>

<ul>
  <li><strong>A system always has a stable handle.</strong> <code class="language-plaintext highlighter-rouge">DisplayName()</code> returns the operator’s
<code class="language-plaintext highlighter-rouge">Name</code> when set, otherwise a synthesized <code class="language-plaintext highlighter-rouge">Unknown-&lt;proto&gt;-&lt;WACN&gt;-&lt;SYSID&gt;</code> — so
an unnamed discovery still round-trips and diffs cleanly.</li>
  <li><strong>Every carrier is named three ways.</strong> <code class="language-plaintext highlighter-rouge">nameSignal</code> fills <code class="language-plaintext highlighter-rouge">Name</code> (what it is),
<code class="language-plaintext highlighter-rouge">Service</code> (what the frequency is allocated to), and <code class="language-plaintext highlighter-rouge">Purpose</code> (what that
service does) — a consolidated “what is this” for decodable and undecodable
signals alike.</li>
  <li><strong>Naming falls back, never guesses wildly.</strong> Decoded protocol → reference-catalog
best guess (above a score floor) → modulation class. An undecoded carrier only
earns a catalog name when the match clears <code class="language-plaintext highlighter-rouge">nameRefFloor</code> (0.40).</li>
  <li><strong>Talkgroups are deliberately unnamed.</strong> A blind discovery knows a talkgroup’s
decimal, hex, and activity count — never its alpha tag — so those fields ship
blank for a human or RR to complete.</li>
</ul>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>What it does</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">nameSignal</code></td>
      <td>fill Name / Service / Purpose for any carrier</td>
      <td><code class="language-plaintext highlighter-rouge">internal/hunt/naming.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DisplayName</code></td>
      <td>operator name, else a synthesized handle</td>
      <td><code class="language-plaintext highlighter-rouge">internal/hunt/system.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DiscoveredTalkgroup</code></td>
      <td>numeric id + activity, descriptive fields blank</td>
      <td><code class="language-plaintext highlighter-rouge">internal/hunt/system.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sigref.Lookup</code> / <code class="language-plaintext highlighter-rouge">.Rank</code></td>
      <td>frequency allocation + catalog best guess</td>
      <td><code class="language-plaintext highlighter-rouge">internal/siglab/sigref</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">NetworkReport</code></td>
      <td>flatten the named system for the shared renderer</td>
      <td><code class="language-plaintext highlighter-rouge">internal/hunt/report.go</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">modClassFor</code></td>
      <td>survey class → blind modulation class for ranking</td>
      <td><code class="language-plaintext highlighter-rouge">internal/hunt/naming.go</code></td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The two things a discovery is missing</strong> — a system name and talkgroup names.</li>
  <li><strong>Naming a system</strong> — the synthesized handle and why it’s shaped that way.</li>
  <li><strong>Naming a carrier</strong> — the Name / Service / Purpose triple and its fallbacks.</li>
  <li><strong>Why talkgroups stay numeric</strong> — the honest limit of a blind decode.</li>
  <li><strong>Rendering the named system</strong> — flattening it for the report.</li>
</ul>

<h2 id="the-two-things-a-discovery-is-missing">The two things a discovery is missing</h2>

<p>Walk back through the series and notice what naming is <em>not</em>. The
<a href="/blog/deep-dives/the-hunt-02-wideband-sweep-engine/">sweep</a>
gave us frequencies. The <a href="/blog/deep-dives/the-hunt-04-classifying-a-signal/">identify</a>
gave us a protocol. The <a href="/blog/deep-dives/the-hunt-01-what-discovery-means/">map</a>
gave us identity fields — WACN, System ID, NAC — and a talkgroup list. All of
that is <em>measured</em>: it came off the air as bits. Names don’t come off the air.
There is no RF field that says “this is the county fire dispatch system” or “this
talkgroup is Engine 12.” Naming is the layer where measured numbers meet a
<strong>human catalog</strong>, and the discipline is to name only what the catalog can
actually justify from what we measured.</p>

<p>Two things want names: the system as a whole, and each talkgroup. GopherTrunk
handles them very differently, and the difference is the whole lesson.</p>

<h2 id="naming-a-system">Naming a system</h2>

<p>A <code class="language-plaintext highlighter-rouge">DiscoveredSystem</code> always needs <em>a</em> label — golden tests diff on it, exports
name files after it, the cockpit lists it. So <code class="language-plaintext highlighter-rouge">DisplayName</code> never returns empty.
It returns the operator’s <code class="language-plaintext highlighter-rouge">Name</code> if one was assigned, and otherwise synthesizes a
stable handle from the identity we did measure:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/hunt/system.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">DiscoveredSystem</span><span class="p">)</span> <span class="n">DisplayName</span><span class="p">()</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">s</span><span class="o">.</span><span class="n">Name</span> <span class="o">!=</span> <span class="s">""</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">s</span><span class="o">.</span><span class="n">Name</span>
    <span class="p">}</span>
    <span class="n">proto</span> <span class="o">:=</span> <span class="n">s</span><span class="o">.</span><span class="n">Protocol</span>
    <span class="k">if</span> <span class="n">proto</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
        <span class="n">proto</span> <span class="o">=</span> <span class="s">"unknown"</span>
    <span class="p">}</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">s</span><span class="o">.</span><span class="n">WACN</span> <span class="o">!=</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="n">s</span><span class="o">.</span><span class="n">SystemID</span> <span class="o">!=</span> <span class="m">0</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"Unknown-%s-%05X-%03X"</span><span class="p">,</span> <span class="n">proto</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">WACN</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">SystemID</span><span class="p">)</span>
    <span class="k">case</span> <span class="n">s</span><span class="o">.</span><span class="n">SystemID</span> <span class="o">!=</span> <span class="m">0</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"Unknown-%s-%03X"</span><span class="p">,</span> <span class="n">proto</span><span class="p">,</span> <span class="n">s</span><span class="o">.</span><span class="n">SystemID</span><span class="p">)</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"Unknown-%s"</span><span class="p">,</span> <span class="n">proto</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three things are true about this handle and all three are deliberate. It is
<strong>stable</strong> — the same system produces the same string every run, so it diffs and
re-imports without churn. It is <strong>honest</strong> — the leading <code class="language-plaintext highlighter-rouge">Unknown-</code> says nobody
has confirmed a real name yet. And it <strong>degrades</strong> — with full P25 identity you
get <code class="language-plaintext highlighter-rouge">Unknown-p25-BEE00-2A9</code>; with only a system id you get <code class="language-plaintext highlighter-rouge">Unknown-p25-2A9</code>; with
neither, just <code class="language-plaintext highlighter-rouge">Unknown-p25</code>. It carries exactly as much identity as we have and
no more. The moment an operator (or a RadioReference match) supplies a real name,
<code class="language-plaintext highlighter-rouge">Name</code> is set and the synthesized handle disappears — but until then, the system
has a stable, self-describing identity.</p>

<h2 id="naming-a-carrier-name-service-purpose">Naming a carrier: Name, Service, Purpose</h2>

<p>Down at the individual-carrier level — the survey inventory, every detected
signal — naming is richer, because a frequency is not just a number: it sits in an
<strong>allocation table</strong>. <code class="language-plaintext highlighter-rouge">nameSignal</code> fills a three-part label on every
<code class="language-plaintext highlighter-rouge">DetectedSignal</code>, decodable or not:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/hunt/naming.go (shape)</span>
<span class="c">// nameSignal fills the consolidated inventory fields — Name, Service, Purpose —</span>
<span class="c">// for any signal. Service/Purpose come from the frequency allocation; Name</span>
<span class="c">// prefers the decoded protocol, then a best-guess from the reference catalog,</span>
<span class="c">// then the modulation class.</span>
<span class="k">func</span> <span class="n">nameSignal</span><span class="p">(</span><span class="n">ds</span> <span class="o">*</span><span class="n">DetectedSignal</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">a</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">sigref</span><span class="o">.</span><span class="n">Lookup</span><span class="p">(</span><span class="n">ds</span><span class="o">.</span><span class="n">FreqHz</span><span class="p">);</span> <span class="n">ok</span> <span class="p">{</span>
        <span class="n">ds</span><span class="o">.</span><span class="n">Service</span> <span class="o">=</span> <span class="n">a</span><span class="o">.</span><span class="n">Service</span>   <span class="c">// what the frequency is allocated to</span>
        <span class="n">ds</span><span class="o">.</span><span class="n">Purpose</span> <span class="o">=</span> <span class="n">a</span><span class="o">.</span><span class="n">Purpose</span>   <span class="c">// what that service is used for</span>
    <span class="p">}</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">ds</span><span class="o">.</span><span class="n">Trunking</span> <span class="o">!=</span> <span class="no">nil</span> <span class="o">&amp;&amp;</span> <span class="n">ds</span><span class="o">.</span><span class="n">Trunking</span><span class="o">.</span><span class="n">Protocol</span> <span class="o">!=</span> <span class="s">""</span><span class="o">:</span>
        <span class="c">// decoded → the protocol's display name (P25, DMR, …)</span>
    <span class="k">case</span> <span class="nb">len</span><span class="p">(</span><span class="n">ds</span><span class="o">.</span><span class="n">Pages</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span><span class="o">:</span>
        <span class="n">ds</span><span class="o">.</span><span class="n">Name</span> <span class="o">=</span> <span class="n">ds</span><span class="o">.</span><span class="n">Pages</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">Protocol</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="c">// undecoded → rank against the signal reference catalog on baud,</span>
        <span class="c">// modulation, bandwidth, and centre; take the top match only if it</span>
        <span class="c">// clears the keep floor, else fall back to the modulation class.</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The precedence is the interesting part. <code class="language-plaintext highlighter-rouge">Service</code> and <code class="language-plaintext highlighter-rouge">Purpose</code> come from <em>where
the carrier lives</em> — the frequency allocation, which is known for a band whether
or not we decoded anything. <code class="language-plaintext highlighter-rouge">Name</code> comes from <em>what the carrier is</em>, and it walks
a strict ladder: a decoded protocol names itself outright; a paging carrier is
named by its page protocol; and an <strong>undecoded</strong> carrier is ranked against the
signal reference catalog on its measured features (baud, modulation, bandwidth,
centre) and named only if the best match clears a floor:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/hunt/naming.go (shape)</span>
<span class="k">const</span> <span class="n">nameRefFloor</span> <span class="o">=</span> <span class="m">0.40</span> <span class="c">// below this, name by modulation class instead</span>
<span class="k">if</span> <span class="n">m</span> <span class="o">:=</span> <span class="n">sigref</span><span class="o">.</span><span class="n">Rank</span><span class="p">(</span><span class="n">obs</span><span class="p">,</span> <span class="m">1</span><span class="p">);</span> <span class="nb">len</span><span class="p">(</span><span class="n">m</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="n">m</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">Score</span> <span class="o">&gt;=</span> <span class="n">nameRefFloor</span> <span class="p">{</span>
    <span class="n">ds</span><span class="o">.</span><span class="n">Name</span> <span class="o">=</span> <span class="n">m</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">Entry</span><span class="o">.</span><span class="n">DisplayName</span>
    <span class="k">if</span> <span class="n">ds</span><span class="o">.</span><span class="n">Confidence</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span> <span class="c">// fill confidence for an undecoded/wideband row</span>
        <span class="n">ds</span><span class="o">.</span><span class="n">Confidence</span> <span class="o">=</span> <span class="n">m</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">Score</span>
    <span class="p">}</span>
<span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="n">ds</span><span class="o">.</span><span class="n">Wideband</span> <span class="p">{</span>
    <span class="n">ds</span><span class="o">.</span><span class="n">Name</span> <span class="o">=</span> <span class="s">"wideband signal"</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="n">ds</span><span class="o">.</span><span class="n">Name</span> <span class="o">=</span> <span class="kt">string</span><span class="p">(</span><span class="n">ds</span><span class="o">.</span><span class="n">Class</span><span class="p">)</span> <span class="c">// e.g. "nbfm" — the honest floor</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That floor is the guard against confident nonsense. A weak, ambiguous carrier
whose best catalog match scores 0.2 is <em>not</em> named “P25 control” — it is named
<code class="language-plaintext highlighter-rouge">nbfm</code>, its modulation class, which is all we actually know. Naming here is a best
guess with a confidence attached, and below the floor the honest label is the
mechanism, not the meaning.</p>

<figure class="lab-figure">
<svg viewBox="0 0 640 220" width="640" height="220" role="img" aria-label="The carrier naming ladder. A carrier's Service and Purpose come from a frequency allocation lookup. Its Name is chosen by precedence: a decoded protocol names itself; else a paging carrier is named by its page protocol; else the signal is ranked against the reference catalog and named by the top match only if its score clears the 0.40 floor; else it falls back to its modulation class.">
  <rect x="10" y="90" width="120" height="40" rx="6" fill="none" stroke="var(--fg-muted)" />
  <text x="70" y="106" text-anchor="middle" fill="var(--fg-muted)" font-size="10">freq allocation</text>
  <text x="70" y="120" text-anchor="middle" fill="var(--fg-muted)" font-size="9">Service · Purpose</text>
  <line x1="130" y1="110" x2="160" y2="110" stroke="currentColor" /><polygon points="160,106 170,110 160,114" fill="currentColor" />
  <rect x="170" y="18" width="180" height="30" rx="5" fill="none" stroke="var(--accent)" />
  <text x="260" y="37" text-anchor="middle" fill="var(--accent)" font-size="10">decoded protocol → Name</text>
  <rect x="170" y="58" width="180" height="30" rx="5" fill="none" stroke="currentColor" />
  <text x="260" y="77" text-anchor="middle" fill="currentColor" font-size="10">paging → page protocol</text>
  <rect x="170" y="98" width="180" height="30" rx="5" fill="none" stroke="currentColor" />
  <text x="260" y="114" text-anchor="middle" fill="currentColor" font-size="10">catalog rank ≥ 0.40</text>
  <text x="260" y="125" text-anchor="middle" fill="var(--fg-muted)" font-size="8">best guess + confidence</text>
  <rect x="170" y="138" width="180" height="30" rx="5" fill="none" stroke="var(--fg-muted)" />
  <text x="260" y="157" text-anchor="middle" fill="var(--fg-muted)" font-size="10">else → modulation class</text>
  <line x1="350" y1="33" x2="470" y2="86" stroke="currentColor" /><polygon points="466,83 476,87 467,92" fill="currentColor" />
  <line x1="350" y1="73" x2="470" y2="90" stroke="currentColor" /><polygon points="466,87 476,91 466,95" fill="currentColor" />
  <line x1="350" y1="113" x2="470" y2="98" stroke="currentColor" /><polygon points="466,94 476,98 466,102" fill="currentColor" />
  <line x1="350" y1="153" x2="470" y2="106" stroke="currentColor" /><polygon points="467,101 476,106 466,110" fill="currentColor" />
  <rect x="476" y="80" width="150" height="40" rx="6" fill="none" stroke="var(--accent)" />
  <text x="551" y="98" text-anchor="middle" fill="var(--accent)" font-size="11">Name</text>
  <text x="551" y="112" text-anchor="middle" fill="var(--fg-muted)" font-size="9">first rule that fires wins</text>
  <text x="320" y="200" text-anchor="middle" fill="var(--fg-muted)" font-size="10">the ladder names as much as the measurement justifies, then stops at the modulation class</text>
</svg>
<figcaption>Every carrier gets a Service/Purpose from its frequency and a Name from a strict precedence ladder — decoded, paged, catalog-ranked above a floor, or the honest modulation class.</figcaption>
</figure>

<h2 id="why-talkgroups-stay-numeric">Why talkgroups stay numeric</h2>

<p>Here is where naming refuses to overreach. A <code class="language-plaintext highlighter-rouge">DiscoveredTalkgroup</code> carries a
decimal, a hex, an encrypted flag, an activity count, and a first-seen time — and
nothing else:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/hunt/system.go (shape)</span>
<span class="c">// DiscoveredTalkgroup is one talkgroup observed on the control channel. On a</span>
<span class="c">// blind discovery only the numeric id and activity are known; the descriptive</span>
<span class="c">// fields are left blank for the operator (or RR) to fill in.</span>
<span class="k">type</span> <span class="n">DiscoveredTalkgroup</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Dec</span>       <span class="kt">uint32</span>    <span class="s">`json:"dec"`</span>
    <span class="n">Hex</span>       <span class="kt">string</span>    <span class="s">`json:"hex"`</span>
    <span class="n">Encrypted</span> <span class="kt">bool</span>      <span class="s">`json:"encrypted,omitempty"`</span>
    <span class="n">Count</span>     <span class="kt">int</span>       <span class="s">`json:"count"`</span>
    <span class="n">FirstSeen</span> <span class="n">time</span><span class="o">.</span><span class="n">Time</span> <span class="s">`json:"first_seen"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There is no <code class="language-plaintext highlighter-rouge">AlphaTag</code>, no <code class="language-plaintext highlighter-rouge">Description</code>, no <code class="language-plaintext highlighter-rouge">Group</code> — because a talkgroup’s
<em>name</em> is nowhere in the RF. The control channel broadcasts that talkgroup 101 was
granted a channel; it never broadcasts that 101 is “Fire Dispatch.” That mapping
lives in a human database, and inventing it would be lying. So the exporter emits
those columns blank for the operator or RadioReference to fill — the same reason
the RR submission package says, right at the top, “a blind discovery cannot name
talkgroups.” The system counts a talkgroup’s activity so you know which ids matter
most; it will not pretend to know what they are.</p>

<p>This is also why the <a href="/blog/series/trunking-engine/">Trunking Engine</a>
and <a href="/import.html">RadioReference import</a> exist on the other
side: naming is the seam where an operator’s knowledge joins the machine’s
measurements. GopherTrunk names what it can prove and leaves a clearly-marked
blank for what it can’t.</p>

<h3 id="how-that-principle-shaped-the-go-code">How that principle shaped the Go code</h3>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">nameSignal</code> never overrides.</strong> It only fills fields the router left unset and
is safe to call once per signal, so a decoded protocol’s own name always wins
over a catalog guess.</li>
  <li><strong>The floor is a constant, not a mood.</strong> <code class="language-plaintext highlighter-rouge">nameRefFloor</code> (0.40) is the single
knob separating “named best guess” from “modulation class,” matching the
wideband survey’s keep floor so the two agree.</li>
  <li><strong>Missing identity degrades the handle, it doesn’t crash it.</strong> <code class="language-plaintext highlighter-rouge">DisplayName</code>’s
switch means a system with partial identity still produces a usable, stable
string — the exporters never see an empty name.</li>
</ul>

<h2 id="rendering-the-named-system">Rendering the named system</h2>

<p>Once the system carries whatever names it has earned, <code class="language-plaintext highlighter-rouge">NetworkReport</code> flattens it
into the protocol-neutral <code class="language-plaintext highlighter-rouge">trunking.NetworkReport</code> the shared renderer consumes —
one <code class="language-plaintext highlighter-rouge">ReportSite</code> per discovered site under a shared identity header and band plan,
with neighbour frequencies resolved from the accumulated band plan:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// internal/hunt/report.go (shape)</span>
<span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="o">*</span><span class="n">DiscoveredSystem</span><span class="p">)</span> <span class="n">NetworkReport</span><span class="p">()</span> <span class="n">trunking</span><span class="o">.</span><span class="n">NetworkReport</span> <span class="p">{</span>
    <span class="n">r</span> <span class="o">:=</span> <span class="n">trunking</span><span class="o">.</span><span class="n">NetworkReport</span><span class="p">{</span>
        <span class="n">Name</span><span class="o">:</span>     <span class="n">s</span><span class="o">.</span><span class="n">DisplayName</span><span class="p">(),</span> <span class="c">// the named (or synthesized) handle</span>
        <span class="n">Protocol</span><span class="o">:</span> <span class="n">s</span><span class="o">.</span><span class="n">Protocol</span><span class="p">,</span> <span class="n">WACN</span><span class="o">:</span> <span class="n">s</span><span class="o">.</span><span class="n">WACN</span><span class="p">,</span> <span class="n">SystemID</span><span class="o">:</span> <span class="kt">uint32</span><span class="p">(</span><span class="n">s</span><span class="o">.</span><span class="n">SystemID</span><span class="p">),</span> <span class="n">NAC</span><span class="o">:</span> <span class="n">s</span><span class="o">.</span><span class="n">NAC</span><span class="p">,</span>
    <span class="p">}</span>
    <span class="c">// …one ReportSite per site: primary CC, secondaries, neighbours (with</span>
    <span class="c">//   uplink derived from the band's TX offset), then the band plan</span>
    <span class="k">return</span> <span class="n">r</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is the same <code class="language-plaintext highlighter-rouge">NetworkReport</code> the live daemon renders for a configured system,
so a discovered system’s summary reads exactly like a known one’s — just with an
<code class="language-plaintext highlighter-rouge">Unknown-</code> handle and blank talkgroup names where a human hasn’t filled them in
yet. The report is honest about its gaps by construction.</p>

<h2 id="where-this-goes-next">Where this goes next</h2>

<p>Talkgroups have numbers but no names — and the units riding them are the same
story. But there is one place a real, human name <em>does</em> travel over the air: the
<strong>talker alias</strong>, the display name a radio broadcasts for itself. <a href="/blog/deep-dives/the-hunt-12-alias-harvesting/">Part 12</a>
is about harvesting those aliases off a system’s traffic channels <em>without</em>
following the voice — the one naming the RF actually hands you.</p>

<h2 id="faq">FAQ</h2>

<p><strong>Why does an unnamed system print <code class="language-plaintext highlighter-rouge">Unknown-p25-…</code> instead of blank?</strong>
Because everything downstream needs a stable handle — golden diffs, export
filenames, the cockpit list. <code class="language-plaintext highlighter-rouge">DisplayName</code> synthesizes one from the measured
identity (WACN/SystemID), prefixed <code class="language-plaintext highlighter-rouge">Unknown-</code> to say it’s unconfirmed. Assign a
real <code class="language-plaintext highlighter-rouge">Name</code> and the synthesized handle vanishes.</p>

<p><strong>Where do a carrier’s Service and Purpose come from?</strong>
The frequency allocation table (<code class="language-plaintext highlighter-rouge">sigref.Lookup</code>). They describe what the
<em>frequency</em> is allocated to and what that service does — known for a band whether
or not the carrier decoded — so even an unidentified signal shows a meaningful
band context.</p>

<p><strong>Why won’t GopherTrunk name my talkgroups?</strong>
Because the name isn’t in the RF. The control channel says talkgroup 101 was
granted a channel; it never says 101 is “Fire Dispatch.” That mapping lives in a
human database, so the discovery ships the id, hex, and activity count and leaves
the descriptive fields blank for you or RadioReference to complete.</p>

<p><strong>What is <code class="language-plaintext highlighter-rouge">nameRefFloor</code> protecting against?</strong>
Confident nonsense. An undecoded carrier is only named from the reference catalog
when its best match scores at least 0.40; below that it’s named by its modulation
class (<code class="language-plaintext highlighter-rouge">nbfm</code>, <code class="language-plaintext highlighter-rouge">psk</code>, …), which is all we actually measured. The floor keeps a
weak guess from masquerading as a firm identification.</p>

<p><strong>Does naming change the decode at all?</strong>
No. Naming is a pure labelling pass over already-measured results — <code class="language-plaintext highlighter-rouge">nameSignal</code>
runs once per signal before it’s stored, and never touches the DSP or the
accumulation. It’s the human-catalog layer on top of the measurements, not part
of them.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 11 of 14</strong> · ←
<a href="/blog/deep-dives/the-hunt-10-offline-vs-live-surveys/">Part 10: Offline vs Live Surveys — Hunting a Recording</a>
· Next →
<a href="/blog/deep-dives/the-hunt-12-alias-harvesting/">Part 12: Alias Harvesting — Following Traffic for Talker Aliases</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="deep-dives" /><category term="the-hunt" /><category term="naming" /><category term="trunking" /><category term="survey" /><category term="metadata" /><category term="go" /><summary type="html"><![CDATA[How GopherTrunk turns a blind discovery's bare numbers into names — a synthesized system handle from the protocol and identity, a Name/Service/Purpose triple from the frequency allocation and reference catalog, and the honest line between what a decoder can name and what only an operator can.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From the Issue Tracker, Part 1: The First P25 Lock — Eleven Fixes Between ‘Trying’ and ‘Locked’</title><link href="https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-01-first-p25-lock/" rel="alternate" type="text/html" title="From the Issue Tracker, Part 1: The First P25 Lock — Eleven Fixes Between ‘Trying’ and ‘Locked’" /><published>2026-08-13T00:00:00-05:00</published><updated>2026-08-13T00:00:00-05:00</updated><id>https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-01-first-p25-lock</id><content type="html" xml:base="https://gophertrunk.org/blog/solution-postmortem/from-the-issue-tracker-01-first-p25-lock/"><![CDATA[<p><em>Part 1 of <strong>From the Issue Tracker</strong>, postmortems of GopherTrunk bugs that fought
back. Each part reconstructs one issue thread — the symptom as reported, the theories
that were wrong, the diagnostic that cracked it, and what the fix left behind in the
codebase. We start with the flagship:
<a href="https://github.com/MattCheramie/GopherTrunk/issues/275">#275</a>, the five days in
which GopherTrunk went from never locking a P25 control channel to decoding one —
through eleven distinct fixes, ending at two constants that had been wrong since the
day they were written.</em></p>

<blockquote>
  <p><strong>TL;DR:</strong> A reporter’s P25 control channel locked in SDRTrunk, OP25, and
p25-survey within seconds, while GopherTrunk logged <code class="language-plaintext highlighter-rouge">cc-hunt: trying</code> forever with
nothing after it. The failure turned out to be eleven bugs deep: an unprogrammed
sample rate, a missing channelizer, four separate chunk-boundary and timing-loop
bugs, a receive filter that didn’t match the P25 spec, unstripped status symbols, a
collapsed slicer — and at the very bottom, a wrong BCH(63,16,11) generator
polynomial and the wrong CRC-CCITT variant. Every single one had been masked by
encoder/decoder round-trip tests agreeing with each other. Fifteen seconds of real
IQ from the reporter’s antenna was what finally broke the spell.</p>
</blockquote>

<h2 id="cheat-sheet">Cheat sheet</h2>

<table>
  <thead>
    <tr>
      <th>Fact</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Issue</td>
      <td><a href="https://github.com/MattCheramie/GopherTrunk/issues/275">#275</a> — 49 comments, five days, eleven fixes</td>
    </tr>
    <tr>
      <td>Symptom</td>
      <td><code class="language-plaintext highlighter-rouge">cc-hunt: trying</code> forever, nothing downstream; SDRTrunk / OP25 lock the same site in ~5 s</td>
    </tr>
    <tr>
      <td>First wrong theory</td>
      <td>PPM drift — the cheapest of many</td>
    </tr>
    <tr>
      <td>Real causes</td>
      <td>Unset sample rate, no channelizer, four chunk-boundary/timing bugs, wrong receive filter, unstripped status symbols, collapsed slicer — and a wrong BCH(63,16,11) generator plus the wrong CRC-CCITT variant</td>
    </tr>
    <tr>
      <td>The diagnostic</td>
      <td>30 s of captured IQ + offline <code class="language-plaintext highlighter-rouge">gophertrunk replay -diag</code></td>
    </tr>
    <tr>
      <td>Why tests never caught it</td>
      <td>Encoder/decoder round-trips shared every wrong constant and every generous assumption</td>
    </tr>
    <tr>
      <td>Verified by</td>
      <td>92 TSBKs and 2 grants decoded from the capture, then <code class="language-plaintext highlighter-rouge">cc-hunt: locked</code> on air</td>
    </tr>
  </tbody>
</table>

<h2 id="in-this-post">In this post</h2>

<ul>
  <li><strong>The report</strong> — the ideal bug report: same hardware, other decoders lock in seconds.</li>
  <li><strong>The ladder</strong> — all eleven fixes, in the order the failures surfaced.</li>
  <li><strong>Rung 1: the radio was never told its sample rate</strong> — the pool programmed everything but the resampler.</li>
  <li><strong>Rungs 2–3: channelize, then let frames cross chunks</strong> — 427 samples per symbol, then 19-symbol USB transfers.</li>
  <li><strong>Rungs 4–7: the same bug class, one stage at a time</strong> — Gardner, coarse AFC, scale invariance, Mueller-Müller.</li>
  <li><strong>Rungs 8–9: the spec the code had never read</strong> — the sinc receive filter and status symbols.</li>
  <li><strong>The wrong turns, named honestly</strong> — CQPSK pushed twice, phantom search convergence, stale builds.</li>
  <li><strong>The capture that ended the guessing</strong> — replay turns each hypothesis into a measurement.</li>
  <li><strong>The bottom of the ladder: two constants</strong> — the BCH generator polynomial and the augmented CRC.</li>
  <li><strong>The meta-lesson</strong> — why a self-consistent system can only be falsified from outside.</li>
  <li><strong>What we keep</strong> — the durable rules and their Field Guide entries.</li>
</ul>

<h2 id="the-report">The report</h2>

<p>The issue arrived as close to ideal as bug reports get. Two NESDR SMArt v5 dongles, a
high-gain Yagi, a busy P25 Phase 2 system (MMR, control channel 420.0875 MHz), a
minimal config — and one observation:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time=2026-05-19T14:59:11.416+10:00 level=INFO msg="cc-hunt: trying" system=MMR freq_hz=420087500
</code></pre></div></div>

<p>Every 10–15 seconds, the same line again. No sync attempts, no framing failures, no
decode errors. Nothing downstream, ever. And the kicker: <em>the same dongles and
antenna decoded the same system in SDRTrunk.</em> OP25-based p25-survey locked it within
~5 seconds at BER 0.00%.</p>

<p>The first community theory was PPM drift — reasonable, wrong, and the cheapest of the
wrong theories this thread would collect. The real answer was that almost the entire
receive chain between the USB port and the trunking engine had never met a real
signal.</p>

<h2 id="the-ladder">The ladder</h2>

<p>Eleven fixes, in the order the failures surfaced. Each rung was invisible until the
one above it was fixed.</p>

<table>
  <thead>
    <tr>
      <th>Rung</th>
      <th>Fix</th>
      <th>What was wrong</th>
      <th>The tell</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>#281</td>
      <td><code class="language-plaintext highlighter-rouge">SetSampleRate</code> never called on the production path</td>
      <td>silence after <code class="language-plaintext highlighter-rouge">cc-hunt: trying</code></td>
    </tr>
    <tr>
      <td>2</td>
      <td>#289</td>
      <td>no channelizer — decoder fed the full 2.048 MHz swath (~427 samples/symbol)</td>
      <td><code class="language-plaintext highlighter-rouge">no FSW hits in chunk</code> on both demods</td>
    </tr>
    <tr>
      <td>3</td>
      <td>#292</td>
      <td>frames couldn’t cross IQ-chunk boundaries (~19 symbols per USB transfer, NID needs 32)</td>
      <td>FSW hits silently discarded, <code class="language-plaintext highlighter-rouge">dibits=19/20</code></td>
    </tr>
    <tr>
      <td>4</td>
      <td>#300</td>
      <td>Gardner timing loop emitted ~1 surplus symbol per call (~5% inflation)</td>
      <td>CQPSK reached NID but never <em>held</em> sync</td>
    </tr>
    <tr>
      <td>5</td>
      <td>#303</td>
      <td>no coarse AFC — tuner offset became DC bias on the discriminator</td>
      <td>PPM sweep changed nothing</td>
    </tr>
    <tr>
      <td>6</td>
      <td>#307</td>
      <td>CMA + Gardner error terms scale with amplitude²</td>
      <td>lock only in a narrow gain window (197 broken, 49 partial, 28 broken)</td>
    </tr>
    <tr>
      <td>7</td>
      <td>#311</td>
      <td>Mueller-Müller dropped one sample per chunk</td>
      <td>dibit error rate 0.66 chunked vs 0.04 one-shot</td>
    </tr>
    <tr>
      <td>8</td>
      <td>#316</td>
      <td>receive filter modeled as RRC; the P25 spec says sinc</td>
      <td>~5.75% residual ISI, <code class="language-plaintext highlighter-rouge">errs=11</code> ceiling</td>
    </tr>
    <tr>
      <td>9</td>
      <td>#318</td>
      <td>status symbols never stripped from the NID</td>
      <td>~21 of 32 NID dibits misaligned, both demods identical</td>
    </tr>
    <tr>
      <td>10</td>
      <td>#335</td>
      <td>slicer collapse — matched-filter DC gain put every sample past the outer threshold</td>
      <td>dibit histogram 50/0/50/0</td>
    </tr>
    <tr>
      <td>11</td>
      <td>#337 + #338</td>
      <td>wrong BCH(63,16,11) generator polynomial; wrong CRC-CCITT variant</td>
      <td>NID byte-identical across frames, yet “not a codeword”</td>
    </tr>
  </tbody>
</table>

<h2 id="rung-1-the-radio-was-never-told-its-sample-rate">Rung 1: the radio was never told its sample rate</h2>

<p>The pool programmed PPM, gain, and bias-tee on every device — but never the
resampler. The chip stayed at whatever divisor it powered up with while every decoder
did symbol-timing math against the configured 2.048 MS/s. SDRTrunk works on the same
hardware because librtlsdr’s <code class="language-plaintext highlighter-rouge">rtlsdr_open()</code> ends with a default
<code class="language-plaintext highlighter-rouge">rtlsdr_set_sample_rate</code> call; GopherTrunk’s pure-Go driver had no such safety net.</p>

<p>The fix (#281) also closed the diagnostic black hole: the P25 <code class="language-plaintext highlighter-rouge">Process</code> paths now
throttle-log <code class="language-plaintext highlighter-rouge">no FSW hits in chunk</code> instead of staying silent, and a new IQ-power
gauge (#282) let the reporter confirm healthy IQ (~−18 dBFS) before the decoder was
even involved. From here on, every failure at least left a fingerprint.</p>

<h2 id="rungs-23-channelize-then-let-frames-cross-chunks">Rungs 2–3: channelize, then let frames cross chunks</h2>

<p>With the rate fixed, the decoder was still being fed the full un-channelized 2.048
MHz swath — roughly 427 samples per symbol against a ±1 MHz window: dibits at about
the right <em>rate</em>, with values that were noise. A digital down-converter (#289) now
decimates every per-protocol pipeline to the narrowband rate its matched filter
expects (~48 kHz for the 4800-baud C4FM family). A negative result from that PR is
worth keeping: an IQ-domain DC blocker was rejected, because C4FM carries real
energy at 0 Hz — a complex DC block measured over 60% RMS error on a round-tripped
stream.</p>

<p>Then the shape of real USB delivery bit. An RTL-SDR hands over IQ in 16 KiB
transfers — about <strong>19 P25 symbols per decoder call</strong>. The control-channel state
machine discarded every frame-sync hit unless the entire 154-dibit frame (FSW +
32-dibit NID + 98-dibit TSBK) landed inside a <em>single</em> call. Nineteen is less than
even the NID, so every hit was dropped. The synthetic tests fed ~819 dibits per
chunk and never saw it. #292 made frames accumulate across calls, and the regression
test now pumps real 8192-sample chunks.</p>

<h2 id="rungs-47-the-same-bug-class-one-stage-at-a-time">Rungs 4–7: the same bug class, one stage at a time</h2>

<p>The chunk-boundary theme repeated up the DSP chain like a drumbeat:</p>

<ul>
  <li><strong>Gardner</strong> (#300): the CQPSK timing loop re-walked its cross-call look-back buffer
and emitted roughly one surplus symbol per call — a ~5% inflation that kept
desynchronizing the dibit stream, so the decoder could reach NID parsing but never
hold it.</li>
  <li><strong>Mueller-Müller</strong> (#311): the C4FM clock started its walk at <code class="language-plaintext highlighter-rouge">src[1]</code> on every
call, so <code class="language-plaintext highlighter-rouge">src[0]</code> never advanced the loop — one dropped sample per chunk. Fed the
whole signal in one block, the dibit error rate was 0.04; fed RTL-realistic
~19-symbol chunks, <strong>0.66</strong> — near-random. The fix carries look-back so the
chunked stream is byte-identical to the one-shot stream, and it repaired the same
latent bug in the DMR, NXDN, YSF, and dPMR receivers for free.</li>
  <li><strong>Coarse AFC</strong> (#303): a residual tuner offset becomes a DC bias on the FM
discriminator, and the fixed 4-level slicer thresholds have no defense — at ≥500 Hz
the FSW stops correlating entirely. This is why the reporter’s ±2 ppm sweep never
changed anything: once the offset exceeds the slicer margin, no small correction
recenters it.</li>
  <li><strong>Scale invariance</strong> (#307): after a CMA equalizer landed on the CQPSK path (more
on that below), lock suddenly depended on RTL gain — 197 broken, 49 partial
framing, 28 broken. Both the Gardner and CMA update terms scale with amplitude², so
the loops only converged when the front-end gain happened to land in a narrow band.
A feed-forward AGC on the matched-filter output restored scale invariance, guarded
by a regression test across a 0.05–20× amplitude range.</li>
</ul>

<p>Rungs 4–7 also mark where the process changed: #299 added a harness driving the real
receiver chain against deliberately impaired IQ — carrier offset, DC spike, IQ
imbalance, AWGN — in RTL-realistic small chunks, replacing the ideal large-block
synthesis every earlier fix had been verified against. The Gardner bug fell out of
that harness on day one.</p>

<h2 id="rungs-89-the-spec-the-code-had-never-read">Rungs 8–9: the spec the code had never read</h2>

<p>With timing solid, the C4FM path pegged at a new wall: <code class="language-plaintext highlighter-rouge">nid corrected errs=11</code> —
exactly the BCH(63,16,11) correction ceiling — with clearly bogus NACs (<code class="language-plaintext highlighter-rouge">0xFF5</code>)
surfacing as miscorrections.</p>

<p><strong>The filter was wrong</strong> (#316). GopherTrunk modeled P25 C4FM as a root-raised-cosine
matched pair. RRC × RRC is raised-cosine and ISI-free — so the chain was perfectly
self-consistent in tests — but per TIA-102.BAAA (cross-checked against OP25’s
<code class="language-plaintext highlighter-rouge">c4fm_const.py</code>), a real P25 transmitter shapes with raised-cosine α=0.2 cascaded
with an inverse-sinc, and the receiver’s job is a plain <strong>sinc</strong> that cancels it.
An RRC receive filter on a spec signal leaves about 5.75% residual ISI — quietly
corrupting dibits at exactly the level that keeps BCH at its ceiling.</p>

<p><strong>The status symbols were never stripped</strong> (#318). P25 interleaves a 2-bit status
symbol into the on-air stream every 70 information bits, through every data unit
including TSDUs. The 24-dibit FSW is contiguous — the first status symbol lands at
dibit 35, just after it — which is precisely why FSW detection worked while the NID
behind it was garbage: the decoder read the NID as 32 contiguous dibits, swallowed a
status symbol as data, and misaligned ~21 of the 32. The give-away was that C4FM and
CQPSK failed <em>identically</em> — the bug lived downstream of both demodulators, in the
one stage they shared.</p>

<h2 id="the-wrong-turns-named-honestly">The wrong turns, named honestly</h2>

<ul>
  <li><strong>CQPSK/LSM was pushed twice</strong> for a site that SDRTrunk labels C4FM. The theory —
simulcast sites transmit LSM, so add a linear demod path (#288) and then a CMA
equalizer (#306) — was plausible and the code was eventually valuable, but the
reporter’s site was a strong standalone C4FM transmitter. The equalizer’s first
contribution was the gain-window regression of rung 6.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">delta=2, rot=3</code> looked like a clue and was two artifacts.</strong> When a bounded
NID-alignment search (#320) went in, the diagnostics converged on
<code class="language-plaintext highlighter-rouge">closest marginal errs=10 at delta=2 strip=true rot=3</code> every frame. <code class="language-plaintext highlighter-rouge">delta=2</code> was
the edge of the ±2 search grid — the textbook signature of a bounded search pegged
at its boundary — and <code class="language-plaintext highlighter-rouge">rot=3</code> is non-physical on an FM-discriminator stream, where
only rotations 0 and 2 correspond to real signal symmetries. Both “signals” were
the search machinery talking to itself; #326 widened the span to ±6 and restricted
C4FM rotations to {0, 2}, and the phantom convergence vanished.</li>
  <li><strong>Two retest cycles were wasted on stale builds</strong> — one binary was eight commits
older than the fix it was testing. The countermeasures outlived the bug: the
pipeline startup line now advertises <code class="language-plaintext highlighter-rouge">demod / rotations / nid_search_span /
build=</code>, and <code class="language-plaintext highlighter-rouge">internal/version</code> falls back to <code class="language-plaintext highlighter-rouge">runtime/debug.ReadBuildInfo()</code> so
even a bare <code class="language-plaintext highlighter-rouge">go build</code> produces a self-identifying binary.</li>
  <li><strong>The first capture was mislabeled.</strong> Its symbol clock measured ~160 samples per
symbol — inconsistent with the stated 2.048 MS/s — and decoded to nothing at every
plausible rate. The lesson became a feature: the <code class="language-plaintext highlighter-rouge">replay</code> tool now reports
effective baud at EOF, so a mislabeled file diagnoses itself.</li>
</ul>

<h2 id="the-capture-that-ended-the-guessing">The capture that ended the guessing</h2>

<p>The turning point of the whole thread was 30 seconds of raw IQ captured with a known
command line: <code class="language-plaintext highlighter-rouge">mt-anakie-420087500-960k-g49.iq</code>, replayed offline through the new
<code class="language-plaintext highlighter-rouge">gophertrunk replay</code> subcommand. The failure reproduced identically off-air:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no NID corroborated over 28 guesses; closest marginal errs=11 at delta=6 strip=true rot=0,
TSBK uncorroborated — best alignment at search boundary (±6); true offset may exceed span;
err_pattern=00100001102010200100000000100100
</code></pre></div></div>

<p>From here every hypothesis became a measurement instead of a field trip:</p>

<ul>
  <li><strong>Alignment was ruled out by bisection</strong> (#334): widening the search to ±36 still
produced 253 NID-BCH failures with closest-miss errors flat at 9–11 across the
whole range. Whatever was wrong, it wasn’t framing.</li>
  <li><strong>The slicer had collapsed</strong> (#335): a new <code class="language-plaintext highlighter-rouge">replay -diag</code> histogram showed the
dibit distribution was <strong>50/0/50/0</strong> — only outer symbols, ever. The C4FM matched
filter is normalized to a DC gain of one symbol period, which on real captures put
every sample past the slicer’s outer threshold. The FSW still correlated because it
uses only outer symbols — which is exactly why the failure hid <em>behind</em> a working
sync detector. A symbol-domain AGC between clock recovery and the slicer opened the
eye: histogram 28/22/27/23, and suddenly ~200 perfect distance-0 FSW hits at exact
360-dibit intervals.</li>
  <li><strong>The NID was constant, and that was the smoking gun</strong> (#336): across all 197
frames in the capture, the 32 NID dibits were <em>byte-identical</em> while the TSBK
payload varied frame to frame. That’s a real control channel — same NAC and DUID
every frame, different payload — being decoded consistently and rejected
consistently. The first 16 info bits even read out plausibly: NAC 0x164, DUID 7
(TSDU). The remaining failure had to be a systematic transform — not noise.</li>
</ul>

<h2 id="the-bottom-of-the-ladder-two-constants">The bottom of the ladder: two constants</h2>

<p><strong>Bug 1 — the BCH(63,16,11) generator polynomial was wrong</strong> (#337). The constant in
<code class="language-plaintext highlighter-rouge">internal/radio/framing/bch.go</code> was off by ten exponents from the TIA-102.BAAA
Annex A generator. The correct value — derived from first principles by multiplying
the binary minimal polynomials of α, α³, α⁵, …, α²¹ over GF(2⁶) with primitive
polynomial x⁶ + x + 1 — is <code class="language-plaintext highlighter-rouge">0xCD930BDD3B2B</code>. Cross-verified by running OP25’s
syndrome decoder against the captured Mt Anakie NID: 0 of 22 syndromes non-zero. The
on-air NID had been a valid spec codeword all along; GopherTrunk was checking it
against a code that exists nowhere but its own tests.</p>

<p><strong>Bug 1b — the 64th NID bit is not a parity bit.</strong> The code computed the trailing bit
as even parity over the 63-bit codeword. Per spec (and OP25’s <code class="language-plaintext highlighter-rouge">p25_framer</code>), it’s a
fixed per-DUID flag: 0 for HDU/TDU/TSDU/PDU/TDULC, 1 for LDU1/LDU2. For a TSDU the
on-air bit is 0 — which the “even parity” computation produced as 1, flagging every
otherwise-clean decode as a parity failure.</p>

<p><strong>Bug 2 — the wrong CRC-CCITT variant</strong> (#338). With the BCH fixed, the trellis
decoder reported <code class="language-plaintext highlighter-rouge">metric=0</code> — zero bit errors in the 196-bit channel block — and 195
of 197 TSBKs <em>still</em> failed CRC. Trellis right, verifier wrong: P25 uses the
“augmented codeword” CRC-CCITT for the TSBK trailer (init=0, MSB-first, final XOR
0xFFFF, evaluated over all 12 bytes, expecting 0), while the code used CRC-CCITT/FALSE.
Same 0x1021 polynomial, different answer on the same bytes. <code class="language-plaintext highlighter-rouge">metric=0</code> plus a CRC
failure is now a recognized signature: the FEC says the bits are perfect, so suspect
the checker.</p>

<p>The capture’s full arc:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">NID failures</th>
      <th style="text-align: right">TSBK CRC failures</th>
      <th style="text-align: right">TSBKs decoded</th>
      <th style="text-align: right">Grants</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pre-#335 (slicer collapse)</td>
      <td style="text-align: right">197/197</td>
      <td style="text-align: right">—</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>Post-#335 (symbol AGC)</td>
      <td style="text-align: right">197/197</td>
      <td style="text-align: right">—</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>Post-#337 (BCH polynomial + parity)</td>
      <td style="text-align: right"><strong>2/197</strong></td>
      <td style="text-align: right">195/197</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>Post-#338 (augmented CRC)</td>
      <td style="text-align: right"><strong>2/197</strong></td>
      <td style="text-align: right"><strong>0/197</strong></td>
      <td style="text-align: right"><strong>92</strong></td>
      <td style="text-align: right"><strong>2</strong></td>
    </tr>
  </tbody>
</table>

<p>And on air, the line the thread had been chasing for five days:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time=2026-05-24T07:16:10.555+10:00 level=INFO msg="control channel locked" nac=356 freq=420087500 rot=0 delta=0
time=2026-05-24T07:16:10.555+10:00 level=INFO msg="cc-hunt: locked" system=MMR freq_hz=420087500 nac=356
</code></pre></div></div>

<p>Followed within seconds by decoded TSBKs, Motorola vendor messages, and live patch
activity. Both constants are pinned by regression tests built from the capture’s
actual on-air bytes (<code class="language-plaintext highlighter-rouge">TestEncodeNIDBitsMtAnakieVector</code>,
<code class="language-plaintext highlighter-rouge">TestTSBKAcceptsMtAnakieOnAirVector</code>), so a future regression names the byte that
changed.</p>

<h2 id="the-meta-lesson">The meta-lesson</h2>

<p>The maintainer’s closing note on the thread deserves to be quoted rather than
paraphrased:</p>

<blockquote>
  <p>Without 15 seconds of real ground-truth bits, every one of these bugs would have
stayed silently masked by the encoder + decoder agreeing with each other inside
synthetic round-trip tests.</p>
</blockquote>

<p>Every rung of the ladder shares that anatomy. The wrong sample rate passed tests
that never touched hardware. The chunk-boundary bugs passed tests that fed
generously sized chunks. The RRC filter passed because the test modulator was also
RRC. The status symbols passed because the test frames were built without them. And
the wrong BCH polynomial and wrong CRC variant passed <em>hundreds</em> of round trips,
because an encoder and decoder that share the same wrong constant agree with each
other perfectly. A self-consistent system can only be falsified from outside — and
the outside, for a radio decoder, is 15 seconds of IQ from somebody’s antenna.</p>

<h2 id="what-we-keep">What we keep</h2>

<ul>
  <li><strong>Round-trip tests can’t catch a shared wrong constant.</strong> Validate codecs against
external ground truth — a reference implementation, a captured on-air vector, or
both. The spec constants this hunt corrected are recorded in the
<a href="/reference/p25-onair-constants/">P25 on-air constants</a>
Field Guide entry.</li>
  <li><strong>A capture plus offline replay beats any number of on-air retests.</strong> The
<code class="language-plaintext highlighter-rouge">gophertrunk replay</code> subcommand — with <code class="language-plaintext highlighter-rouge">-diag</code> histograms, effective-baud
reporting, and per-failure <code class="language-plaintext highlighter-rouge">err_pattern</code> strings — turned each hypothesis into a
measurement. The workflow is written up in the
<a href="/reference/diagnostic-playbook/">diagnostic playbook</a>.</li>
  <li><strong>Learn the signatures.</strong> A bounded search pegged at its boundary (<code class="language-plaintext highlighter-rouge">delta=2</code> on a
±2 grid), a non-physical rotation “winning” (rot=3 on an FM discriminator), a
dibit histogram of 50/0/50/0, <code class="language-plaintext highlighter-rouge">metric=0</code> with a failing CRC — each of these names
its own cause, and they’re cataloged in
<a href="/reference/signal-signatures/">signal signatures</a>.</li>
  <li><strong>Make staleness self-evident.</strong> Two retest cycles died on old binaries. Every
pipeline now logs its decode parameters and build stamp on startup, so the first
line of any pasted log answers “which code was this?”</li>
  <li><strong>A silent failure is a bug in its own right.</strong> The original report’s defining
feature was <em>no logs at all</em>. Every stage that can produce nothing now says so, at
a throttled cadence.</li>
</ul>

<h2 id="faq">FAQ</h2>

<p><strong>Why did SDRTrunk and OP25 lock the same hardware in seconds?</strong>
Their receive chains had met real signals for years. librtlsdr sets a default
sample rate as part of opening the device, their DSP tolerates real USB transfer
sizes, and their filters follow the TIA-102 pulse shapes. GopherTrunk’s chain had
only ever been validated against its own synthetic output, so every divergence
from real air was invisible until a real antenna was attached.</p>

<p><strong>How can eleven distinct bugs hide behind one symptom?</strong>
Because they were stacked: each rung fully masked everything below it. With the
sample rate unprogrammed, the channelizer gap couldn’t surface; with the slicer
collapsed, the wrong BCH polynomial couldn’t surface. Stacked failures are only
ever visible one at a time, which is why the thread reads as a ladder rather than
a list.</p>

<p><strong>What is the “augmented codeword” CRC-CCITT, exactly?</strong>
Same 0x1021 polynomial as CRC-CCITT/FALSE, different procedure: initial value 0,
MSB-first, final XOR 0xFFFF, evaluated over all 12 TSBK bytes including the
trailer, expecting a result of 0. On the same bytes the two variants produce
different answers — which is how a trellis decode with <code class="language-plaintext highlighter-rouge">metric=0</code> (zero channel
bit errors) can still “fail CRC” for 195 of 197 frames.</p>

<p><strong>Did any of this help other protocols?</strong>
Yes. The Mueller-Müller look-back fix repaired the same latent chunk-boundary bug
in the DMR, NXDN, YSF, and dPMR receivers for free, and the <code class="language-plaintext highlighter-rouge">replay</code> tooling with
<code class="language-plaintext highlighter-rouge">-diag</code> histograms became the standard investigation workflow for every
postmortem that follows in this series.</p>

<p><strong>What would have caught these bugs before a user did?</strong>
External ground truth — a reference implementation’s vectors or a captured on-air
frame — used as the test input instead of the project’s own encoder. The two
constants at the bottom of the ladder are now pinned by regression tests built
from the capture’s actual on-air bytes, so they can never again be “verified” by
a round trip against themselves.</p>

<h2 id="series-navigation">Series navigation</h2>

<p><strong>Part 1 of 22</strong> · Next →
<a href="/blog/solution-postmortem/from-the-issue-tracker-02-talker-alias-hunt/">Part 2: The Talker-Alias Hunt — Three Wrong Transports and an Architectural Gate</a></p>]]></content><author><name>Matt Cheramie</name></author><category term="solution-postmortem" /><category term="from-the-issue-tracker" /><category term="p25" /><category term="dsp" /><category term="trunking" /><category term="debugging" /><category term="postmortem" /><summary type="html"><![CDATA[A P25 control channel that SDRTrunk and OP25 decoded in five seconds took GopherTrunk eleven fixes over five days to lock — and the last two bugs were a wrong BCH generator polynomial and a wrong CRC variant that every synthetic round-trip test had happily agreed with.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gophertrunk.org/assets/gophertrunk-logo.png" /><media:content medium="image" url="https://gophertrunk.org/assets/gophertrunk-logo.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>