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>
65 lines
2.3 KiB
Python
65 lines
2.3 KiB
Python
"""
|
|
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))}
|