An incident header had two independently last-write-wins halves, and in the
2026-08-20 dump both were wrong at once. `b9b4f392` opened on a suspect search
at 80 Grasslands Road; it was labelled "100 South Mosher" (its third call),
pinned at `Westmed` (its second), and titled after the label. Five of six
incidents were pinned somewhere other than the place they claimed to be.
Location and pin are now one value
----------------------------------
`_resolve_location_pair()` computes `location`, `location_coords` and the new
`location_coords_source` together, and `_update_incident`/`_create_incident`/
`_create_master_incident` always write all three. There is no longer a code
path that can move one and leave another behind — including the cross-system
master, which used to take its label from the parent and its pin from the call.
The pin now carries the label it was geocoded from. `_verified_pin()` returns
it only when that source still matches the incident's current label; anything
else is dropped. That includes every pre-existing incident, whose pin has no
recorded source and therefore cannot be reconciled — which is the right
outcome, since the dump says 5 in 6 of those are wrong. A missing pin reads as
missing data; a wrong pin reads as fact, and this is a map people may act on.
An incident also keeps the first place it was given rather than the latest.
Later mentions still accumulate in `location_mentions` (what the map path is
drawn from); they just don't rename the incident's own location. The one
permitted change is filling in a pin the incident never had, from a later call
naming the exact same label — geocoding needs the node position, a quota and a
response, so the same address genuinely does fail once and resolve later.
"49" is not a place
-------------------
`clean_location()` rejects any string with no two-letter word in it, applied at
extraction (intelligence.py, before the geocoder and before the call document)
and again at the correlator's context boundary. `9d376ffe` carried
`location: "49"` from "Fire received. Flames from 49." — a box number — and its
summary asserted "A fire incident was reported at location 49". Nothing
validated that field at all, so it would have recurred.
Title: the founding event, escalation only
------------------------------------------
The title was re-derived from the newest classified call, so `f5190670` was
named after the thirteenth of its thirteen events. It now names the call that
opened the incident, recorded in `title_tag`/`title_severity`, and can only be
replaced by a call of strictly higher severity.
Three candidates were considered:
* Newest call (status quo) — rejected. The same incident has a different name
at different times, so a user who saw it in the rail cannot find it again,
and the name is decided by radio timing rather than by the event.
* Highest severity alone — rejected as the sole rule. Severity has four
levels and most traffic sits on one of them, so ties are the common case
and the tiebreak degrades to "newest" — the defect it was meant to fix.
* Founding event, escalated by strictly-greater severity — chosen. An
incident's identity is the event that opened it, so that is its default
name and it is stable for the incident's whole life. The single case where
the header MUST change is the one where the situation got worse: a check
condition that becomes a structure fire is a structure fire, and the
worst-first rail, the "Major only" filter and the map colour all exist so
that is never missed. Requiring strictly-greater makes it monotonic, the
same contract `_max_severity` already gives the severity field: routine
chatter can never take the name back.
A summary-level title regenerated as a whole was rejected outright: it needs an
LLM call per incident, AI flags are off in production, and every incident today
would have no title at all.
Two renames survive, because neither replaces an event name: filling in the
placeholder title of an incident that opened on a call with no content tags
("Police — Ch 1"), and re-rendering the same event once the incident learns its
address. Incidents created before this change have no `title_tag`, so their
existing title is treated as the founding one rather than handed to whichever
call links next.
Interaction with the caps from 33a247d: `incident_max_duration_minutes` /
`incident_max_calls` bound how far an incident can drift, but they don't fix
this — `b9b4f392` was renamed by its third call, 30 minutes in, well inside
both caps. What the caps do change is the cost of being wrong in the other
direction: a founding-derived title can no longer be left describing a
four-hour chain, because there are no four-hour chains any more.
Tests
-----
tests/test_incident_identity.py, 23 cases: the b9b4f392 chain replayed
end-to-end with the label/pin invariant asserted after every link; the pin not
moving without the label; the same-label pin fill-in; an unverifiable legacy
pin dropped; bare numbers, ten-codes and unit designators rejected at
`clean_location`, at `_build_context` and at incident creation; an unrelated
later call not renaming; a worse call renaming and a calmer one not taking it
back; placeholder fill-in; address learned later; legacy title not claimed.
Each was confirmed to fail against the reverted behaviour. 171 passed.
Closes logan/server-26#23
Closes logan/server-26#26
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DRB Server
Full-stack backend for the DRB (Distributed Radio Bot) platform — a community-powered, real-time situational awareness system for public safety radio monitoring. Receives telemetry and audio from SDR edge nodes, runs an AI intelligence pipeline on every call, correlates calls into incidents, dispatches alerts, and serves a live operational dashboard.
What DRB Is
DRB turns radio scanners into a shared geographic intelligence picture. Anyone can contribute coverage by running an edge node (an RTL-SDR dongle on a small Linux machine). All nodes feed into a single platform where operators can:
- Monitor in real time — see active incidents on a map as they unfold, with calls grouped by incident as they come in
- Listen live — stream audio to Discord voice channels or directly in the browser
- Get alerted — receive push notifications when significant events are detected
- Investigate after the fact — search transcribed call history, replay audio, and review incident timelines
The intended deployment is a Tactical Operations Center (TOC) display: a map showing active incidents across all covered jurisdictions, with live audio and alert feeds for operators monitoring an area during an event (severe weather, major incident, etc.).
System Overview
DRB is a distributed SDR (Software Defined Radio) monitoring platform. Edge nodes (small Linux machines with RTL-SDR dongles) decode radio systems and stream audio. The server coordinates those nodes, runs the intelligence pipeline on each call, manages Discord voice bot assignments, and serves the operational dashboard.
Edge Node (client machine)
│
├── MQTT checkin/status/metadata ──► mosquitto ──► c2-core ──► Firestore
│
└── MQTT commands ◄─────────────────────────────── c2-core
│
└── discord_join ──► edge node joins Discord voice + streams PulseAudio
Discord User
└── /join /leave /status /help ──► discord-bot ──► c2-core ──► MQTT ──► edge node
Browser (admin)
└── frontend ──► Firestore (real-time reads, Firebase Auth)
└── c2-core REST API (writes, commands, token pool)
Services
| Service | Container | Description | Port |
|---|---|---|---|
mosquitto |
eclipse-mosquitto |
MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
mosquitto |
eclipse-mosquitto |
MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
c2-core |
Python 3.11 / FastAPI | Command & control API — MQTT handler, intelligence pipeline, incident correlator, alert dispatch, node/system/token management | 8000 |
discord-bot |
Python 3.11 / discord.py | Server-side Discord slash command bot — /join, /leave, /status, /help |
— |
frontend |
Node.js / Next.js 14 | Operational web dashboard — live map, incident feed, call history, node and system management | 3000 |
Directory Structure
Server/
├── docker-compose.yml # Orchestrates all four services
├── Makefile # Convenience targets (setup/build/up/down/logs)
│
├── drb-c2-core/ # FastAPI command & control backend
│ ├── app/
│ │ ├── main.py # App entry point — router registration, MQTT startup, node sweeper
│ │ ├── config.py # Pydantic settings (reads from .env)
│ │ ├── models.py # Shared Pydantic models (CommandPayload, etc.)
│ │ ├── routers/
│ │ │ ├── nodes.py # Node CRUD, approve/reject, command dispatch, system assignment
│ │ │ ├── systems.py # Radio system CRUD (P25/DMR/NBFM configs)
│ │ │ ├── calls.py # Call log retrieval (read-only — calls created by MQTT handler)
│ │ │ ├── tokens.py # Discord bot token pool — add/delete/list; assign_token/release_token helpers
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes); triggers intelligence pipeline
│ │ │ ├── incidents.py # Incident CRUD, manual link/unlink calls, resolve
│ │ │ └── alerts.py # Alert rule CRUD
│ │ └── internal/
│ │ ├── auth.py # Auth — Firebase ID token OR service key (Bearer)
│ │ ├── firestore.py # Async Firestore wrappers (doc_get, doc_set, doc_update, collection_list)
│ │ ├── mqtt_handler.py # MQTT subscriber — call_start/call_end handlers, node checkin/status
│ │ ├── node_sweeper.py # Background task — marks nodes offline after 90s without heartbeat
│ │ ├── storage.py # GCS audio file uploads
│ │ ├── transcription.py # STT pipeline — OpenAI Whisper / GPT-4o transcribe
│ │ ├── intelligence.py # GPT scene extraction: tags, incident type, location, units, vehicles; geocoding
│ │ ├── incident_correlator.py # Hybrid correlator — matches calls to incidents or creates new ones
│ │ └── alerter.py # Alert dispatch — evaluates rules, sends notifications
│ ├── scripts/
│ │ └── set_admin.py # CLI to grant/revoke Firebase admin custom claim
│ ├── gcp-key.json # GCP service account key — place here, NOT committed to git
│ └── .env.example
│
├── drb-server-discord-bot/ # Discord slash command bot (server-side)
│ ├── app/
│ │ ├── main.py # Bot startup, cog loading, guild sync
│ │ ├── config.py # Settings (Discord token, C2 URL, service key)
│ │ ├── commands/
│ │ │ └── radio.py # /join (with token autocomplete), /leave, /status, /help
│ │ └── internal/
│ │ └── c2_client.py # Async HTTP client to c2-core — includes service key auth header
│ └── .env.example
│
└── drb-frontend/ # Next.js 14 admin dashboard
├── app/
│ ├── dashboard/ # Overview: active node grid + live call feed
│ ├── map/ # Leaflet map — node locations, active incident pins
│ ├── calls/ # Call history — talkgroup, duration, audio playback, transcript
│ ├── nodes/ # Node list + per-node detail (approve, reject, assign system)
│ ├── systems/ # Radio system CRUD (P25 form with talkgroup editor, DMR/NBFM JSON)
│ ├── tokens/ # Discord bot token pool management
│ ├── incidents/ # Incident list/detail — linked calls, location, summary, resolve
│ ├── alerts/ # Alert rule configuration
│ └── login/ # Firebase Auth login page
├── components/
│ ├── MapView.tsx # react-leaflet map with status-colored markers
│ ├── NodeCard.tsx # Node status card with action buttons
│ ├── NodeConfigModal.tsx # Modal for assigning a radio system to a node
│ ├── CallRow.tsx # Call history row — talkgroup, duration, audio player
│ ├── AuthProvider.tsx # Firebase Auth context + middleware guard
│ ├── Nav.tsx # Sidebar navigation
│ └── StatusBadge.tsx # Color-coded status pill (online/recording/offline/unconfigured)
├── lib/
│ ├── firebase.ts # Firebase JS SDK initialization
│ ├── c2api.ts # Fetch wrapper for c2-core REST API (attaches Firebase ID token)
│ ├── types.ts # Shared TypeScript types (NodeRecord, CallRecord, SystemRecord, etc.)
│ ├── useNodes.ts # Firestore real-time subscription to nodes collection
│ ├── useCalls.ts # Firestore real-time subscription to calls (active + history)
│ └── useSystems.ts # Firestore real-time subscription to systems collection
├── next.config.mjs # Next.js config — standalone output, transpilePackages for Leaflet
└── .env.example
Prerequisites
- Docker + Docker Compose
- A Firebase project with:
- Firestore enabled (Native mode)
- Firebase Authentication enabled (Email/Password provider)
- A GCP service account key with Firestore + Firebase Auth Admin SDK permissions
- A Discord application with:
- One bot token for the server command bot (handles
/join,/leave, etc.) - One or more additional bot tokens for the pool (edge nodes use these to join voice channels)
- One bot token for the server command bot (handles
Setup
# 1. Copy all env files
make setup
# 2. Place your GCP service account key
cp /path/to/your-service-account-key.json drb-c2-core/gcp-key.json
# 3. Fill in secrets — see tables below
nano drb-c2-core/.env
nano drb-server-discord-bot/.env
nano drb-frontend/.env
# 4. Build and start
make build
make up
# 5. Grant admin access to your Firebase user
cd drb-c2-core
python scripts/set_admin.py grant your@email.com
# Sign out and back in to the frontend for the claim to take effect
Environment Variables
drb-c2-core/.env
| Variable | Required | Description |
|---|---|---|
MQTT_BROKER |
Yes | Hostname of the MQTT broker (default: mosquitto) |
MQTT_PORT |
No | MQTT broker port (default: 1883) |
FIRESTORE_DATABASE |
No | Firestore database name (default: (default)) |
GCP_CREDENTIALS_PATH |
Yes | Path to the service account key inside the container (e.g. /secrets/gcp-key.json) |
GCS_BUCKET |
No | GCS bucket name for storing call audio recordings |
SERVICE_KEY |
Yes* | Shared secret for internal service-to-service auth. Must match C2_SERVICE_KEY in the discord-bot env |
*Required for the server Discord bot to authenticate against the C2 API.
drb-server-discord-bot/.env
| Variable | Required | Description |
|---|---|---|
DISCORD_TOKEN |
Yes | Bot token for the server-side slash command bot |
C2_URL |
Yes | Internal URL of c2-core (e.g. http://c2-core:8000) |
C2_SERVICE_KEY |
Yes* | Must match SERVICE_KEY in drb-c2-core — sent as a Bearer token on all C2 API calls |
DEV_GUILD_ID |
No | Discord guild ID to sync slash commands instantly during development |
drb-frontend/.env
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_FIREBASE_API_KEY |
Yes | Firebase web API key |
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN |
Yes | Firebase auth domain |
NEXT_PUBLIC_FIREBASE_PROJECT_ID |
Yes | GCP/Firebase project ID |
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET |
Yes | Firebase storage bucket |
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID |
Yes | Firebase messaging sender ID |
NEXT_PUBLIC_FIREBASE_APP_ID |
Yes | Firebase app ID |
NEXT_PUBLIC_FIRESTORE_DATABASE |
No | Firestore database name — must match c2-core (default: (default)) |
NEXT_PUBLIC_C2_URL |
Yes | C2 API URL reachable from the user's browser |
Authentication
Two auth mechanisms are supported on all C2 routes:
- Firebase ID tokens — issued by Firebase Auth to browser users. The frontend attaches these automatically via
c2api.ts. Required for the dashboard to call the C2 API. - Service key — a static shared secret for internal service calls. The server Discord bot sends this as a
Bearertoken. SetSERVICE_KEYin c2-core andC2_SERVICE_KEYin the discord-bot to the same value.
Admin-only routes (node approve/reject) additionally require the Firebase user to have an admin: true custom claim:
python drb-c2-core/scripts/set_admin.py grant your@email.com
python drb-c2-core/scripts/set_admin.py revoke your@email.com
Discord Bot Token Pool
Edge nodes join Discord voice channels using bot tokens managed by the server. Add tokens in the Tokens page of the frontend (or POST /tokens). Each token has:
- A friendly name
- The actual Discord bot token string (masked in list views)
in_useflag and the node it's currently assigned to
Flow when a user runs /join:
- Discord bot sends a
discord_joincommand to c2-core with the target guild and channel IDs. - c2-core picks a free token (optionally the one requested via autocomplete), marks it
in_use, and forwards the token + channel info to the target edge node over MQTT. - The edge node starts a
discord.pybot with that token and joins the voice channel. - The bot streams live audio continuously from PulseAudio (
drb_sink.monitoron the edge node). Icecast is for browser listening only. - On
/leave, c2-core releases the token back to the pool.
Node Lifecycle
- Edge node starts → sends MQTT
checkin→ appears as pending in the dashboard - Admin clicks Approve → c2-core generates a random API key, stores it in
node_keys, publishes it to the node over MQTT, updates node toapproved - Admin assigns a radio system via Config → c2-core pushes the full system config (frequencies, talkgroups, Icecast settings) over MQTT
- Edge node writes the config and (re)starts OP25
- Node sends
online/recording/unconfiguredstatus via 30s MQTT heartbeats node_sweeperbackground task marks any node offline after 90s without a heartbeat
Call & Intelligence Pipeline
Every radio call flows through a four-stage pipeline triggered when the edge node uploads its recording:
Edge node ──► MQTT call_start ──► CallRecord created (active)
│
Edge node ──► audio upload ──► GCS storage
│
▼
[1] TRANSCRIPTION
OpenAI Whisper / GPT-4o transcribe
→ CallRecord.transcript
│
▼
[2] INTELLIGENCE EXTRACTION (GPT-4o-mini)
Scene detection — splits multi-incident recordings
Speaker role inference — dispatch vs. unit patterns
used to correctly attribute locations (dispatch-
provided address vs. unit position report) and
units (being dispatched vs. acknowledging)
Entity extraction: tags, incident_type, location,
units, vehicles, severity, resolved flag
+ geocoding (Google Maps)
+ embedding (text-embedding-3-small)
→ CallRecord.tags, .location, .units, etc.
│
▼
[3] INCIDENT CORRELATION (hybrid engine)
Fast path — talkgroup + recency
Unit path — same officer continuity
Location — proximity match
Cross-TG — multi-agency / channel hop
Slow path — embedding similarity
→ IncidentRecord created or updated
│
▼
[4] ALERT DISPATCH
Evaluate alert rules (keywords, talkgroups)
→ notifications sent
Frontend Pages
| Page | URL | Description |
|---|---|---|
| Dashboard | /dashboard |
Live node grid + active call stream |
| Map | /map |
Leaflet map — nodes color-coded by status, active call popups |
| Calls | /calls |
Full call history — talkgroup, duration, audio playback, transcript |
| Nodes | /nodes |
Node list; per-node detail for approve/reject/assign system |
| Systems | /systems |
Create and manage P25/DMR/NBFM radio system configurations |
| Tokens | /tokens |
Discord bot token pool management |
| Incidents | /incidents |
Incident list/detail — linked calls, location, summary, tags, resolve |
| Alerts | /alerts |
Not built |
Makefile Targets
make setup — copy .env.example files to .env
make build — docker compose build
make up — docker compose up -d
make down — docker compose down
make logs — follow all service logs
make logs-c2 — c2-core logs only
make logs-bot — discord-bot logs only
make logs-frontend — frontend logs only