Systems: P25 trunked / P25 conventional / analog as one choice (node-26#24)

- system_config.normalize(): a P25 system with config.mode "conventional"
  carries config.channels [{freq MHz, name, nac?}] (1-2, within 1.5 MHz) and
  can no longer carry control channels (422). A missing mode is trunked and
  passes through untouched, so there is no migration. An analog channel is
  mirrored into control_channels for older nodes.
- talkgroups.name_from_system resolves the edge node's synthetic per-channel
  id (freq in 100 Hz units) to the channel name.
- The checkin persists radio_channels (conventional channels + encryption).
- Systems editor: Type = P25 trunked / P25 conventional / Analog (NBFM) /
  DMR, plus a channel-row editor for conventional and a one-frequency form for
  analog. The node page shows conventional channels and an encryption notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 15:55:34 -04:00
co-authored by Claude Opus 5.5
parent ae0ece9d2c
commit 23860eb31b
9 changed files with 649 additions and 41 deletions
+14 -1
View File
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
from app.models import AreaContextBody, SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal import area_context as area_ctx
from app.internal import system_config
from app.internal.auth import (
require_admin_token,
require_node_service_or_firebase_token,
@@ -38,6 +39,14 @@ class AiFlagsBody(BaseModel):
correlation_enabled: Optional[bool] = None
def _checked_config(system_type: str, config: dict) -> dict:
"""Trunked/conventional/analog shape check (node-26#24) — 422 with the reason."""
try:
return system_config.normalize(system_type, config)
except system_config.SystemConfigError as e:
raise HTTPException(422, str(e))
@router.get("")
async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
org_id = await resolve_caller_org_id(decoded)
@@ -64,7 +73,9 @@ async def create_system(
_: dict = Depends(require_admin_token),
):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump())
fields = body.model_dump()
fields["config"] = _checked_config(body.type, fields["config"])
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **fields)
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
return doc
@@ -80,6 +91,8 @@ async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(re
# save — they are edited through PUT /{id}/ten-codes and were never in this
# payload. area_context (server-26#36) would have been the second casualty.
patch = body.model_dump(exclude_unset=True)
if "config" in patch:
patch["config"] = _checked_config(patch.get("type", existing.get("type")), patch["config"])
# The form sends config.talkgroups[] in full, which would erase the resolved
# anchor and the pending-term queue the backend put there. Same class of bug
# as ten_codes above; the backend merges its own fields back rather than