# simplex-agent

Records trunked radio traffic at a customer or office site and uploads each call to the `simplex`
cloud service, which transcribes it and matches it to a Bryx job.

The agent runs alongside [trunk-recorder](https://github.com/TrunkRecorder/trunk-recorder), which
runs as a rootless podman container under systemd — see `host/simplex-survey.container`. The
recorder writes a WAV plus a JSON sidecar per call into a directory; the agent sweeps that
directory, spools what it finds, uploads it, and holds each call until `simplex` confirms it was
transcribed and published. See **[How a call reaches `simplex`](#how-a-call-reaches-simplex)** for
the whole chain, and **[Installing at a site](#installing-at-a-site)** for the commands.

Status: **M0 passed; survey complete.** See **[docs/survey-2026-09-08.md](docs/survey-2026-09-08.md)**
for the 17-hour results and the receiver decision. Headline: 2,627 calls, **0 encrypted**, radio IDs
on 100% of calls, 286 distinct radios — but only **65% call coverage** on one RTL-SDR, so a second
dongle is needed before the numbers are trustworthy.

Previously: **M0 passed on an RTL-SDR.** Monroe fire and EMS dispatch traffic records cleanly and
**every call sampled was unencrypted**, which retires the project's largest risk. Source radio IDs
are present on 100% of calls, so the radio-ID learning loop is viable. The SDRplay RSP1 does *not*
work — see "The RSP1 dead end".

## Repository layout

| Path | Contents |
|---|---|
| `configs/monroe-dispatch.trunk.json` | **The survey config.** RTL-SDR at 2.4 MS/s, fire/EMS dispatch only, `recordUnknown: false` |
| `configs/monroe-wide.trunk.json` | Wide 7.992 MS/s variant covering all 16 control channels. Needs a receiver that can sustain it |
| `talkgroups/monroe-fire-ems-dispatch.csv` | **23 talkgroups**: every Monroe fire and EMS dispatch talkgroup, plus fireground and the fire-test group. Built from the OpenMHz registry |
| `talkgroups/monroe.csv` | All 383 talkgroups from the 2021 OP25 file. **Superseded** — kept only as provenance |
| `tools/fetch-openmhz-talkgroups.md` | How to pull the current 657-talkgroup registry, and **which blocks must never be recorded** |
| `host/simplex-survey.container` | Podman quadlet unit that runs the survey under systemd |
| `tools/op25-tgid-to-csv.py` | Converts an OP25 TGID tags file to a trunk-recorder talkgroup CSV |
| `tools/bandscan.py` | Measures received power on each control channel. Distinguishes "no antenna" from "the system moved" |
| `reference/op25-2021/` | The 2021 OP25 configuration recovered from the office box. Read-only provenance — do not edit |
| `host/` | udev rule, systemd units, and apt bootstrap for the site host |
| `tools/manifest/` | Describes a release so an agent can decide whether to update without downloading a binary |
| `tools/sign/` | Mints the Ed25519 release key and signs the manifest. Run by CI; `--generate` is run by a human, once |

## How a call reaches `simplex`

```
     RF  ──►  trunk-recorder  ──►  <captureDir>/  ──►  simplex-agent  ──►  spool/  ──►  websocket
                (container)        wav + json          (sweeps it)        (holds it)     (to simplex)
```

**The handoff between the recorder and the agent is a directory, and nothing else.** There is no
socket, no plugin and no shared process: trunk-recorder writes a pair of files per call, the agent
notices them. That is the whole interface, and it is what makes each side restartable without the
other caring — the recorder can be upgraded mid-shift, the agent can crash, and the only shared
state is files on a disk that both can already read.

### Where the files land

The recorder runs as a container with the site's data directory bind-mounted, so the path in its
config is a container path and the agent sees the host one:

| | |
|---|---|
| `captureDir` in `config.json` | `/app/captures` |
| Bind mount (`host/simplex-survey.container`) | `%h/simplex` → `/app` |
| What the agent is pointed at | `~/simplex/captures` |

Getting that mapping wrong is the most common way to stand up a site where everything looks healthy
and nothing uploads: the recorder writes happily inside the container and the agent sweeps an empty
directory on the host.

### The file pair

trunk-recorder lays calls out by system short name and date, with the month and day **not**
zero-padded:

```
<captureDir>/<shortName>/<YYYY>/<M>/<D>/<talkgroup>-<startEpoch>_<freqHz>.0-call_<n>.wav
                                        <talkgroup>-<startEpoch>_<freqHz>.0-call_<n>.json
```

```
captures/mcny/2026/9/14/1704-1789389754_770306250.0-call_172299.wav     the audio
captures/mcny/2026/9/14/1704-1789389754_770306250.0-call_172299.json    the sidecar
```

The `.wav` is 8 kHz mono 16-bit PCM — the rate P25 decodes to, not a downsample. The `.json` is the
call's metadata; its fields and their real units are in **[Verified sidecar contract](#verified-sidecar-contract)**,
and two of them are traps worth knowing before reading any code that touches them.

Observed on this system, the pair is written together: mtimes match to the millisecond across
every sample checked. **The agent does not rely on that.** It finds the sidecar first and then
looks for the audio, and treats a missing `.wav` as "not yet" rather than "never", so a pair that
does appear in two steps is picked up on the next pass instead of being lost.

### What in the recorder's config is load-bearing

Four settings decide whether the agent has anything to do at all:

| Setting | Value | Why it matters |
|---|---|---|
| `audioArchive` | `true` | Keeps the `.wav` on disk after the call. Without it there is nothing to upload |
| `callLog` | `true` | Writes the `.json`. The agent keys off the sidecar, so without it a call is invisible |
| `compressWav` | `false` | `true` produces `.m4a`, which the agent does not read and would silently skip |
| `recordUnknown` | `false` | With `talkgroupsFile`, bounds recording to the talkgroups we are entitled to record |

### How the agent decides a call is ready

Every 5 seconds the agent walks the capture tree, up to 200 new calls per pass:

1. **Skip what is settled.** A durable record — a watermark plus the recently-settled paths — means
   a restart does not re-read the whole tree. Without it the first sweep after every restart
   re-uploaded everything still on disk: 11,931 captures and 934 MB at this site.
2. **Require both files.** Sidecar present, audio present. Otherwise leave it for the next pass.
3. **Filter at the edge**, before anything is copied:
   - `encrypted` set — never uploaded. It cannot be decoded, so sending it is pointless traffic.
   - shorter than `minDurationMs` (default 700) — recorder artefacts with no speech in them.
4. **Derive the identity from the sidecar alone.** The `callUid` is
   `{sourceId}:{system}:{talkgroup}:{startEpochMs}`, which recomputes identically on replay and
   needs no audio — deriving it through the bytes meant re-reading and re-hashing every recording
   on every pass, which cost a fifth of a core on a machine that must demodulate continuously.
5. **Copy into the spool**, audio first and envelope last, both staged under `.partial` and renamed
   into place. The envelope's presence is what makes an entry valid, so a crash leaves either
   nothing or an orphan audio file the drain ignores — never a half-entry that looks sendable.

The capture is **copied, not moved.** Renaming it out of the recorder's tree would be atomic but
leaves a window where the only copy of a transmission exists under no valid name. Copying costs a
second write and cannot lose anything.

### Then the spool owns it

| State | Meaning |
|---|---|
| `pending/` | Queued. Sent oldest first — a recent call still has matching value, an old one has less |
| `sent/` | Uploaded, awaiting confirmation. **Still ours.** A disconnection moves it back to `pending/` |
| `archive/` | `simplex` confirmed it was transcribed *and* published. Pruned after 72 hours |
| `quarantine/` | Refused terminally, with the reason beside it. Never retried, never forgotten |

Only a `processed` frame moves a call out of `sent/`. `accepted` means stored, which is all a POST
could ever have said; the audio is held until the far side has actually put it on a job, so a
failure anywhere downstream is recoverable from the edge.

### Pruning the capture tree

The capture tree belongs to trunk-recorder and the agent only ever reads it, so for most of this
project nothing deleted from it at all: 22,247 calls and 1.8 GB in six days on the office box,
about 300 MB a day, with no mechanism. `pruneCaptures` is that mechanism, and it is **off by
default** — deleting another process's files is a thing to turn on deliberately.

| `pruneCaptures` | What it deletes | When to use it |
|---|---|---|
| `never` *(default)* | nothing | The recorder's own retention handles it, or something else does |
| `processed` | a capture once `simplex` confirms the call was transcribed **and** published | The safe choice. Nothing goes until the far end definitively has it |
| `age` | captures older than `pruneAfterDays`, uploaded or not | Disk pressure. It **will** delete a call that never got sent |

```json
{
  "pruneCaptures": "processed"
}
```

```json
{
  "pruneCaptures": "age",
  "pruneAfterDays": 14
}
```

`age` without a positive `pruneAfterDays` is refused: a zero window means "delete on sight", which
is not what anyone meant by leaving a field out. A `pruneAfterDays` alongside `never` or
`processed` is *kept and ignored*, so a site can switch the policy off and back on without
retyping the number.

Three rails, because this deletes the only copy of a transmission that has not been uploaded yet:

* **A call still in the spool is never pruned**, whatever the policy says. Deleting the source of
  an in-flight upload would not lose the call — the spool has its own copy — but it would remove
  the thing an operator reaches for to explain a stuck queue.
* **`processed` deletes nothing it cannot identify.** An unreadable sidecar is not proof that a
  call is finished with, so the pair stays.
* **`age` counts what it cost.** A capture past the window that was never confirmed is a
  transmission this site no longer has, and the pass logs that count separately at warning level
  rather than burying it in a total.

**`processed` is a steady-state policy, not a way to clear a backlog.** Its evidence that a call is
finished with is the spool's own archive, which is bounded at 72 hours — so a capture whose call
was confirmed longer ago than that can never be pruned by it, because the proof is gone. Turning
it on at the office box after six days reclaimed the 10,753 calls still inside that window and
left the 11,555 older ones alone. A one-off `age` pass is how a historical backlog goes.

The pass runs once at startup and hourly after that. Not in the five-second sweep: `processed`
reads each candidate's sidecar to learn its call id, which is fine once an hour and 20 MB of reads
every five seconds at this site's volume — retention is not a thing that needs to be prompt. The
startup pass exists because an agent restarting more often than the interval would otherwise never
prune, and because disk pressure is noticed exactly when someone turns this on and restarts.

The spool's own archive is separately bounded at 72 hours (`--archive-hours`); these settings are
only about the recorder's output.

## Installing at a site

Everything is one binary. There is no installer and nothing to unpack.

```bash
BASE=https://bucket.bryx.com/simplex-agent
curl -fsSLO "$BASE/$(curl -fsS $BASE/VERSION)/linux-amd64/simplex-agent"
chmod +x simplex-agent && sudo mv simplex-agent /usr/local/bin/
```

Windows is the same file at `.../windows-amd64/simplex-agent.exe`, Authenticode-signed.

For a build that is not a release yet — a fix a site needs before the next tag — swap the version
for `development` or `main`. Read [Branch builds](#branch-builds) first: an agent installed from
one **never updates itself**, so handing one to a site is a commitment to go back and replace it.

Then four commands, in this order:

```
simplex-agent configure          # answer four questions, checked against Bryx before anything is saved
simplex-agent service install    # register it with systemd or the Windows service manager
simplex-agent service status     # confirm
simplex-agent update --check     # what is published, and whether this is it
```

`configure` asks for the **API key**, the **recorder name**, the **audio folder** and the **Bryx
endpoint**, then opens a real connection and reads back the dispatch centre's own name and how many
agencies it covers. **Nothing is written until that succeeds.** A file that parses but cannot
connect leaves a site looking configured and silently doing nothing, which is the failure that
surfaces a week later as "no audio ever arrived".

On Windows, `simplex-agent configure --ui` opens a settings page in the default browser instead;
double-clicking the executable does the same, because someone who found it in a folder came to set
it up rather than to run a service by hand. The page binds `127.0.0.1` on a random port and carries
a single-use token in its URL — it is a local form, not a web console, and it never renders the API
key back into the page.

Both front ends ask the same questions and run the same checks. A CLI and a form that validate
differently are two products, and the one that is wrong is always the one the customer used.

### The settings file

`/etc/bryx/simplex-agent.json` on Linux, `%ProgramData%\Bryx\simplex-agent.json` on Windows.
Written `0600`: **the file is the credential**, and anything that can read it can impersonate the
site.

| Key | Required | What it is |
|---|---|---|
| `apiKey` | yes | Identifies and authenticates the site. Written `dispatchCenterId` on the wire and accepted under either name here |
| `capturesDir` | yes | Where the recorder writes. The agent reads this and never writes to it |
| `destinationUrl` | yes | `wss://…/stream` |
| `sourceId` | no | Which recorder this is, within the site. Defaults to the hostname. Part of every call's uid, so **changing it re-uploads history under new identities** rather than correcting anything |
| `spoolDir` | no | Defaults beside `capturesDir`, which is the one place guaranteed to be on the same disk |
| `pruneCaptures` / `pruneAfterDays` | no | See [Pruning the capture tree](#pruning-the-capture-tree) |
| `autoUpdate` | no | `false` stops the agent replacing its own binary. Absent means on |
| `updateBaseUrl` | no | Overrides where releases are fetched from. Empty uses the value compiled in at build time |
| `maxSpoolBytes` / `minDurationMs` / `backfillHours` | no | Bounds. The defaults are the ones the office box runs |

### Running it as a service

`service install` writes a systemd unit or creates a Windows service, points it at the settings
file, and starts it. Both are set to come back on their own: `Restart=always` with a 15-second
delay on Linux, and recovery actions that restart three times at 15 seconds on Windows. Both matter
more than they look, because **an update deliberately exits and relies on being restarted**.

The Windows service runs delayed-automatic — the network stack and any recorder service should be
up first, and the agent spools regardless, so being a minute late at boot costs nothing.

`service uninstall` removes the registration and **leaves the spool and the settings file alone**.
The spool holds every call Bryx has not confirmed, and on a recorder that is the only copy;
removing a service is a routine troubleshooting step, and losing a site's unsent audio is not.

## Keeping itself up to date

Once a day, jittered, the agent reads `https://bucket.bryx.com/simplex-agent/VERSION`, fetches the
manifest for the release it names, checks its signature against a public key compiled into itself,
and installs it if it is newer. This is remote code execution on every recorder, so the shape of it
is worth reading rather than trusting.

**`VERSION` is the only file that is ever rewritten, and it is not signed.** Rewriting it buys an
attacker three things and none of them is code execution: naming an older release, which is refused
because only a strictly newer version installs; naming a release that does not exist, which is a
404; or naming the real newest release, which is what was going to happen. Freezing it to withhold
updates is the one real effect, and signing would not prevent that — serving a stale signed pointer
works just as well.

**What has to be true before anything is executed:**

1. `VERSION` names a release tag, and the manifest published under that tag says it is that
   release. A genuinely signed manifest from an old release, replayed into a newer folder, is
   refused rather than quietly reported as "already up to date".
2. The manifest carries a valid Ed25519 signature from the key this binary was built with. A build
   with no key compiled in does not update at all — "I do not know what to trust" resolves to
   downloading nothing, not to running whatever arrives.
3. The binary's sha256 matches the digest in that signed manifest. Manifest and binary come from
   the same immutable folder, so they cannot disagree about which release they are from.
4. It runs. The staged file is executed with `--version` and has to identify itself as the release
   the manifest names. This catches a wrong architecture, a truncated download and a file the
   kernel will not exec — none of which could reach the rollback path below, because a binary that
   cannot start cannot roll itself back.

**What happens after it is in place:** the running binary is renamed to `.old`, the new one takes
its place, and the process exits so the service manager starts what is now on disk. The new version
then has to *prove* itself, and proof is one thing — a completed handshake with `simplex`. Until
that happens the update is counted, not accepted. Three starts without a handshake and the agent
restores the previous binary, keeps the failing one as `.rejected` for whoever investigates, and
exits again.

**When it will not update, even though a newer release exists:**

- `autoUpdate: false` in the settings file.
- More than 50 calls waiting in the spool. The site is behind, and replacing the binary would drop
  the connection and restart the drain. Skipping costs a day.
- The queue cannot be measured at all — treated the same as deep.
- This is a development build. A version that is not a tag has no place on the version line, so
  "newer than me" is not a question that can be answered.

`minimumVersion` in the manifest overrides the backlog rule. It is there so a release that turns
out to be harmful can be superseded rather than only rolled back — a rollback is per-site and needs
the site to notice, a floor does not.

`simplex-agent update` does the same thing immediately, and restarts the service afterwards if one
is running. `simplex-agent update --check` reports what is published and changes nothing.

## The Monroe County system

Recovered from `reference/op25-2021/trunk.tsv`, which someone configured for OP25 in October 2021.

| | |
|---|---|
| System | `MonroeEast`, NAC `0x47f` |
| Modulation | CQPSK (linear simulcast) → trunk-recorder `"modulation": "qpsk"` |
| Control channels | 16, spanning 769.08125 – 774.91875 MHz |
| Channel span | **5.8375 MHz** |
| Capture center | 772.000 MHz — the exact midpoint, and the frequency the 2021 config also used |
| Sample rate | 8 MS/s, covering 768 – 776 MHz (the whole 769 – 775 MHz public-safety allocation) |
| Talkgroups | 383 |

### How talkgroup filtering actually works

trunk-recorder has **no whitelist or blacklist config key** — verified by extracting the config key
names from the 5.0.0 binary. The talkgroup file *is* the filter, gated by one boolean:

| Key | Effect |
|---|---|
| `recordUnknown: true` | Record every talkgroup, including ones absent from `talkgroupsFile`. What the M0 survey wants |
| `recordUnknown: false` | Record only talkgroups present in `talkgroupsFile`. What production wants, paired with `monroe-dispatch.csv` |
| `hideEncrypted: false` | Keep encrypted calls visible in the log, so the survey can count them |

An earlier revision of these configs carried a `talkgroupsWhitelistFile` key. **No such key exists**;
trunk-recorder ignored it silently and recorded everything.

### Talkgroups the survey records

**23 talkgroups, fire and EMS only**, in `talkgroups/monroe-fire-ems-dispatch.csv`. The list comes
from the OpenMHz registry, not the 2021 OP25 file, because that file was missing three real dispatch
talkgroups — see `tools/fetch-openmhz-talkgroups.md`.

| TGID | Name | Why |
|---|---|---|
| `1076` | MC Fire 5 (Dispatch) | Primary: county fire dispatch |
| `1077` | MC EMS Dispatch | Primary: county EMS dispatch |
| `1072` | Rochester City Fire 1 (Dispatch) | City fire dispatch |
| `1704` | Rochester Fire Dispatch | **Missing from the 2021 list** |
| `1811` | MC FD Ops 5 Disp | **Missing from the 2021 list** — and the largest capture in the first survey |
| `1760` | AMR Dispatch | **Missing from the 2021 list** |
| `1752` | Monroe Amb Dispatch | Ambulance dispatch |
| `1607`, `1654` | Gates / Henrietta Fire Dispatch | Town fire dispatch |
| `1403`-`1407` | Fire and EMS dispatch backups | Catch failover |
| `1073`-`1075` | Rochester City Fire 2, MC Fire 3/4 Fireground | Tests matching on tactical traffic, which is far less formulaic than dispatch |
| `1812`-`1816` | MC Fire Test 1-5 | End-to-end testing without touching live traffic |
| `50508` | MC Fire Dispatch [PATCH 50508] | Supergroup patch of fire dispatch |

### What is deliberately excluded

`recordUnknown: false` plus the fire/EMS-only talkgroup file is what enforces this, and the recorder
log confirms it in operation (`Not Recording: TG not in Talkgroup File`).

- **Schools — `3001`-`3072`.** The first ad-hoc survey ran with `recordUnknown: true`, before the
  registry existed to name talkgroups, and recorded `3027 Willink MS 1` and `3048 Webster CSD Bus`.
  OpenMHz removed school and bus talkgroups from this feed for privacy. Nothing here needs them.
- **Transit — `1262`-`1274`.** RTA buses and Lift Line.
- **Law enforcement — `2020`-`2441`, `7001`-`7017`.** RPD, MCSO and Ontario County Sheriff. Keeping
  v1 to fire and EMS avoids a CJIS conversation.
- **Ontario County — `8000`-`8223`.** A different county; out of scope for v1.

**Do not set `recordUnknown: true` on a live run.**

## Site state: office box `airspy` @ 10.10.241.34

Surveyed 2026-09-08. **This box is not ready to record.** Work the repair sequence in order.

| | Found |
|---|---|
| OS | Debian, mid-upgrade to trixie. Sources on trixie, kernel 6.12.107+deb13, but `apt-get -s dist-upgrade` still lists pending `Conf` steps, so dpkg is partially configured |
| CPU | Pentium G4400 — 2 cores @ 3.3 GHz, `sse4_2`, **no AVX or AVX2** |
| RAM / disk | 3.8 GB / 108 GB on RAID1 `md0`, 96 GB free |
| Clock | NTP active and synchronized, `America/New_York` |
| SDR | **SDRplay RSP1.** An RTL-SDR was present earlier the same day; the two were swapped. No Airspy was detected in either check, despite the hostname |
| RSP1 driver | Wrong one — SoapySDR reports `driver = miri` (reverse-engineered libmirisdr), not `driver = sdrplay` |
| GNU Radio | Not installed. `dpkg` reports `gnuradio 3.10.5.1-3` in state `rc` (removed, config remains) |
| OP25 | Broken. `import gnuradio.op25_repeater` fails; the checkout is from 2021-10-08 and its CMake cache targets GNU Radio 3.8.2.0 / Python 3.9 / SWIG |
| Missing | trunk-recorder, Docker, Tailscale |

### Why the RSP1 is the right receiver

The 5.8375 MHz channel span decides this, and the answer is not the Airspy that the hostname
implies:

| Device | Usable bandwidth | Verdict |
|---|---|---|
| RTL-SDR | ~2.4 MS/s | Needs three dongles to span it; 700 MHz sensitivity and drift are poor |
| Airspy Mini | 6 MS/s | Just spans it |
| Airspy R2 | 10 MS/s | Comfortable |
| **SDRplay RSP1** | up to 10 MHz | **Comfortable, and already installed** |

Two caveats. trunk-recorder reaches SDRplay through SoapySDR rather than a first-class driver, so
verify that path before committing to a single-device design. And the SDRplay API is a host daemon,
so running trunk-recorder in Docker means running `sdrplay_apiService` inside the container with USB
passthrough.

### What was verified on 2026-09-08

The repair is done and the system is confirmed receivable. Recorded here because each item cost real
time to establish.

**The box is repaired.** The trixie upgrade completed (`dpkg --audit` clean, `full-upgrade` simulates
to zero changes). Podman 5.4.2 rootless is installed with `podman-compose` 1.3.0. The udev rules and
the mirics blacklist are installed at `/etc/udev/rules.d/99-bryx-sdr.rules` and
`/etc/modprobe.d/blacklist-mirics.conf`, and `msi2500`/`msi001` are unloaded, which freed the RSP1
for userspace.

**The Monroe system is live and still on the 2021 frequencies.** `tools/bandscan.py` measured every
control channel: peaks stand **19-32 dB above the noise floor**, and eight of the sixteen peak at
*exactly* 0.0 kHz offset from the listed frequency. So an antenna is connected, the band is active,
the 2021 `trunk.tsv` is still accurate, and the receiver's frequency reference is good enough that
`"error": 0` is correct. This also rules out the antenna as a cause of any decode failure — a
disconnected antenna still delivers noise, and noise still costs CPU.

**The 24 kHz rate constraint.** trunk-recorder rejects any source rate that is not an integer
multiple of 24000, its channel IF rate:

```
OsmoSDR must have a sample rate that is a multiple of 24000, current rate: 8e+06
```

This shapes the whole SDR choice, so check a candidate's advertised rates against it before buying:

| Rate | ÷ 24000 | |
|---|---|---|
| 2,400,000 | 100 | valid — the common RTL-SDR rate |
| 6,000,000 | 250 | valid |
| 7,992,000 | 333 | valid — the widest rate below 8 MS/s, used by the wide config |
| 8,000,000 | 333.33 | **invalid** — the RSP1's advertised rate |
| 10,000,000 | 416.67 | **invalid** |

**trunk-recorder itself works.** Version 5.0.0 from `ghcr.io/robotastic/trunk-recorder` starts under
rootless podman, opens the device, tunes to 772 MHz, loads all 383 talkgroups and cycles the control
channel list. Its own `CMD` is already `trunk-recorder --config=/app/config.json` with workdir
`/app`, so pass **no** arguments — appending any overrides the command. `captureDir` must point
inside the bind mount (`/app/captures`); the default `/captures` is not mounted and recordings are
silently lost.

### M0 result: the audio is receivable and in the clear

A 5-minute survey on 2026-09-08 with the RTL-SDR and `configs/monroe-survey.trunk.json`:

| | |
|---|---|
| Calls recorded | **40**, 199 s of audio, 3.3 MB, across 18 distinct talkgroups |
| **Encrypted** | **0 of 40.** Every call was `encrypted: 0` |
| Calls carrying `srcList` | **40 of 40 (100%)** |
| Distinct radio IDs seen | **21**, in five minutes |
| Lost samples | **0** |
| Control channel decode | up to 4 msg/sec |
| CPU | ~22% of the 2-core box at 2.4 MS/s |
| Recorder starvation | **38 events at `digitalRecorders: 6`** — see below |

Two consequences for the wider project. **The encryption risk is retired** for fire and EMS, which
was the one thing that could have killed the effort outright. And **radio IDs are present**, so the
RID-to-unit learning loop is worth building rather than dead weight.

Of the seven fire/EMS dispatch talkgroups, only **`1072 Rochester FD Dsp`** and
**`1752 Monroe Amb Disp`** appeared — both clear. **`1076 MC Fire Dispatch` and `1077 MC EMS
Dispatch` did not**, so the two primary targets remain unconfirmed. Five minutes is a small sample;
the full 48-72 hour survey is still required, both to confirm those two and to produce the
per-talkgroup call-rate figures that size the cloud side.

### The 2021 talkgroup list is stale

`recordUnknown: true` exposed live talkgroups that are absent from `monroe.tsv`: `1263`, `1264`,
`1704`, `1760`, `1811`, `2421`, `3027`, `3048`, plus a supergroup patch id `50501`. Their
`talkgroup_tag` comes through empty. Before production, rebuild the talkgroup list from a current
source and re-run `tools/op25-tgid-to-csv.py`; the 2021 file is fine for M0 but should not be
trusted as a registry.

### Verified sidecar contract

`testdata/trunk-recorder/call-phase2-tdma.json` is a real capture, and it is the authoritative
input shape for the upload envelope. 25 top-level keys. The ones that matter, with their **actual
units** — two of which differ from what the design assumed:

| Field | Type | Note |
|---|---|---|
| `start_time`, `stop_time` | int | **Unix epoch seconds, not milliseconds** |
| `call_length` | int | seconds |
| `srcList[].pos`, `freqList[].pos` | float | **seconds, not milliseconds** |
| `srcList[].src` | int | the radio ID |
| `encrypted`, `emergency`, `duplex`, `mode` | int | **0/1, not booleans** |
| `phase2_tdma`, `tdma_slot` | int | Phase II confirmed on this system |
| `freq`, `freq_error` | int | hertz |
| `signal`, `noise` | int | `999` is a sentinel for "not measured" |
| `talkgroup_tag`, `talkgroup_description`, `talkgroup_group`, `talkgroup_group_tag` | str | empty when the TG is absent from the talkgroup file |
| `audio_type` | str | e.g. `digital tdma` |

### Receiver tuning

`tools/bandscan.py` measures both SNR and frequency offset per control channel, reading the channel
list straight from a trunk-recorder config so the two cannot drift apart:

```
tools/bandscan.py configs/monroe-survey.trunk.json --driver rtlsdr --gain 40
```

It searches only within 15 kHz of the tuned frequency. A whole-window peak search finds whichever
carrier is strongest in the capture, and on this system control channels sit as close as 37.5 kHz
apart, so a wide search reports a neighbour's power and a meaningless offset.

Measured on this dongle across two independent scans: **+2.3 kHz** and **+1.5 kHz** at 770 MHz,
i.e. **`ppm: 2`**, which is what the configs carry.

An earlier revision set `ppm: 8`, derived by *adding* the recorded calls' median `freq_error` to the
then-current ppm. That was the wrong direction and put the receiver roughly 4.6 kHz off, which
visibly degraded control-channel decode. **Trust `bandscan.py`'s measured offset, not arithmetic on
`freq_error`** — the offset is a direct measurement against a known frequency, whereas `freq_error`'s
sign convention is easy to get backwards. Re-measure per dongle; RTL-SDR crystals vary widely.

`digitalRecorders` is the other value to tune, and the survey shows why it depends on which
config you run. Recording the **whole county** (`recordUnknown: true`) starved 38 times even at
`digitalRecorders: 6` — an 18-talkgroup county simply has more concurrent calls than that. CPU was
only ~22%, so raise it well above 6 for survey work. The dispatch-only config needs far fewer,
because it records at most a handful of talkgroups.

### The 2.4 MHz coverage ceiling

**One RTL-SDR cannot cover this system.** The control channel decodes fine — it sits at
769.30625 MHz, inside the window, and message counters climb steadily. But **voice grants are
spread across the whole 769-775 MHz allocation**, and a 2.4 MHz window reaches only part of it.
trunk-recorder says so explicitly:

```
TG: 1752  Freq: 774.918750 MHz   Not Recording: no source covering Freq
```

That is Monroe Ambulance Dispatch getting a channel 3.8 MHz away from where we are listening. Every
such grant is a call we never hear, and no single 2.4 MHz window fixes it — grants were observed at
769.31, 770.31, 770.76, 774.44 and 774.92 MHz within minutes of each other.

So survey call counts from a single RTL-SDR are **biased low by an unknown factor**, and the log's
`no source covering Freq` count is the measurement of that bias. Encryption status and talkgroup
presence are still trustworthy; per-talkgroup call *rates* are not.

**The fix is one wideband receiver, and the 24 kHz rate rule narrows the choice:**

| Device | Rate | ÷ 24000 | Spans 5.84 MHz? |
|---|---|---|---|
| RTL-SDR | 2,400,000 | 100 ✓ | No — needs three |
| **Airspy Mini** | **6,000,000** | **250 ✓** | **Yes** |
| Airspy R2 | 10,000,000 | 416.67 ✗ | Rate invalid |
| SDRplay RSP1 | 8,000,000 | 333.33 ✗ | Rate invalid, and the driver is broken |

An **Airspy Mini at 6 MS/s** is the clean answer: it spans the allocation in one device and divides
cleanly. Three RTL-SDRs would also work (trunk-recorder takes multiple sources) but that is
7.2 MS/s aggregate into a 2-core box, and the wide-window CPU question is still unanswered.

Note the box is *named* `airspy` and had Airspy libraries installed, and the 2021 OP25 config used
Airspy gain-stage names — so one may simply need finding.

### Control channel identification

Only **769.30625 MHz** was strong in both band scans, 30-35 dB above noise. A control channel
transmits continuously, so it appears in every scan; voice channels come and go. That is how to pick
it out, and it is why `control_channels` in the config now lists 769.30625 first — trunk-recorder
walks the list and spends about 3 seconds on each dead frequency before moving on. The rest stay as
failover for when the site rotates.

### The RSP1 dead end

**The SDRplay RSP1 cannot drive trunk-recorder through libmirisdr.** The device itself is healthy —
`SoapySDRUtil --args="driver=miri" --rate=7992000` sustains **7.99 Msps at 63.9 MB/s** with no drops,
and it accepts 2.4 MS/s too, so the probe's "Sample rates: 8 MSps" list is not a real limit. But
inside trunk-recorder the same device loses samples catastrophically:

| Rate | Device string | Lost-sample messages in 60 s | Decode rate |
|---|---|---|---|
| 7,992,000 | `miri=0` | ~4,600 | 0/sec |
| 2,400,000 | `miri=0` | 833,270 | 0/sec |
| 2,400,000 | `soapy=0,driver=miri` | 831,821 | 0/sec |

Both routes fail identically, and at 2.4 MS/s the CPU sits at only ~35%, so this is not a throughput
limit — it is libmirisdr failing to feed GNU Radio's scheduler. `SoapySDRUtil`'s rate test passes
because it reads and discards as fast as it can; GNU Radio has buffer expectations libmirisdr does
not meet.

Two ways forward, in order of speed:

1. **Fit a natively supported SDR and finish M0.** An RTL-SDR at 2,400,000 divides cleanly, is the
   best-trodden trunk-recorder path, and one was plugged into this box earlier the same day. With
   20-30 dB of measured SNR there is margin for its weaker 700 MHz front end. It covers 2.4 MHz, so
   use `configs/monroe-narrow.trunk.json` — enough to prove reception and encryption status, which
   is all M0 needs. An Airspy Mini at 6,000,000 would cover nearly the whole span; the box is named
   for an Airspy and the 2021 config used Airspy gain-stage names, so one may simply need finding.
2. **Give the RSP1 its own driver.** Install SDRplay's API plus SoapySDRPlay3 so the device presents
   as `driver = sdrplay`, which also unlocks proper IFGR/RFGR gain control instead of libmirisdr's
   single 0-10.2 dB LNA stage. This is the right long-term answer for full-span coverage, but the
   API is a proprietary installer from sdrplay.com requiring licence acceptance, and it has to be
   baked into a custom container image because the API is a host daemon. Someone has to make that
   call deliberately.

Whichever receiver lands, the wide 7.992 MS/s config is still unproven on this CPU: the only wide
run also had libmirisdr in the path, so its ~4,600 lost samples cannot be attributed to the CPU.
Re-measure once a working driver is in place.

### Repair sequence

1. **Back up the OP25 edits, if not already done.** `git status` in `/home/airspy/op25` shows
   `trunk.tsv`, `meta.json`, `op25.liq` and `op25.sh` modified. `trunk.tsv` and `monroe.tsv` are the
   system intel this repo depends on; both are already preserved under `reference/op25-2021/`.
2. **Finish the Debian upgrade.** Several packages sit in `rc` state, GNU Radio among them.
   ```
   sudo dpkg --configure -a && sudo apt full-upgrade
   ```
3. **Install Docker and Tailscale.** Both are prerequisites: Compose runs the recorder and the agent,
   and CI deploys over Tailscale SSH the way `polygon` does.
4. **Put the RSP1 on the real driver.** Install the SDRplay API and SoapySDRPlay3, then confirm
   `SoapySDRUtil --find` reports `driver = sdrplay`. Until it does, `configs/monroe-dev.trunk.json`
   will not open the device.
5. **Install `host/99-bryx-sdr.rules` and `host/blacklist-mirics.conf`.** The rules grant the
   container access; the blacklist keeps the in-kernel Mirics DVB drivers from claiming the RSP1
   ahead of the SDRplay API. These SDRs are libusb devices with no persistent character device, so
   there is nothing to name in a Compose `devices:` entry — bind-mount the whole
   `/dev/bus/usb` tree instead and let the udev rules handle permissions.
6. **Run trunk-recorder from its official image.** Do not repair OP25. Repairing it means pulling
   five years of upstream, discarding a CMake cache pinned to GNU Radio 3.8, and rebuilding against
   a GNU Radio version the 2021 code predates. trunk-recorder's image sidesteps the host's GNU Radio
   state entirely, and it produces what the pipeline needs — per-call WAV plus a sidecar carrying
   talkgroup, `srcList` radio IDs and timestamps — which OP25 does not.

### Values still to be measured

`configs/monroe-dev.trunk.json` carries placeholders that only real signal can settle:

- `sources[0].gain` (40) and `sources[0].error` (0). Tune gain against decode error rates; find the
  frequency error with `trunk-recorder`'s control-channel decode or `rtl_test -p` on a known signal.
  The per-stage gain names for SDRplay through SoapySDR come from `SoapySDRUtil --probe`.
- `sources[0].digitalRecorders` (4). **This is the number the CPU decides.** Two cores with no AVX2
  feeding an 8 MS/s stream is the weakest link in the whole design; expect to need a newer box.
  Record `load average` and trunk-recorder's recorder-starvation counters during M0.

## Releasing

Tag it. The pipeline does the rest.

```
git tag v0.2.0 && git push origin v0.2.0
```

A tag matching `^v` builds `linux/amd64` and `windows/amd64`, Authenticode-signs the Windows binary
with a Sectigo timestamp, writes a manifest, signs it, and publishes to R2:

```
r2:simplex-agent/VERSION        the current release tag. The only file ever rewritten
r2:simplex-agent/<tag>/         a release. Never rewritten
r2:simplex-agent/main/          the head of main. Overwritten every push
r2:simplex-agent/development/   the head of development. Overwritten every push
```

Served at `https://bucket.bryx.com/simplex-agent/`. Each folder holds `manifest.json`,
`manifest.json.sig`, `README.md`, and the two binaries under `<os>-<arch>/`.

`VERSION` is written by a **separate job that `needs` the release job**, so it never names a folder
that is not there yet. The other order gives every agent checking in during that window a 404 on a
manifest — recoverable, but an outage nobody could explain from the logs.

**The bucket name is part of the rclone endpoint, not the path.** `RCLONE_CONFIG`'s remote ends in
`.../bryx`, so `r2:simplex-agent` is `bucket.bryx.com/simplex-agent` and `r2:bryx/simplex-agent`
would be a folder called `bryx` inside the bucket. `dispatch-courier`'s `r2:courier/files/…` reads
like a bucket named `courier` and is a prefix — worth knowing before writing a path that looks
right and lands somewhere else.

### Branch builds

Pushing to `main` or `development` publishes the same set of files to `main/` or `development/`,
Authenticode-signed and with a signed manifest, so a build can be handed to a site before it is a
release. **`VERSION` never names them**, so no agent reaches one by updating.

A branch build is stamped with its commit rather than a tag, which means `--version` prints a sha
and **the agent will not update itself off it, ever.** That is deliberate: a site is given a branch
build to carry a fix that is not in a release yet, and letting the next release quietly replace it
would take the fix away again. Getting that site back onto the release line is a manual step, and
it should be, because someone has to confirm the fix actually shipped.

Branch and tag pipelines run the same build, sign and package stages. Only the destination differs,
and only a tag moves `VERSION`. Every other ref — a feature branch, a merge request — runs the
checks and the build and publishes nothing.

### `manifest.json`

What an agent reads to decide whether to update, without downloading a binary first.

```json
{
  "version": "v0.2.0",
  "releasedAt": "2026-09-14T12:39:54Z",
  "artifacts": [
    {"os": "linux", "arch": "amd64", "path": "linux-amd64/simplex-agent", "sha256": "...", "bytes": 5656760}
  ]
}
```

`minimumVersion` is optional and empty by default. It exists so a release that turns out to be
harmful can be superseded rather than only rolled back -- a rollback is per-site and needs the site
to notice, a floor does not.

The digests are taken **after** signing. Authenticode rewrites the Windows binary, so a manifest
built before the signing stage describes a file that is not the one published.

A detached `manifest.json.sig` sits beside it, and **the agent installs nothing without it.** The
signature covers the manifest's exact bytes, and the manifest covers every binary's sha256, so one
signature covers the release. It is deliberately not a field inside the manifest: a manifest
carrying its own signature has to define what that signature covers, and a whole-file detached one
does not.

The format is two lines — a comment, then base64 of the 64-byte Ed25519 signature. One algorithm
and one key, so there is no negotiation to get wrong.

### The release signing key

Mint it once:

```
go run ./tools/sign --generate
```

That prints both halves. The private half goes in the `SIGNING_KEY_B64` CI variable, masked and
protected — **anything holding it can make every deployed agent run arbitrary code.** The public
half goes in `UPDATE_PUBLIC_KEY` in `.gitlab-ci.yml`, which compiles it into every binary; it is
not a secret, since it ships inside the executable either way, and keeping it in the repo means a
release can be read back to work out which key it trusts.

Check a published release by hand with the same code an agent runs:

```bash
curl -sO https://bucket.bryx.com/simplex-agent/development/manifest.json
curl -sO https://bucket.bryx.com/simplex-agent/development/manifest.json.sig
go run ./tools/sign --verify --in manifest.json --sig manifest.json.sig \
  --pub-b64 "$(grep UPDATE_PUBLIC_KEY .gitlab-ci.yml | cut -d'"' -f2)"
```

**Rotating strands every agent already in the field**, because each one verifies against the key it
was built with. Plan two releases: one signed with the old key that carries the new public key,
then a second signed with the new one.

### What the pipeline needs

| Variable | What it is |
|---|---|
| `P12_JKS_BASE64` | base64 of the Authenticode code-signing keystore |
| `KEYSTORE_PASS` | its password |
| `RCLONE_CONFIG` | rclone config for the `bryx` R2 bucket |
| `SIGNING_KEY_B64` | the Ed25519 release key. Without it the pipeline publishes an unsigned release, and every agent refuses to install it |

All four are project-scoped and **protected**. That is load-bearing rather than tidiness: a
protected variable is not exposed to a pipeline on an unprotected ref, and every ref that publishes
is protected — `main`, `development`, and the `v*` tag pattern. A feature branch cannot reach the
signing keys, which is the point.

Two more are plain `variables:` in `.gitlab-ci.yml` rather than CI settings, because neither is
secret and both should be readable from the source of a given release:

| Variable | What it is |
|---|---|
| `UPDATE_PUBLIC_KEY` | the public half of `SIGNING_KEY_B64`, compiled into every binary |
| `UPDATE_BASE_URL` | `https://bucket.bryx.com/simplex-agent` — where the tree is reachable over https |
| `RELEASE_PATH` | `r2:simplex-agent` — where rclone writes it |

A local `go build` sets neither of the first two, so a development binary has no key and no release
url and does not update itself. That is the intended behaviour, not a gap.

## Regenerating the talkgroup CSV

The converter derives trunk-recorder's `Tag`, `Category` and `Priority` columns from the alpha tag
text, because OP25 tags files carry only `TGID` and a name. Those three columns affect grouping and
filtering only — never decode — so the heuristic is safe, but review the output before it drives a
recorder.

```
tools/op25-tgid-to-csv.py reference/op25-2021/monroe.tsv -o talkgroups/monroe.csv
```

Pass `--only` to restrict output to an OP25 whitelist file:

```
tools/op25-tgid-to-csv.py reference/op25-2021/monroe.tsv --only reference/op25-2021/whitelist.wlist
```

Every talkgroup starts at `Mode: D`. Change a row to `DE` only once a talkgroup is *observed*
encrypted, so the recorder stops spending a slot on traffic it cannot decode.
