Calls: store every radio heard on a call; call_end writes srcaddr (server-26#304)
Build & Deploy / Build & push images (push) Successful in 4m36s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped

P1 of the radio-ID plan. The node now reports `radios`, one
{radio, start, end} per transmission, on /upload and on call_end. Both go
through app/internal/radios.py into `radios` + `radio_ids` on the call doc.
/upload writes them before the pipeline starts, because call_end arrives
after it.

call_end also writes srcaddr now. Only call_start used to, and the node
usually learns the talker after call_start, so most calls kept None and the
correlator's srcaddr match rarely fired. /upload fills srcaddr from the
latest radio when the doc has none.

Older nodes send neither field; nothing is blanked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-11-08 23:03:08 -05:00
co-authored by Claude Opus 5.5
parent cbf45ff004
commit 5cdde85d55
4 changed files with 181 additions and 0 deletions
+7
View File
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
from app.config import settings
from app.internal.logger import logger
from app.internal import firestore as fstore
from app.internal import radios
from app.internal import talkgroups
from app.internal.tenancy import FOUNDING_ORG_ID
@@ -307,6 +308,12 @@ class MQTTHandler:
updates["org_id"] = node["org_id"]
if payload.get("audio_url"):
updates["audio_url"] = payload["audio_url"]
# The node usually learns the talker after call_start went out; only
# call_start used to write srcaddr, so most calls kept None
# (server-26#304).
if payload.get("srcaddr") is not None:
updates["srcaddr"] = payload["srcaddr"]
updates.update(radios.call_fields(payload.get("radios")))
await fstore.doc_set("calls", call_id, updates)
logger.info(f"Call end: {call_id}")
+64
View File
@@ -0,0 +1,64 @@
"""
Every radio heard on a call, as the edge node reports it (server-26#304).
The node sends `radios`: a list of {radio, start, end}, one entry per
transmission, offsets in seconds into the uploaded audio file, so a talker's
wall-clock time is audio_start_epoch + offset (the same mapping the node uses
for the audio itself). It arrives twice — on
/upload (as a JSON string form field, because the pipeline starts there) and
on the MQTT call_end — and both go through `call_fields` so the call doc gets
the same shape either way.
`radio_ids` is the distinct IDs in first-heard order, kept alongside so a
correlator or query can ask "was radio X on this call" without walking the list.
"""
import json
from typing import Any, Dict, List, Optional
from app.internal.logger import logger
# A 600 s recording polled every 0.5 s cannot honestly hold more than this.
MAX_TRANSMISSIONS = 200
def _num(value: Any) -> Optional[float]:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number >= 0 else None
def parse(value: Any) -> List[Dict[str, Any]]:
"""The node's radio list, validated. Accepts a list or its JSON string; junk entries are dropped."""
if value is None or value == "":
return []
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
logger.warning(f"radios: not JSON, ignored ({value[:80]!r})")
return []
if not isinstance(value, list):
return []
out: List[Dict[str, Any]] = []
for item in value[:MAX_TRANSMISSIONS]:
if not isinstance(item, dict):
continue
try:
radio = int(item.get("radio"))
except (TypeError, ValueError):
continue
start, end = _num(item.get("start")), _num(item.get("end"))
if radio <= 0 or start is None or end is None:
continue
out.append({"radio": radio, "start": start, "end": max(start, end)})
return out
def call_fields(value: Any) -> Dict[str, Any]:
"""Call-doc fields for a radio list; empty when the node sent none (older nodes, analog)."""
radios = parse(value)
if not radios:
return {}
return {"radios": radios, "radio_ids": list(dict.fromkeys(r["radio"] for r in radios))}
+10
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPExce
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio
from app.internal import dedup
from app.internal import radios
from app.internal import firestore as fstore
from app.internal import clock
from app.internal.logger import logger
@@ -24,6 +25,7 @@ async def upload_call_audio(
talkgroup_id: Optional[int] = Form(None),
talkgroup_name: Optional[str] = Form(None),
system_id: Optional[str] = Form(None),
radios_json: Optional[str] = Form(None, alias="radios"),
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
):
"""
@@ -101,6 +103,14 @@ async def upload_call_audio(
updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"):
updates["org_id"] = node["org_id"]
# Every radio heard on the call (server-26#304). Written here, not
# only on call_end, because the pipeline below starts before the
# node publishes call_end.
radio_fields = radios.call_fields(radios_json)
updates.update(radio_fields)
if radio_fields and existing.get("srcaddr") is None:
# Same meaning call_end gives it: the latest talker.
updates["srcaddr"] = radio_fields["radios"][-1]["radio"]
await fstore.doc_set("calls", call_id, updates)
except Exception as e:
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")