Secondary SDR priority: every spare SDR runs the next decoder in an ordered list
Replaces the single secondary_sdr_mode with secondary_sdr_priority, e.g. ["adsb", "ais"]. OP25 always keeps its own dongle; the secondary-sdr container starts decoders top-down until it runs out of free SDRs, so a 3-SDR node runs ADS-B and AIS at once and a 2-SDR node runs the top pick. - secondary-sdr: one decoder per mode, POST /secondary/apply(priority) (no-op when the right prefix is already running), orphaned decoders from a uvicorn reload are reaped on start, /status reports sdr_count via lsusb (op25's :stable image has none). - edge-node: one apply path (set_secondary_priority) for the local dashboard, a new C2 'set_secondary_priority' MQTT command, and config pushes; it never restarts op25. Legacy mode migrates on load. Checkin reports priority, what's running, and sdr_count. Uplink forwards aircraft and vessels whenever either is present. - Local dashboard: 'Secondary SDRs' card to enable/reorder/save. Verified: edge-node pytest 190 passed, flake8 clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1c74edffea
commit
9b6c64fbbf
@@ -210,10 +210,14 @@ class MQTTManager:
|
|||||||
logger.info("No API key on disk — requesting re-delivery from C2 server.")
|
logger.info("No API key on disk — requesting re-delivery from C2 server.")
|
||||||
self._publish(self._t_key_request, {}, qos=1)
|
self._publish(self._t_key_request, {}, qos=1)
|
||||||
|
|
||||||
|
async def publish_checkin(self):
|
||||||
|
await self._publish_checkin()
|
||||||
|
|
||||||
async def _publish_checkin(self):
|
async def _publish_checkin(self):
|
||||||
from app.internal.discord_radio import radio_bot
|
from app.internal.discord_radio import radio_bot
|
||||||
from app.internal.config_manager import load_node_config
|
from app.internal.config_manager import load_node_config
|
||||||
from app.internal.op25_client import op25_client
|
from app.internal.op25_client import op25_client
|
||||||
|
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||||
config = load_node_config()
|
config = load_node_config()
|
||||||
devices = await op25_client.devices()
|
devices = await op25_client.devices()
|
||||||
payload = {
|
payload = {
|
||||||
@@ -228,11 +232,18 @@ class MQTTManager:
|
|||||||
"override_system_id": config.override_system_id,
|
"override_system_id": config.override_system_id,
|
||||||
"enforce_override_timeout": config.enforce_override_timeout,
|
"enforce_override_timeout": config.enforce_override_timeout,
|
||||||
"secondary_sdr_mode": config.secondary_sdr_mode,
|
"secondary_sdr_mode": config.secondary_sdr_mode,
|
||||||
|
"secondary_sdr_priority": config.secondary_sdr_priority,
|
||||||
}
|
}
|
||||||
# Best-effort — the first node-initiated hardware-report field. Omit
|
secondary = await secondary_sdr_client.status()
|
||||||
# rather than guess if op25's control API is unreachable.
|
if secondary is not None:
|
||||||
if devices is not None:
|
payload["secondary_sdr_running"] = [r["mode"] for r in secondary.get("running", [])]
|
||||||
payload["sdr_count"] = devices.get("count")
|
# Best-effort hardware report — omit rather than guess. op25's :stable
|
||||||
|
# image has no lsusb, so fall back to the secondary-sdr container's count.
|
||||||
|
count = devices.get("count") if devices else None
|
||||||
|
if count is None and secondary is not None:
|
||||||
|
count = secondary.get("sdr_count")
|
||||||
|
if count is not None:
|
||||||
|
payload["sdr_count"] = count
|
||||||
self._publish(self._t_checkin, payload, qos=1)
|
self._publish(self._t_checkin, payload, qos=1)
|
||||||
|
|
||||||
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
|
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
|
from app.internal.config_manager import load_node_config, save_node_config
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||||
|
from app.models import normalize_secondary_priority
|
||||||
|
|
||||||
|
|
||||||
|
async def set_secondary_priority(priority: Iterable[str]) -> Optional[List[str]]:
|
||||||
|
"""Persist and apply the node's secondary SDR priority — one path for the
|
||||||
|
local dashboard, a C2 command and a config push alike. Never touches op25:
|
||||||
|
changing what the spare dongles do must not interrupt P25 recording.
|
||||||
|
|
||||||
|
Returns the decoders now running (None if the container is unreachable).
|
||||||
|
"""
|
||||||
|
ordered = normalize_secondary_priority(priority)
|
||||||
|
cfg = load_node_config()
|
||||||
|
cfg.secondary_sdr_priority = ordered
|
||||||
|
cfg.secondary_sdr_mode = ordered[0] if ordered else "none"
|
||||||
|
save_node_config(cfg)
|
||||||
|
|
||||||
|
running = await secondary_sdr_client.apply(ordered)
|
||||||
|
logger.info(f"Secondary SDR priority set to {ordered!r}; running {running!r}")
|
||||||
|
|
||||||
|
# Report straight away so C2's view doesn't wait for the next heartbeat.
|
||||||
|
from app.internal.mqtt_manager import mqtt_manager
|
||||||
|
asyncio.create_task(mqtt_manager.publish_checkin())
|
||||||
|
return running
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
@@ -15,6 +15,18 @@ class SecondarySdrClient:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_url = settings.secondary_sdr_api_url
|
self.api_url = settings.secondary_sdr_api_url
|
||||||
|
|
||||||
|
async def apply(self, priority: List[str]) -> Optional[List[str]]:
|
||||||
|
"""Run decoders down the priority list until SDRs run out. Returns the
|
||||||
|
modes actually running, or None if the container is unreachable."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
r = await client.post(f"{self.api_url}/secondary/apply", json={"priority": priority})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("running", [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Secondary SDR apply (priority={priority!r}) failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def start(self, mode: str) -> bool:
|
async def start(self, mode: str) -> bool:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ async def _post_snapshot(path: str, body: dict) -> None:
|
|||||||
async def telemetry_uplink_loop():
|
async def telemetry_uplink_loop():
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(UPLINK_INTERVAL_SECONDS)
|
await asyncio.sleep(UPLINK_INTERVAL_SECONDS)
|
||||||
config = load_node_config()
|
if not load_node_config().secondary_sdr_priority:
|
||||||
if config.secondary_sdr_mode not in ("adsb", "ais"):
|
|
||||||
continue
|
continue
|
||||||
snapshot = await secondary_sdr_client.data()
|
snapshot = await secondary_sdr_client.data()
|
||||||
if not snapshot:
|
if not snapshot:
|
||||||
continue
|
continue
|
||||||
if config.secondary_sdr_mode == "adsb" and snapshot.get("aircraft"):
|
# Several decoders can run at once (one per spare SDR), so forward
|
||||||
|
# whatever each produced rather than keying off a single mode.
|
||||||
|
if snapshot.get("aircraft"):
|
||||||
await _post_snapshot("/telemetry/adsb", {"aircraft": snapshot["aircraft"]})
|
await _post_snapshot("/telemetry/adsb", {"aircraft": snapshot["aircraft"]})
|
||||||
elif config.secondary_sdr_mode == "ais" and snapshot.get("vessels"):
|
if snapshot.get("vessels"):
|
||||||
await _post_snapshot("/telemetry/ais", {"vessels": snapshot["vessels"]})
|
await _post_snapshot("/telemetry/ais", {"vessels": snapshot["vessels"]})
|
||||||
|
|||||||
+14
-12
@@ -158,6 +158,9 @@ async def on_command(payload: dict):
|
|||||||
)
|
)
|
||||||
elif action == "discord_leave":
|
elif action == "discord_leave":
|
||||||
await radio_bot.leave()
|
await radio_bot.leave()
|
||||||
|
elif action == "set_secondary_priority":
|
||||||
|
from app.internal.secondary_priority import set_secondary_priority
|
||||||
|
await set_secondary_priority(payload.get("priority") or [])
|
||||||
elif action == "op25_restart":
|
elif action == "op25_restart":
|
||||||
from app.internal.op25_client import op25_client
|
from app.internal.op25_client import op25_client
|
||||||
await op25_client.stop()
|
await op25_client.stop()
|
||||||
@@ -224,7 +227,8 @@ async def on_config_push(payload: dict):
|
|||||||
hardware_preset = payload.pop("hardware_preset", None)
|
hardware_preset = payload.pop("hardware_preset", None)
|
||||||
ppm_override = payload.pop("ppm_override", None)
|
ppm_override = payload.pop("ppm_override", None)
|
||||||
node_type = payload.pop("node_type", None)
|
node_type = payload.pop("node_type", None)
|
||||||
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None)
|
secondary_sdr_priority = payload.pop("secondary_sdr_priority", None)
|
||||||
|
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) # legacy single-mode C2
|
||||||
enforce_override_timeout = payload.pop("enforce_override_timeout", None)
|
enforce_override_timeout = payload.pop("enforce_override_timeout", None)
|
||||||
try:
|
try:
|
||||||
config = SystemConfig(**payload)
|
config = SystemConfig(**payload)
|
||||||
@@ -244,8 +248,6 @@ async def on_config_push(payload: dict):
|
|||||||
node_cfg.ppm_override = float(ppm_override)
|
node_cfg.ppm_override = float(ppm_override)
|
||||||
if node_type is not None:
|
if node_type is not None:
|
||||||
node_cfg.node_type = node_type
|
node_cfg.node_type = node_type
|
||||||
if secondary_sdr_mode is not None:
|
|
||||||
node_cfg.secondary_sdr_mode = secondary_sdr_mode
|
|
||||||
if enforce_override_timeout is not None:
|
if enforce_override_timeout is not None:
|
||||||
node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
|
node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
|
||||||
save_node_config(node_cfg)
|
save_node_config(node_cfg)
|
||||||
@@ -260,12 +262,11 @@ async def on_config_push(payload: dict):
|
|||||||
await op25_client.start()
|
await op25_client.start()
|
||||||
logger.info(f"Config push applied: {config.name}")
|
logger.info(f"Config push applied: {config.name}")
|
||||||
|
|
||||||
if secondary_sdr_mode is not None:
|
if secondary_sdr_priority is None and secondary_sdr_mode is not None:
|
||||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
secondary_sdr_priority = [secondary_sdr_mode]
|
||||||
if secondary_sdr_mode in ("adsb", "ais"):
|
if secondary_sdr_priority is not None:
|
||||||
await secondary_sdr_client.start(secondary_sdr_mode)
|
from app.internal.secondary_priority import set_secondary_priority
|
||||||
else:
|
await set_secondary_priority(secondary_sdr_priority)
|
||||||
await secondary_sdr_client.stop()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -322,10 +323,11 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
|
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
if node_cfg.secondary_sdr_mode in ("adsb", "ais"):
|
# After op25 has claimed its dongle, so the decoders only get the spares.
|
||||||
|
if node_cfg.secondary_sdr_priority:
|
||||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||||
logger.info(f"Resuming secondary SDR (mode={node_cfg.secondary_sdr_mode!r}) after restart.")
|
logger.info(f"Resuming secondary SDRs (priority={node_cfg.secondary_sdr_priority!r}) after restart.")
|
||||||
await secondary_sdr_client.start(node_cfg.secondary_sdr_mode)
|
await secondary_sdr_client.apply(node_cfg.secondary_sdr_priority)
|
||||||
|
|
||||||
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
|
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
|
||||||
from app.internal.telemetry_uplink import telemetry_uplink_loop
|
from app.internal.telemetry_uplink import telemetry_uplink_loop
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, model_validator
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Iterable, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -23,6 +23,21 @@ class SystemConfig(BaseModel):
|
|||||||
config: Dict[str, Any] # OP25-compatible config blob passed through to op25-container
|
config: Dict[str, Any] # OP25-compatible config blob passed through to op25-container
|
||||||
|
|
||||||
|
|
||||||
|
# Decoders a node can run on the SDRs beyond op25's (node-26#9). op25 always
|
||||||
|
# keeps its own dongle; each further dongle runs the next entry of the node's
|
||||||
|
# secondary_sdr_priority, so a 3-SDR node with ["adsb", "ais"] runs both.
|
||||||
|
SECONDARY_SDR_MODES = ("adsb", "ais")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_secondary_priority(items: Iterable[str]) -> List[str]:
|
||||||
|
"""Known modes only, first occurrence wins, order preserved."""
|
||||||
|
out: List[str] = []
|
||||||
|
for m in items or []:
|
||||||
|
if m in SECONDARY_SDR_MODES and m not in out:
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class NodeConfig(BaseModel):
|
class NodeConfig(BaseModel):
|
||||||
node_id: str
|
node_id: str
|
||||||
node_name: str
|
node_name: str
|
||||||
@@ -34,12 +49,22 @@ class NodeConfig(BaseModel):
|
|||||||
hardware_preset: str = "rtl-sdr-v3"
|
hardware_preset: str = "rtl-sdr-v3"
|
||||||
ppm_override: Optional[float] = None
|
ppm_override: Optional[float] = None
|
||||||
node_type: str = "fixed" # fixed or portable
|
node_type: str = "fixed" # fixed or portable
|
||||||
secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR
|
secondary_sdr_priority: List[str] = [] # ordered; SDRs beyond op25's run these top-down
|
||||||
|
# Legacy single-mode field (pre-priority). Still written as priority[0] so
|
||||||
|
# an older C2 reading checkins sees something sensible; read only to
|
||||||
|
# migrate a node_config.json saved before priority existed.
|
||||||
|
secondary_sdr_mode: str = "none"
|
||||||
enforce_override_timeout: bool = True
|
enforce_override_timeout: bool = True
|
||||||
override_system_id: Optional[str] = None
|
override_system_id: Optional[str] = None
|
||||||
override_config: Optional[SystemConfig] = None
|
override_config: Optional[SystemConfig] = None
|
||||||
offline_call_buffer_size: int = 35 # max call_end events to buffer while MQTT is offline
|
offline_call_buffer_size: int = 35 # max call_end events to buffer while MQTT is offline
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _migrate_secondary_mode(self) -> "NodeConfig":
|
||||||
|
if not self.secondary_sdr_priority and self.secondary_sdr_mode in SECONDARY_SDR_MODES:
|
||||||
|
self.secondary_sdr_priority = [self.secondary_sdr_mode]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class CallEvent(BaseModel):
|
class CallEvent(BaseModel):
|
||||||
call_id: str
|
call_id: str
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import SystemConfig
|
from app.models import SystemConfig, SECONDARY_SDR_MODES
|
||||||
from app.internal.op25_client import op25_client
|
from app.internal.op25_client import op25_client
|
||||||
from app.internal.config_manager import load_node_config, save_node_config, apply_system_config
|
from app.internal.config_manager import load_node_config, save_node_config, apply_system_config
|
||||||
from app.internal.call_recorder import call_recorder
|
from app.internal.call_recorder import call_recorder
|
||||||
@@ -204,6 +204,30 @@ async def ack_override(timeout_minutes: int = Body(1440)):
|
|||||||
raise HTTPException(500, f"Failed to contact C2: {e}")
|
raise HTTPException(500, f"Failed to contact C2: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/secondary")
|
||||||
|
async def get_secondary():
|
||||||
|
"""Secondary SDR priority plus what's actually running (node-26#9)."""
|
||||||
|
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||||
|
status = await secondary_sdr_client.status()
|
||||||
|
return {
|
||||||
|
"priority": load_node_config().secondary_sdr_priority,
|
||||||
|
"modes": list(SECONDARY_SDR_MODES),
|
||||||
|
"running": [r["mode"] for r in status.get("running", [])] if status else None,
|
||||||
|
"sdr_count": status.get("sdr_count") if status else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/secondary/priority")
|
||||||
|
async def set_secondary_priority(priority: List[str] = Body(..., embed=True)):
|
||||||
|
"""Set the ordered list the spare SDRs work through. Never restarts op25."""
|
||||||
|
from app.internal.secondary_priority import set_secondary_priority as apply_priority
|
||||||
|
unknown = [m for m in priority if m not in SECONDARY_SDR_MODES]
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(400, f"Unknown secondary SDR mode(s): {unknown}")
|
||||||
|
running = await apply_priority(priority)
|
||||||
|
return {"ok": True, "priority": load_node_config().secondary_sdr_priority, "running": running}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/discord/join")
|
@router.post("/discord/join")
|
||||||
async def discord_join(guild_id: int, channel_id: int):
|
async def discord_join(guild_id: int, channel_id: int):
|
||||||
ok = await radio_bot.join(guild_id, channel_id)
|
ok = await radio_bot.join(guild_id, channel_id)
|
||||||
|
|||||||
@@ -105,6 +105,30 @@
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sdr-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.sdr-row:last-child { border-bottom: none; }
|
||||||
|
.sdr-rank { width: 1.2rem; color: var(--text-muted); font-size: 0.8rem; text-align: right; }
|
||||||
|
.sdr-name { flex: 1; }
|
||||||
|
.sdr-name small { display: block; color: var(--text-muted); font-size: 0.75rem; }
|
||||||
|
.sdr-move {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sdr-move:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.sdr-state { font-size: 0.75rem; min-width: 6.5rem; text-align: right; color: var(--text-muted); }
|
||||||
|
.sdr-state.on { color: var(--success); }
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
@@ -360,6 +384,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Secondary SDRs Card (node-26#9) -->
|
||||||
|
<div class="glass-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Secondary SDRs</div>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.8rem; margin: 0 0 0.5rem;">
|
||||||
|
OP25 always keeps its own SDR. Every other SDR runs the next enabled item below, top first.
|
||||||
|
<span id="sdr-summary"></span>
|
||||||
|
</p>
|
||||||
|
<div id="sdr-list"></div>
|
||||||
|
<div style="display:flex; gap:0.75rem; align-items:center; margin-top:0.75rem;">
|
||||||
|
<button id="sdr-save" class="btn btn-primary" style="padding:0.5rem 1rem;" onclick="saveSdrPriority()" disabled>Save priority</button>
|
||||||
|
<span id="sdr-msg" style="color: var(--text-muted); font-size: 0.8rem;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Local Listening Card -->
|
<!-- Local Listening Card -->
|
||||||
<div class="glass-card player-card">
|
<div class="glass-card player-card">
|
||||||
<div class="card-header" style="margin-bottom:0.5rem; border:none;">
|
<div class="card-header" style="margin-bottom:0.5rem; border:none;">
|
||||||
@@ -492,6 +532,99 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Secondary SDR priority ────────────────────────────────────────────
|
||||||
|
const SDR_LABELS = {
|
||||||
|
adsb: ['ADS-B', 'Aircraft · 1090 MHz'],
|
||||||
|
ais: ['AIS', 'Vessels · 162 MHz'],
|
||||||
|
};
|
||||||
|
let sdrRows = []; // [{mode, enabled}] in display order
|
||||||
|
let sdrRunning = [];
|
||||||
|
let sdrDirty = false;
|
||||||
|
|
||||||
|
function renderSdr() {
|
||||||
|
const list = document.getElementById('sdr-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
let rank = 0;
|
||||||
|
sdrRows.forEach((row, i) => {
|
||||||
|
const [name, hint] = SDR_LABELS[row.mode] || [row.mode, ''];
|
||||||
|
const running = sdrRunning.includes(row.mode);
|
||||||
|
const state = !row.enabled ? 'Off' : running ? 'Running' : (sdrDirty ? 'Unsaved' : 'Waiting for SDR');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'sdr-row';
|
||||||
|
el.innerHTML = `
|
||||||
|
<span class="sdr-rank">${row.enabled ? ++rank : ''}</span>
|
||||||
|
<input type="checkbox" ${row.enabled ? 'checked' : ''} aria-label="Enable ${name}">
|
||||||
|
<span class="sdr-name">${name}<small>${hint}</small></span>
|
||||||
|
<button class="sdr-move" aria-label="Move ${name} up" ${i === 0 ? 'disabled' : ''}>▲</button>
|
||||||
|
<button class="sdr-move" aria-label="Move ${name} down" ${i === sdrRows.length - 1 ? 'disabled' : ''}>▼</button>
|
||||||
|
<span class="sdr-state ${running && row.enabled ? 'on' : ''}">${state}</span>`;
|
||||||
|
const [box] = el.getElementsByTagName('input');
|
||||||
|
const [up, down] = el.getElementsByTagName('button');
|
||||||
|
box.onchange = () => { row.enabled = box.checked; markSdrDirty(); };
|
||||||
|
up.onclick = () => { [sdrRows[i - 1], sdrRows[i]] = [sdrRows[i], sdrRows[i - 1]]; markSdrDirty(); };
|
||||||
|
down.onclick = () => { [sdrRows[i + 1], sdrRows[i]] = [sdrRows[i], sdrRows[i + 1]]; markSdrDirty(); };
|
||||||
|
list.appendChild(el);
|
||||||
|
});
|
||||||
|
document.getElementById('sdr-save').disabled = !sdrDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSdrDirty() {
|
||||||
|
sdrDirty = true;
|
||||||
|
document.getElementById('sdr-msg').textContent = '';
|
||||||
|
renderSdr();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSdr() {
|
||||||
|
if (sdrDirty) return; // never clobber unsaved edits with a poll
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/secondary');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
sdrRunning = d.running || [];
|
||||||
|
sdrRows = [
|
||||||
|
...d.priority.map(mode => ({ mode, enabled: true })),
|
||||||
|
...d.modes.filter(m => !d.priority.includes(m)).map(mode => ({ mode, enabled: false })),
|
||||||
|
];
|
||||||
|
const summary = document.getElementById('sdr-summary');
|
||||||
|
if (d.running === null) {
|
||||||
|
summary.textContent = 'Secondary SDR service is not responding.';
|
||||||
|
} else if (d.sdr_count != null) {
|
||||||
|
const spare = Math.max(d.sdr_count - 1, 0);
|
||||||
|
summary.textContent = `This node has ${d.sdr_count} SDR${d.sdr_count === 1 ? '' : 's'}, so ${spare} spare.`;
|
||||||
|
}
|
||||||
|
renderSdr();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Secondary SDR load failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSdrPriority() {
|
||||||
|
const priority = sdrRows.filter(r => r.enabled).map(r => r.mode);
|
||||||
|
const btn = document.getElementById('sdr-save');
|
||||||
|
const msg = document.getElementById('sdr-msg');
|
||||||
|
btn.disabled = true;
|
||||||
|
msg.textContent = 'Applying…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/secondary/priority', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ priority }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const d = await r.json();
|
||||||
|
sdrDirty = false;
|
||||||
|
msg.textContent = d.running === null ? 'Saved, but the secondary SDR service did not respond.' : 'Saved.';
|
||||||
|
await loadSdr();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Secondary SDR save failed:', e);
|
||||||
|
msg.textContent = 'Save failed.';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSdr();
|
||||||
|
setInterval(loadSdr, 10000);
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 2000); // Polling every 2 seconds
|
setInterval(refresh, 2000); // Polling every 2 seconds
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
node-26#9 — secondary SDR priority: an ordered list the SDRs beyond op25's work
|
||||||
|
through. Normalisation, migration from the legacy single-mode field, and the one
|
||||||
|
apply path shared by the dashboard, C2 commands and config pushes.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.models import NodeConfig, normalize_secondary_priority
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(**kw) -> NodeConfig:
|
||||||
|
return NodeConfig(node_id="n1", node_name="N1", lat=0.0, lon=0.0, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_keeps_order_drops_unknown_and_duplicates():
|
||||||
|
assert normalize_secondary_priority(["ais", "bogus", "adsb", "ais"]) == ["ais", "adsb"]
|
||||||
|
assert normalize_secondary_priority([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_single_mode_migrates_to_priority():
|
||||||
|
assert _cfg(secondary_sdr_mode="adsb").secondary_sdr_priority == ["adsb"]
|
||||||
|
assert _cfg(secondary_sdr_mode="none").secondary_sdr_priority == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_priority_wins_over_legacy_mode():
|
||||||
|
assert _cfg(secondary_sdr_mode="adsb", secondary_sdr_priority=["ais"]).secondary_sdr_priority == ["ais"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_priority_persists_applies_and_never_touches_op25(tmp_path):
|
||||||
|
config_file = tmp_path / "node_config.json"
|
||||||
|
import app.internal.config_manager as cm
|
||||||
|
with patch.object(cm, "_CONFIG_FILE", config_file):
|
||||||
|
cm.save_node_config(_cfg(secondary_sdr_mode="adsb"))
|
||||||
|
from app.internal import secondary_priority as sp
|
||||||
|
with patch.object(sp.secondary_sdr_client, "apply", AsyncMock(return_value=["ais"])) as apply, \
|
||||||
|
patch("app.internal.mqtt_manager.mqtt_manager.publish_checkin", AsyncMock()), \
|
||||||
|
patch("app.internal.op25_client.op25_client.stop", AsyncMock()) as op25_stop:
|
||||||
|
running = asyncio.run(sp.set_secondary_priority(["ais", "adsb", "ais"]))
|
||||||
|
saved = cm.load_node_config()
|
||||||
|
|
||||||
|
assert running == ["ais"]
|
||||||
|
apply.assert_awaited_once_with(["ais", "adsb"])
|
||||||
|
op25_stop.assert_not_awaited()
|
||||||
|
assert saved.secondary_sdr_priority == ["ais", "adsb"]
|
||||||
|
assert saved.secondary_sdr_mode == "ais"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_priority_is_not_undone_by_legacy_migration(tmp_path):
|
||||||
|
config_file = tmp_path / "node_config.json"
|
||||||
|
import app.internal.config_manager as cm
|
||||||
|
with patch.object(cm, "_CONFIG_FILE", config_file):
|
||||||
|
cm.save_node_config(_cfg(secondary_sdr_mode="adsb"))
|
||||||
|
from app.internal import secondary_priority as sp
|
||||||
|
with patch.object(sp.secondary_sdr_client, "apply", AsyncMock(return_value=[])), \
|
||||||
|
patch("app.internal.mqtt_manager.mqtt_manager.publish_checkin", AsyncMock()):
|
||||||
|
asyncio.run(sp.set_secondary_priority([]))
|
||||||
|
assert cm.load_node_config().secondary_sdr_priority == []
|
||||||
@@ -10,64 +10,59 @@ from internal.logger import create_logger
|
|||||||
|
|
||||||
LOGGER = create_logger(__name__)
|
LOGGER = create_logger(__name__)
|
||||||
|
|
||||||
# AIS-catcher streams one JSON object per received message on stdout rather
|
# One decoder per mode, each on its own SDR (node-26#9). The node's secondary
|
||||||
# than writing a periodic snapshot file (dump1090's approach above) — so the
|
# SDR *priority* (e.g. ["adsb", "ais"]) is applied by apply(): decoders start
|
||||||
# current-vessel snapshot lives in memory, keyed by mmsi, kept warm by a
|
# down the list until no free dongle is left, so every SDR the node has gets
|
||||||
# background reader thread for as long as the subprocess we started is
|
# used and the ones beyond the list's reach simply aren't started. OP25 always
|
||||||
# alive. Unlike the pgid-file state, this does NOT survive this API
|
# keeps its own dongle — it is started first and never part of this list.
|
||||||
# process restarting independently of its subprocess — acceptable since
|
MODES = ("adsb", "ais")
|
||||||
# nothing here does that today.
|
|
||||||
_ais_vessels: Dict[str, Dict[str, Any]] = {}
|
|
||||||
_ais_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Which RTL-SDR index op25 holds is NOT fixed — on radio-box op25 had index 1
|
# Which RTL-SDR index op25 holds is NOT fixed — on radio-box op25 had index 1
|
||||||
# and index 0 was free, so "op25 is always 0" was wrong. rtlsdr can't open a
|
# and index 0 was free, so "op25 is always 0" was wrong. rtlsdr can't open a
|
||||||
# dongle another process has claimed, and the decoders exit within ~50ms when
|
# dongle another process has claimed, and the decoders exit within ~50ms when
|
||||||
# that happens, so start() tries each index and keeps the first that stays up.
|
# that happens, so _start_one() tries each index and keeps the first that
|
||||||
# op25 is never disturbed: a failed claim doesn't touch its dongle.
|
# stays up. op25 is never disturbed: a failed claim doesn't touch its dongle.
|
||||||
MAX_SDR_INDEX = 4
|
MAX_SDR_INDEX = 4
|
||||||
_STARTUP_GRACE_S = 2.0
|
_STARTUP_GRACE_S = 2.0
|
||||||
|
|
||||||
_PGID_FILE = "/tmp/secondary_sdr.pgid"
|
_STATE_DIR = Path("/tmp/secondary_sdr")
|
||||||
_MODE_FILE = "/tmp/secondary_sdr.mode"
|
|
||||||
ADSB_JSON_DIR = Path("/tmp/adsb")
|
ADSB_JSON_DIR = Path("/tmp/adsb")
|
||||||
|
|
||||||
# The live decoder handle. poll() is the only reliable liveness check: a
|
# Live decoder handles. poll() is the only reliable liveness check: a decoder
|
||||||
# decoder that dies on startup (e.g. SDR busy) stays an unreaped zombie, and
|
# that dies on startup stays an unreaped zombie, and killpg(pgid, 0) still
|
||||||
# killpg(pgid, 0) still succeeds on a zombie — status reported "running".
|
# succeeds on a zombie.
|
||||||
_proc: Optional[subprocess.Popen] = None
|
_procs: Dict[str, subprocess.Popen] = {}
|
||||||
|
_indices: Dict[str, int] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
# AIS-catcher streams one JSON object per received message on stdout rather
|
||||||
|
# than writing a periodic snapshot file (readsb's approach) — so the
|
||||||
|
# current-vessel snapshot lives in memory, keyed by mmsi, kept warm by a
|
||||||
|
# background reader thread for as long as the decoder is alive.
|
||||||
|
_ais_vessels: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_ais_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _save_state(pgid: int, mode: str) -> None:
|
def _pgid_file(mode: str) -> Path:
|
||||||
Path(_PGID_FILE).write_text(str(pgid))
|
return _STATE_DIR / f"{mode}.pgid"
|
||||||
Path(_MODE_FILE).write_text(mode)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_pgid() -> Optional[int]:
|
def _reap_orphans() -> None:
|
||||||
try:
|
"""Kill decoders left behind by a previous API process (uvicorn --reload
|
||||||
return int(Path(_PGID_FILE).read_text().strip())
|
restarts this process, but decoders run in their own session and would
|
||||||
except Exception:
|
otherwise keep holding their SDRs)."""
|
||||||
return None
|
if not _STATE_DIR.exists():
|
||||||
|
return
|
||||||
|
for f in _STATE_DIR.glob("*.pgid"):
|
||||||
|
try:
|
||||||
|
os.killpg(int(f.read_text().strip()), signal.SIGTERM)
|
||||||
|
LOGGER.info(f"Stopped orphaned secondary decoder from {f.name}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _read_mode() -> Optional[str]:
|
_reap_orphans()
|
||||||
try:
|
|
||||||
return Path(_MODE_FILE).read_text().strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_running() -> bool:
|
|
||||||
if _proc is not None:
|
|
||||||
return _proc.poll() is None
|
|
||||||
pgid = _read_pgid()
|
|
||||||
if pgid is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
os.killpg(pgid, 0)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _adsb_command(index: int) -> List[str]:
|
def _adsb_command(index: int) -> List[str]:
|
||||||
@@ -90,6 +85,9 @@ def _ais_command(index: int) -> List[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_COMMANDS = {"adsb": _adsb_command, "ais": _ais_command}
|
||||||
|
|
||||||
|
|
||||||
def _ais_reader(proc: subprocess.Popen) -> None:
|
def _ais_reader(proc: subprocess.Popen) -> None:
|
||||||
"""
|
"""
|
||||||
Consume AIS-catcher's stdout, one JSON message per line, and keep the
|
Consume AIS-catcher's stdout, one JSON message per line, and keep the
|
||||||
@@ -134,25 +132,32 @@ def _ais_reader(proc: subprocess.Popen) -> None:
|
|||||||
_ais_vessels[str(mmsi)] = existing
|
_ais_vessels[str(mmsi)] = existing
|
||||||
|
|
||||||
|
|
||||||
def start(mode: str) -> bool:
|
def is_running(mode: str) -> bool:
|
||||||
global _proc
|
proc = _procs.get(mode)
|
||||||
if is_running():
|
return proc is not None and proc.poll() is None
|
||||||
stop()
|
|
||||||
|
|
||||||
if mode == "adsb":
|
|
||||||
build = _adsb_command
|
def running() -> List[str]:
|
||||||
elif mode == "ais":
|
return [m for m in MODES if is_running(m)]
|
||||||
build = _ais_command
|
|
||||||
|
|
||||||
|
def _start_one(mode: str) -> bool:
|
||||||
|
"""Start one decoder on the first free SDR. False when none is free."""
|
||||||
|
if mode not in _COMMANDS:
|
||||||
|
raise ValueError(f"Unknown secondary SDR mode: {mode!r}")
|
||||||
|
if is_running(mode):
|
||||||
|
return True
|
||||||
|
if mode == "ais":
|
||||||
with _ais_lock:
|
with _ais_lock:
|
||||||
_ais_vessels.clear()
|
_ais_vessels.clear()
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown secondary SDR mode: {mode!r}")
|
|
||||||
|
|
||||||
needs_stdout = mode == "ais"
|
needs_stdout = mode == "ais"
|
||||||
for index in range(MAX_SDR_INDEX):
|
for index in range(MAX_SDR_INDEX):
|
||||||
|
if index in {_indices[m] for m in running() if m in _indices}:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
build(index),
|
_COMMANDS[mode](index),
|
||||||
preexec_fn=os.setsid,
|
preexec_fn=os.setsid,
|
||||||
stdout=subprocess.PIPE if needs_stdout else None,
|
stdout=subprocess.PIPE if needs_stdout else None,
|
||||||
text=True if needs_stdout else None,
|
text=True if needs_stdout else None,
|
||||||
@@ -169,46 +174,84 @@ def start(mode: str) -> bool:
|
|||||||
pass
|
pass
|
||||||
if needs_stdout:
|
if needs_stdout:
|
||||||
threading.Thread(target=_ais_reader, args=(proc,), daemon=True).start()
|
threading.Thread(target=_ais_reader, args=(proc,), daemon=True).start()
|
||||||
_proc = proc
|
_procs[mode] = proc
|
||||||
_save_state(proc.pid, mode)
|
_indices[mode] = index
|
||||||
|
_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_pgid_file(mode).write_text(str(proc.pid))
|
||||||
LOGGER.info(f"Started secondary SDR decoder mode={mode!r} on SDR index {index} pid={proc.pid}")
|
LOGGER.info(f"Started secondary SDR decoder mode={mode!r} on SDR index {index} pid={proc.pid}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
LOGGER.error(f"Secondary SDR decoder mode={mode!r}: no free SDR found (op25 holds one; is a second plugged in?)")
|
LOGGER.info(f"Secondary SDR decoder mode={mode!r}: no free SDR left")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def stop() -> bool:
|
def _stop_one(mode: str) -> None:
|
||||||
global _proc
|
proc = _procs.pop(mode, None)
|
||||||
pgid = _read_pgid()
|
_indices.pop(mode, None)
|
||||||
if pgid is None:
|
if proc is not None:
|
||||||
return True
|
|
||||||
try:
|
|
||||||
os.killpg(pgid, signal.SIGTERM)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if _proc is not None:
|
|
||||||
try:
|
try:
|
||||||
_proc.wait(timeout=5)
|
os.killpg(proc.pid, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
_proc = None
|
_pgid_file(mode).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start(mode: str) -> bool:
|
||||||
|
with _lock:
|
||||||
|
return _start_one(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def stop(mode: Optional[str] = None) -> None:
|
||||||
|
with _lock:
|
||||||
|
for m in [mode] if mode else list(_procs):
|
||||||
|
_stop_one(m)
|
||||||
|
|
||||||
|
|
||||||
|
def apply(priority: List[str]) -> List[str]:
|
||||||
|
"""Run decoders in priority order until SDRs run out; stop everything else.
|
||||||
|
|
||||||
|
Unchanged when the running set is already the right prefix of the list, so
|
||||||
|
re-applying the same priority (every restart/config push) is a no-op
|
||||||
|
rather than a decoder restart.
|
||||||
|
"""
|
||||||
|
for m in priority:
|
||||||
|
if m not in _COMMANDS:
|
||||||
|
raise ValueError(f"Unknown secondary SDR mode: {m!r}")
|
||||||
|
with _lock:
|
||||||
|
live = set(running())
|
||||||
|
if live and live == set(priority[:len(live)]):
|
||||||
|
# Already running the head of the list; only try to extend it.
|
||||||
|
for m in priority[len(live):]:
|
||||||
|
if not _start_one(m):
|
||||||
|
break
|
||||||
|
return running()
|
||||||
|
for m in list(_procs):
|
||||||
|
_stop_one(m)
|
||||||
|
for m in priority:
|
||||||
|
if not _start_one(m):
|
||||||
|
break
|
||||||
|
return running()
|
||||||
|
|
||||||
|
|
||||||
|
def sdr_count() -> Optional[int]:
|
||||||
|
"""RTL-SDR dongles on USB, same heuristic as install.sh. This container has
|
||||||
|
usbutils; op25's :stable image doesn't, so its /op25/devices can't answer."""
|
||||||
try:
|
try:
|
||||||
os.remove(_PGID_FILE)
|
out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout
|
||||||
except OSError:
|
except Exception:
|
||||||
pass
|
return None
|
||||||
try:
|
return sum(1 for line in out.splitlines() if "0bda:2838" in line or "0bda:2832" in line)
|
||||||
os.remove(_MODE_FILE)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def status() -> Dict[str, Any]:
|
def status() -> Dict[str, Any]:
|
||||||
running = is_running()
|
live = running()
|
||||||
return {
|
return {
|
||||||
"status": "running" if running else "stopped",
|
"running": [{"mode": m, "sdr_index": _indices.get(m)} for m in live],
|
||||||
"mode": _read_mode() if running else None,
|
"sdr_count": sdr_count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -250,11 +293,10 @@ def _read_adsb_snapshot() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def data() -> Dict[str, Any]:
|
def data() -> Dict[str, Any]:
|
||||||
mode = _read_mode()
|
with _ais_lock:
|
||||||
if mode == "adsb":
|
vessels = list(_ais_vessels.values()) if is_running("ais") else []
|
||||||
return {"mode": mode, "aircraft": _read_adsb_snapshot()}
|
return {
|
||||||
if mode == "ais":
|
"running": running(),
|
||||||
with _ais_lock:
|
"aircraft": _read_adsb_snapshot() if is_running("adsb") else [],
|
||||||
vessels = list(_ais_vessels.values())
|
"vessels": vessels,
|
||||||
return {"mode": mode, "vessels": vessels}
|
}
|
||||||
return {"mode": mode, "aircraft": [], "vessels": []}
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -11,9 +13,25 @@ class StartBody(BaseModel):
|
|||||||
mode: str # adsb | ais
|
mode: str # adsb | ais
|
||||||
|
|
||||||
|
|
||||||
|
class StopBody(BaseModel):
|
||||||
|
mode: Optional[str] = None # omit to stop every decoder
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyBody(BaseModel):
|
||||||
|
priority: List[str] # ordered, e.g. ["adsb", "ais"]
|
||||||
|
|
||||||
|
|
||||||
def create_secondary_router():
|
def create_secondary_router():
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/apply")
|
||||||
|
async def apply(body: ApplyBody):
|
||||||
|
try:
|
||||||
|
live = decoder_control.apply(body.priority)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return {"running": live}
|
||||||
|
|
||||||
@router.post("/start")
|
@router.post("/start")
|
||||||
async def start(body: StartBody):
|
async def start(body: StartBody):
|
||||||
try:
|
try:
|
||||||
@@ -21,12 +39,12 @@ def create_secondary_router():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=500, detail="Failed to start secondary SDR decoder")
|
raise HTTPException(status_code=409, detail="No free SDR for this decoder")
|
||||||
return {"status": f"secondary SDR started ({body.mode})"}
|
return {"status": f"secondary SDR started ({body.mode})"}
|
||||||
|
|
||||||
@router.post("/stop")
|
@router.post("/stop")
|
||||||
async def stop():
|
async def stop(body: Optional[StopBody] = None):
|
||||||
decoder_control.stop()
|
decoder_control.stop(body.mode if body else None)
|
||||||
return {"status": "secondary SDR stopped"}
|
return {"status": "secondary SDR stopped"}
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|||||||
Reference in New Issue
Block a user