Radio IDs panel

GopherTrunk’s Radio IDs panel (web /rids) gives every subscriber unit (the “RID” or source_id in grant / call payloads) the same first-class treatment talkgroups have always had: a filterable list, a detail view, per-RID call history, and operator- configurable aliases.

It exists because recurring radios are operationally interesting in their own right. The dispatch officer who’s on the air all morning, the patrol unit that runs the same routes every day, the test radio that helps you spot a misconfigured CC — naming them once and seeing them as named entities in the live feed makes that workflow far easier than scrolling raw source_id numbers.

What you get

  • A merged view of three RID sources, keyed by radio ID, in increasing order of freshness:
    • Configured rows from a per-system rid_alias_file (CSV or JSON) — operator-assigned alias, description, tag, group, owner, priority, lockout, watch, icon.
    • Persisted rows aggregated from the call log — the durable, all-time call_count, first_seen, last_seen, and last group last_talkgroup for every radio ever recorded. This is what keeps a radio in the list after the live tracker’s idle TTL sweeps it: the list reflects the daemon’s whole lifetime and survives control-channel re-locks and restarts, rather than collapsing to the configured catalogue during a re-hunt gap.
    • Live rows from the affiliation tracker — the freshest in-window last_seen, last_talkgroup, and observed over-the-air talker_alias. The tracker holds a radio for a 30-minute idle window; its per-window call_count never shrinks the all-time total the persisted layer supplies.
  • Overlapping rows are merged on id; any source can contribute a row by itself.
  • Detail modal with the last 50 calls observed for the RID (queried from the persisted call log by source_id).
  • Write-mode edits to the in-memory catalogue (alias / watch / lockout / priority / tag / group / owner / icon).

Loading aliases

Each trunked system can point at its own RID catalogue with the rid_alias_file key (mirrors talkgroup_file):

trunking:
  systems:
    - name: "Example-P25"
      protocol: p25
      control_channels: [851_000_000]
      talkgroup_file: "/etc/gophertrunk/talkgroups-p25.csv"
      rid_alias_file:  "/etc/gophertrunk/rids-p25.csv"

The file is dispatched by extension: *.json goes through the JSON loader, anything else through the CSV loader.

CSV format

Required column (case-insensitive): Decimal / DEC / ID. Optional columns: Alias (or Alpha Tag / AlphaTag), Description, Tag, Group, Owner, Priority, Lockout, Watch, Icon.

Decimal,Alias,Description,Tag,Group,Owner,Priority,Watch
207545,CPL-SMITH,Patrol corporal,Patrol,Bossier PD,Cpl. Smith,2,
207546,LOCKED,Decommissioned radio,,,L,,no
207547,ENG-12,Fire engine 12,Fire,Bossier Fire,Engine 12,1,

Lockout accepts Y / yes / true / 1; the legacy Priority:L sentinel from talkgroup CSVs also sets Lockout. Watch defaults to true; explicit no / false / 0 / n opts a row out of the watch list.

JSON format

[
  {"id": 207545, "alias": "CPL-SMITH", "owner": "Cpl. Smith", "priority": 2},
  {"id": 207546, "alias": "LOCKED", "lockout": true, "watch": false},
  {"id": 207547, "alias": "ENG-12", "tag": "Fire", "group": "Bossier Fire"}
]

The daemon preflights the file the same way it preflights talkgroup_file — non-fatal warnings for missing / empty paths so the daemon still starts and the affiliation tracker still surfaces live RIDs.

REST surface

Method Path Auth What it does
GET /api/v1/rids open Merged list (configured ∪ live).
GET /api/v1/rids/{id} open Single merged row.
GET /api/v1/rids/{id}/history open call_log filtered by source_id. Same query params as /api/v1/calls/history (limit, only_ended, system). /api/v1/calls/history?source_id={id} is the equivalent on the main history route (the web History panel’s “Source RID” filter / the “All calls from this radio” link). Rows with has_recording play via GET /api/v1/calls/{id}/audio — the ▶ button beside each recent call in the Radio IDs modal.
PATCH /api/v1/rids/{id} mutation-gated Edit alias / description / tag / group / owner / priority / lockout / watch / icon. A radio with no catalogue row is created, so a radio seen only over the air can be named without editing a file first. Optional ?system= scopes the persisted name to one system.
GET /api/v1/labels open The persisted operator-applied names. ?kind=rid\|talkgroup, ?system=. 503 without storage.path.
GET /api/v1/labels/export open Those names as a CSV that loads into rid_alias_file / talkgroup_file. ?kind= (required), ?system=, ?scope=labels\|all.
DELETE /api/v1/labels/{kind}/{id} mutation-gated Forget one persisted name. The in-memory catalogue keeps it until the next restart.

Naming a radio from the web console

Open a radio in Radio IDs, enable write mode, and type a name. This works for radios that appear in no rid_alias_file at all — which is most of the ones worth naming, since they are the ones showing up live.

The on-disk rid_alias_file is never rewritten. When storage.path is set the name/description/tag/group/owner/icon are saved to a labels table in that SQLite database and re-applied over the alias files at every startup, so the name survives a restart; a stored name that disagrees with the file’s logs a WARN naming both. The policy fields (priority, lockout, watch) stay in memory, as before.

Without storage.path the rename still works, but only until the daemon stops.

Export names → CSV on the panel downloads what you have named in the alias file’s own format, so operator-applied names can be folded back into a hand-maintained file whenever you want. scope=all exports the whole merged catalogue instead of just the named rows.

gRPC surface

RIDService in proto/rid.proto mirrors the HTTP surface:

  • ListRIDs(ListRIDsRequest) → ListRIDsResponse
  • GetRID(GetRIDRequest) → GetRIDResponse
  • ListRIDHistory(ListRIDHistoryRequest) → ListRIDHistoryResponse

Read-only for this slice; mutations go through the HTTP PATCH.

Talker aliases

The decoded over-the-air talker alias (the radio’s display name) shows up in two places:

  • On the RID row as talker_alias once the daemon reassembles it, with talker_alias_at as the observation timestamp.
  • As a talker.alias event on the bus / CC Activity feed at decode time.

Three paths feed it:

  • Motorola vendor TSBK — control-channel OpVendorTalkerAlias 0x15, reassembled by phase1.TalkerAliasAssembler (Phase 1) and the Phase 2 vendor MAC opcode.
  • Motorola voice-channel LCs — P25 Phase 1 LDU1 Link Control opcodes 0x15 (header: talkgroup + variable block_count + sequence number) and 0x17 (data blocks, 44 bits each), reassembled per call by phase1.MotorolaTalkerAliasBuf. The reassembled message is run through a reverse-engineered Motorola substitution-table cipher (phase1.decodeAliasBytes) to recover the printable alias characters. (Earlier work assumed the TIA-102.AABF “standard” HEADER+BLOCK1+BLOCK2 layout; real Motorola systems don’t emit that form, so the standard-form decoder was replaced with this vendor variant in a follow-up to PR #389.)
  • P25 Phase 2 FACCH-S signalling follower — on Phase 2 systems the talker alias rides the traffic channel’s FACCH-S MAC signalling during hangtime (Motorola header opcode 0x91 + data 0x95), not the control channel. Decoding it used to require a voice tuner to follow the call, so on busy multi-site systems — where most grants never get a voice tap, and encrypted calls are torn down before hangtime — the alias was almost never decoded (issue #376). The signalling follower (internal/sigfollow) fixes this: it allocates lightweight signalling-only DDC taps on the wideband IQ stream and harvests the alias off the traffic channel independent of the voice pool, the way SDRTrunk does. Enable it with signalling_taps: N on a role: wideband device (see config.example.yaml); the decode runs the same shared MAC dispatch the voice chain uses, so the voice and follower paths never diverge.

See also

  • CC Activity panel — the live chatter feed whose RID chips link into this panel.
  • Web console — overall web UI orientation.
  • Hardening — the bearer-token auth gate that protects the PATCH endpoint.