Compare commits
19
Commits
@@ -111,6 +111,13 @@ OP25_TERMINAL_URL=http://localhost:8081
|
||||
# for local development off a real node; leave false everywhere else.
|
||||
OP25_DEBUG_EXPOSE=false
|
||||
|
||||
# Secondary SDR container (node-26#9) — only matters if a second physical SDR
|
||||
# is present and secondary_sdr_mode is set to adsb|ais via the edge dashboard
|
||||
# or C2. Usually no need to change.
|
||||
SECONDARY_SDR_API_URL=http://localhost:8002
|
||||
# Same caveat as OP25_DEBUG_EXPOSE — debugging aid only, leave false.
|
||||
SECONDARY_SDR_DEBUG_EXPOSE=false
|
||||
|
||||
# --- Local dashboard / API login ---------------------------------------------
|
||||
# Protects the node's local dashboard (port 80) and JSON API. The node is
|
||||
# reachable by anyone on whatever site's LAN it's deployed to, so this MUST be
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build secondary-sdr
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "secondary-sdr-container/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
CONTAINER_NAME: secondary-sdr
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."git.vpn.cusano.net"]
|
||||
http = false
|
||||
insecure = false
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.vpn.cusano.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.BUILD_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: meta
|
||||
run: |
|
||||
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./secondary-sdr-container
|
||||
file: ./secondary-sdr-container/Dockerfile
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
||||
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Publish public images
|
||||
|
||||
# Push the three node images to a PUBLIC registry on a version tag, so a fresh
|
||||
# Pi can `docker pull` them without a Gitea login (git.vpn.cusano.net is now
|
||||
# behind REQUIRE_SIGNIN_VIEW — see INCIDENT-2026-09-06). Source + the private
|
||||
# registry stay walled; only the built node images go public.
|
||||
#
|
||||
# ── DISABLED ────────────────────────────────────────────────────────────────
|
||||
# The job is gated on `vars.NODE_PUBLIC_PUBLISH == 'true'`. Until that repo
|
||||
# variable is set the workflow triggers on tags but the job is skipped, so
|
||||
# this file is wired and inert. We're still building the core; flip it on when
|
||||
# self-serve node install is actually needed.
|
||||
#
|
||||
# To enable:
|
||||
# 1. Repo → Settings → Actions → Variables:
|
||||
# NODE_PUBLIC_PUBLISH = true
|
||||
# PUBLIC_REGISTRY = ghcr.io (or docker.io)
|
||||
# PUBLIC_NAMESPACE = <org-or-user> (images land at <ns>/drb-<name>)
|
||||
# 2. Repo → Settings → Actions → Secrets:
|
||||
# PUBLIC_REGISTRY_USER = <push user>
|
||||
# PUBLIC_REGISTRY_TOKEN = <push token / PAT with write:packages>
|
||||
# 3. Re-push a tag (or run this workflow via workflow_dispatch).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: publish-public-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Inert until the repo variable is set. Do NOT convert this to `if: false`
|
||||
# — the variable is the switch, no code change needed to go live.
|
||||
if: ${{ vars.NODE_PUBLIC_PUBLISH == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: edge-node
|
||||
context: ./drb-edge-node
|
||||
file: ./drb-edge-node/Dockerfile
|
||||
cache_name: edge-node
|
||||
- name: icecast
|
||||
context: ./icecast
|
||||
file: ./icecast/Dockerfile
|
||||
cache_name: icecast
|
||||
- name: op25-client
|
||||
context: ./op25-container
|
||||
file: ./op25-container/Dockerfile
|
||||
cache_name: op25-client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # need tags for `git describe`
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."git.vpn.cusano.net"]
|
||||
http = false
|
||||
insecure = false
|
||||
|
||||
# Private Gitea registry — read only, to reuse the existing build cache
|
||||
# (keeps the op25 image off a ~1h from-scratch compile).
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.vpn.cusano.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.BUILD_TOKEN }}
|
||||
|
||||
# Public registry — where the images are pushed.
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.PUBLIC_REGISTRY }}
|
||||
username: ${{ secrets.PUBLIC_REGISTRY_USER }}
|
||||
password: ${{ secrets.PUBLIC_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Version
|
||||
id: meta
|
||||
run: |
|
||||
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:${{ steps.meta.outputs.VERSION }}
|
||||
${{ vars.PUBLIC_REGISTRY }}/${{ vars.PUBLIC_NAMESPACE }}/drb-${{ matrix.name }}:latest
|
||||
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ matrix.cache_name }}:buildcache
|
||||
@@ -32,6 +32,21 @@ services:
|
||||
depends_on:
|
||||
- icecast
|
||||
|
||||
# Claims the node's SECOND physical SDR (op25 always claims the first).
|
||||
# Only useful if secondary_sdr_mode is set to adsb|ais via the edge-node
|
||||
# config; otherwise it just sits idle answering /secondary/status. See
|
||||
# node-26#9. Same network/device access as op25 for the same reason: it
|
||||
# needs the raw USB device, not a virtualized one.
|
||||
secondary-sdr:
|
||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:latest
|
||||
build: ./secondary-sdr-container
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- /dev:/dev
|
||||
|
||||
edge-node:
|
||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest
|
||||
build: ./drb-edge-node
|
||||
|
||||
@@ -136,6 +136,9 @@ class Settings(BaseSettings):
|
||||
op25_api_url: str = "http://localhost:8001"
|
||||
op25_terminal_url: str = "http://localhost:8081"
|
||||
|
||||
# Secondary SDR container (node-26#9) — ADS-B / AIS on a second SDR
|
||||
secondary_sdr_api_url: str = "http://localhost:8002"
|
||||
|
||||
# Paths (volume mounts)
|
||||
config_path: str = "/configs"
|
||||
recordings_path: str = "/recordings"
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Plain-language health checklist for the local dashboard (/api/health).
|
||||
|
||||
A first-time owner opens http://<node>/ and needs three answers: is this node
|
||||
working, if not what is wrong, and what do I do about it. Each check below
|
||||
answers one of those for one link in the chain
|
||||
|
||||
password -> C2 enrollment -> MQTT -> SDR -> what to listen to -> OP25
|
||||
-> decoding -> audio (PulseAudio) -> local stream (Icecast)
|
||||
|
||||
`gather_facts()` does the I/O; `build_checks()` is a pure function of those
|
||||
facts so every state (unapproved, no SDR, OP25 crashed, …) is unit-testable.
|
||||
|
||||
Read-only: nothing here changes node state, and it never polls the OP25
|
||||
terminal itself (that drains OP25's call_log, which metadata_watcher owns) —
|
||||
it reads metadata_watcher.console_state instead.
|
||||
"""
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
OK, WARN, ERROR, OFF = "ok", "warn", "error", "off"
|
||||
|
||||
# OP25's console answers within a second or two of a healthy multi_rx; past
|
||||
# this the console is considered silent.
|
||||
CONSOLE_STALE_SECONDS = 5.0
|
||||
# P25 control channels send several messages a second; this long without one
|
||||
# means OP25 is not decoding.
|
||||
TSBK_STALE_SECONDS = 15.0
|
||||
|
||||
|
||||
def _check(cid: str, label: str, state: str, summary: str, action: str = "") -> Dict[str, str]:
|
||||
return {"id": cid, "label": label, "state": state, "summary": summary, "action": action}
|
||||
|
||||
|
||||
def _mhz(freq: Any) -> Optional[str]:
|
||||
try:
|
||||
f = float(freq)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
return None
|
||||
return f"{f / 1e6:.4f} MHz" if f > 1e5 else f"{f:.4f} MHz"
|
||||
|
||||
|
||||
def _ago(seconds: float) -> str:
|
||||
seconds = max(0, int(seconds))
|
||||
if seconds < 60:
|
||||
return f"{seconds}s ago"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60} min ago"
|
||||
return f"{seconds // 3600} h ago"
|
||||
|
||||
|
||||
def active_config(node_cfg) -> Any:
|
||||
if node_cfg.override_system_id and node_cfg.override_config:
|
||||
return node_cfg.override_config
|
||||
return node_cfg.system_config
|
||||
|
||||
|
||||
def build_checks(f: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
"""Facts -> ordered checklist. See gather_facts() for the keys."""
|
||||
now = f.get("now", time.time())
|
||||
checks: List[Dict[str, str]] = []
|
||||
|
||||
# 1. Dashboard password
|
||||
if f["default_password"]:
|
||||
checks.append(_check(
|
||||
"password", "Dashboard password", WARN,
|
||||
"Still the factory default — anyone on this network can log in.",
|
||||
"Set DASHBOARD_PASSWORD in the node's .env file, then restart the edge-node container.",
|
||||
))
|
||||
else:
|
||||
checks.append(_check("password", "Dashboard password", OK, "Changed from the default."))
|
||||
|
||||
# 2. C2 enrollment / approval
|
||||
c2_url, has_key = f["c2_url"], f["has_api_key"]
|
||||
if not c2_url:
|
||||
checks.append(_check(
|
||||
"c2", "Server (C2) link", OFF,
|
||||
"Standalone — no C2 server is set. Local listening below still works.",
|
||||
"To join a DRB server, re-run install.sh with an enrollment token.",
|
||||
))
|
||||
elif not has_key:
|
||||
checks.append(_check(
|
||||
"c2", "Server (C2) link", WARN,
|
||||
f"Waiting for approval — this node has no API key from {c2_url} yet, so nothing is uploaded.",
|
||||
f"Ask a DRB admin to approve node \"{f['node_id']}\" on the Nodes page, then re-run "
|
||||
"install.sh (or restart the edge-node container). Local listening below works meanwhile.",
|
||||
))
|
||||
else:
|
||||
checks.append(_check("c2", "Server (C2) link", OK, f"Approved — API key on file for {c2_url}."))
|
||||
|
||||
# 3. MQTT
|
||||
broker = f"{f['mqtt_broker']}:{f['mqtt_port']}"
|
||||
if f["mqtt_connected"]:
|
||||
checks.append(_check("mqtt", "Live link (MQTT)", OK, f"Connected to {broker}."))
|
||||
elif not c2_url and not has_key:
|
||||
checks.append(_check(
|
||||
"mqtt", "Live link (MQTT)", OFF, f"Not connected to {broker} (expected for a standalone node).",
|
||||
))
|
||||
elif not has_key:
|
||||
checks.append(_check(
|
||||
"mqtt", "Live link (MQTT)", WARN,
|
||||
f"Not connected to {broker} — expected until the node is approved (the broker only admits approved nodes).",
|
||||
"Fixes itself once the node is approved and has its API key (see above).",
|
||||
))
|
||||
else:
|
||||
checks.append(_check(
|
||||
"mqtt", "Live link (MQTT)", ERROR,
|
||||
f"Not connected to {broker} even though an API key is on file.",
|
||||
"If the key was just installed or the node was re-approved, restart the edge-node container "
|
||||
"so it loads the key. If it still fails, the key may be stale (every re-approval issues a new "
|
||||
"one) — re-run install.sh, and check the node can reach the broker.",
|
||||
))
|
||||
|
||||
# 4. SDR
|
||||
sdr_count = f.get("sdr_count")
|
||||
if sdr_count is None:
|
||||
checks.append(_check(
|
||||
"sdr", "SDR receiver", WARN, "Couldn't check — the SDR service isn't answering.",
|
||||
"Check the secondary-sdr container is running.",
|
||||
))
|
||||
elif sdr_count == 0:
|
||||
checks.append(_check(
|
||||
"sdr", "SDR receiver", ERROR, "No RTL-SDR dongle detected.",
|
||||
"Plug the SDR into a USB port (avoid unpowered hubs), then restart the stack.",
|
||||
))
|
||||
else:
|
||||
checks.append(_check(
|
||||
"sdr", "SDR receiver", OK, f"{sdr_count} SDR{'s' if sdr_count != 1 else ''} detected.",
|
||||
))
|
||||
|
||||
# 5. What to listen to
|
||||
src = f.get("source")
|
||||
if not src:
|
||||
checks.append(_check(
|
||||
"source", "What to listen to", WARN, "Nothing chosen yet.",
|
||||
"Pick a system or enter a frequency under \"Listen locally\" below.",
|
||||
))
|
||||
else:
|
||||
origin = "chosen on this dashboard" if src["local"] else "assigned by the server"
|
||||
checks.append(_check("source", "What to listen to", OK, f"{src['name']} ({src['type']}) — {origin}."))
|
||||
|
||||
# 6. OP25
|
||||
op25 = f.get("op25_status")
|
||||
running = op25 == "running"
|
||||
if op25 is None:
|
||||
checks.append(_check(
|
||||
"op25", "Radio decoder (OP25)", ERROR, "The op25 container isn't answering.",
|
||||
"Check the op25 container is running; its log usually says why it stopped.",
|
||||
))
|
||||
elif running:
|
||||
checks.append(_check("op25", "Radio decoder (OP25)", OK, "Running."))
|
||||
elif op25 == "crashed":
|
||||
checks.append(_check(
|
||||
"op25", "Radio decoder (OP25)", ERROR, "Stopped unexpectedly.",
|
||||
"Open \"OP25 log\" below for the reason — usually the SDR is unplugged or in use. Then press Start.",
|
||||
))
|
||||
elif not src:
|
||||
checks.append(_check("op25", "Radio decoder (OP25)", OFF, "Stopped — choose what to listen to first."))
|
||||
else:
|
||||
checks.append(_check("op25", "Radio decoder (OP25)", WARN, "Stopped.", "Press Start."))
|
||||
|
||||
# 7. Decoding
|
||||
console = f.get("console") or {}
|
||||
last_poll = console.get("last_poll_epoch")
|
||||
is_analog = bool(src) and str(src.get("type", "")).upper() in ("NBFM", "ANALOG")
|
||||
if not running:
|
||||
checks.append(_check("decode", "Decoding", OFF, "Starts when OP25 runs."))
|
||||
elif last_poll is None or now - last_poll > CONSOLE_STALE_SECONDS:
|
||||
checks.append(_check(
|
||||
"decode", "Decoding", WARN, "OP25 is running but hasn't reported in yet.",
|
||||
"Give it 20 seconds after a start. If this stays, open the OP25 log below.",
|
||||
))
|
||||
elif is_analog:
|
||||
chans = console.get("channels") or []
|
||||
freq = _mhz(chans[0].get("freq")) if chans else None
|
||||
checks.append(_check(
|
||||
"decode", "Decoding", OK, f"Tuned to {freq or 'the analog frequency'} (analog FM — no decoding needed).",
|
||||
"Live listening only: analog calls are not recorded or uploaded yet (node-26#20).",
|
||||
))
|
||||
else:
|
||||
trunk = console.get("trunk") or []
|
||||
tsbk = [float(t.get("last_tsbk") or 0) for t in trunk]
|
||||
if not trunk:
|
||||
checks.append(_check("decode", "Decoding", WARN, "Waiting for OP25 to report control-channel status…"))
|
||||
elif max(tsbk) > 0 and now - max(tsbk) < TSBK_STALE_SECONDS:
|
||||
sys_ = trunk[tsbk.index(max(tsbk))]
|
||||
cc = _mhz(sys_.get("rxchan"))
|
||||
checks.append(_check(
|
||||
"decode", "Decoding", OK, f"Locked on the control channel{' ' + cc if cc else ''}.",
|
||||
))
|
||||
else:
|
||||
checks.append(_check(
|
||||
"decode", "Decoding", ERROR, "Not decoding the control channel.",
|
||||
"Usually a wrong control-channel frequency, a weak signal/antenna, or SDR frequency error (PPM). "
|
||||
"Try another control channel for the same system.",
|
||||
))
|
||||
|
||||
# 8. Audio (PulseAudio — recording + Discord path)
|
||||
if not f["audio_capturing"]:
|
||||
checks.append(_check(
|
||||
"audio", "Audio", ERROR if running else OFF,
|
||||
"No audio reaching the recorder." if running else "Nothing to hear until OP25 runs.",
|
||||
"The op25 container owns the audio path (PulseAudio); restart it if this persists." if running else "",
|
||||
))
|
||||
else:
|
||||
last_voice = f.get("last_voice_epoch")
|
||||
if last_voice is None:
|
||||
checks.append(_check("audio", "Audio", OK, "Audio path up — no voice heard yet."))
|
||||
elif now - last_voice < 3:
|
||||
checks.append(_check("audio", "Audio", OK, "Voice right now."))
|
||||
else:
|
||||
checks.append(_check("audio", "Audio", OK, f"Audio path up — last voice {_ago(now - last_voice)}."))
|
||||
|
||||
# 9. Local stream (Icecast)
|
||||
ice = f.get("icecast_live")
|
||||
if ice is None:
|
||||
checks.append(_check(
|
||||
"stream", "Local stream", ERROR, f"Icecast isn't answering on port {f['icecast_port']}.",
|
||||
"Check the icecast container is running.",
|
||||
))
|
||||
elif ice:
|
||||
checks.append(_check("stream", "Local stream", OK, "Live — press play in the player below."))
|
||||
elif running:
|
||||
checks.append(_check(
|
||||
"stream", "Local stream", WARN, "OP25 is running but nothing is feeding the stream yet.",
|
||||
"It normally appears within ~20 seconds of a start; if not, check the OP25 log below.",
|
||||
))
|
||||
else:
|
||||
checks.append(_check("stream", "Local stream", OFF, "Goes live when OP25 runs."))
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
async def icecast_live() -> Optional[bool]:
|
||||
"""True if the configured mount has a source, False if not, None if Icecast is down."""
|
||||
url = f"http://{settings.icecast_host}:{settings.icecast_port}/status-json.xsl"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
sources = (r.json().get("icestats") or {}).get("source") or []
|
||||
except Exception as e:
|
||||
logger.debug(f"Icecast status check failed: {e}")
|
||||
return None
|
||||
if isinstance(sources, dict):
|
||||
sources = [sources]
|
||||
return any(str(s.get("listenurl", "")).endswith(settings.icecast_mount) for s in sources)
|
||||
|
||||
|
||||
async def gather_facts() -> Dict[str, Any]:
|
||||
from app.internal import auth, credentials
|
||||
from app.internal.call_recorder import call_recorder
|
||||
from app.internal.config_manager import load_node_config
|
||||
from app.internal.metadata_watcher import metadata_watcher
|
||||
from app.internal.mqtt_manager import mqtt_manager
|
||||
from app.internal.op25_client import op25_client
|
||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||
|
||||
node_cfg = load_node_config()
|
||||
cfg = active_config(node_cfg)
|
||||
op25 = await op25_client.status()
|
||||
|
||||
devs = await secondary_sdr_client.devices()
|
||||
sdr_count: Optional[int] = len(devs) if devs is not None else None
|
||||
if not sdr_count:
|
||||
# op25's own lsusb count — its :stable image may lack lsusb and say 0,
|
||||
# so only trust a positive answer from it.
|
||||
op25_devs = await op25_client.devices()
|
||||
if op25_devs and op25_devs.get("count"):
|
||||
sdr_count = op25_devs["count"]
|
||||
|
||||
activity = call_recorder.audio_activity()
|
||||
return {
|
||||
"now": time.time(),
|
||||
"node_id": settings.node_id,
|
||||
"default_password": auth.is_using_default_password(),
|
||||
"c2_url": settings.c2_url,
|
||||
"has_api_key": bool(credentials.get_api_key()),
|
||||
"mqtt_connected": mqtt_manager.is_connected,
|
||||
"mqtt_broker": settings.mqtt_broker,
|
||||
"mqtt_port": settings.mqtt_port,
|
||||
"sdr_count": sdr_count,
|
||||
"source": None if cfg is None else {
|
||||
"name": cfg.name, "type": cfg.type, "system_id": cfg.system_id,
|
||||
"local": bool(node_cfg.override_system_id and node_cfg.override_config),
|
||||
},
|
||||
"op25_status": (op25 or {}).get("status") if op25 is not None else None,
|
||||
"console": metadata_watcher.console_state,
|
||||
"audio_capturing": call_recorder.is_capturing,
|
||||
"last_voice_epoch": activity.last_voice_epoch,
|
||||
"icecast_live": await icecast_live(),
|
||||
"icecast_port": settings.icecast_port,
|
||||
}
|
||||
@@ -235,6 +235,13 @@ class MetadataWatcher:
|
||||
self._last_tx_end: Optional[float] = None # epoch of the srcaddr 1→0 edge
|
||||
self._last_ok_poll: float = 0.0
|
||||
|
||||
# Dashboard-only view of what the OP25 console last said. Read by
|
||||
# /api/health; never consulted by segmentation or attribution.
|
||||
self._terminal_epoch: Optional[float] = None
|
||||
self._terminal_channels: List[Dict[str, Any]] = []
|
||||
self._terminal_trunk: List[Dict[str, Any]] = []
|
||||
self._last_heard: Optional[ConsoleEvent] = None
|
||||
|
||||
# Rolling console history for close-time attribution.
|
||||
self._console: deque[ConsoleEvent] = deque(maxlen=CONSOLE_HISTORY_MAX)
|
||||
|
||||
@@ -294,6 +301,7 @@ class MetadataWatcher:
|
||||
|
||||
self._last_ok_poll = now
|
||||
self._record_console(update, now)
|
||||
self._note_terminal(update, now)
|
||||
|
||||
activity = self._snapshot()
|
||||
if activity is None or not activity.capturing:
|
||||
@@ -350,6 +358,18 @@ class MetadataWatcher:
|
||||
while self._console and self._console[0].epoch < cutoff:
|
||||
self._console.popleft()
|
||||
|
||||
def _note_terminal(self, update: Any, now: float) -> None:
|
||||
"""Keep the latest console snapshot for the dashboard. No side effects
|
||||
on segment state — trunk_update/channel_update are not sent on every
|
||||
poll, so the last non-empty one is kept."""
|
||||
self._terminal_epoch = now
|
||||
if update.channels:
|
||||
self._terminal_channels = update.channels
|
||||
if getattr(update, "trunk", None):
|
||||
self._terminal_trunk = update.trunk
|
||||
if self._console:
|
||||
self._last_heard = self._console[-1]
|
||||
|
||||
def _is_duplicate_grant(self, event: ConsoleEvent) -> bool:
|
||||
for index in range(len(self._console) - 1, -1, -1):
|
||||
if len(self._console) - index > _GRANT_DEDUPE_DEPTH:
|
||||
@@ -898,6 +918,19 @@ class MetadataWatcher:
|
||||
def is_active(self) -> bool:
|
||||
return self._active_call_id is not None
|
||||
|
||||
@property
|
||||
def console_state(self) -> Dict[str, Any]:
|
||||
"""Last thing the OP25 console reported, for /api/health only."""
|
||||
heard = self._last_heard
|
||||
return {
|
||||
"last_poll_epoch": self._terminal_epoch,
|
||||
"channels": self._terminal_channels,
|
||||
"trunk": self._terminal_trunk,
|
||||
"last_heard": None if heard is None else {
|
||||
"epoch": heard.epoch, "tgid": heard.tgid, "name": heard.name, "freq": heard.freq,
|
||||
},
|
||||
}
|
||||
|
||||
@property
|
||||
def unattributed_segments(self) -> int:
|
||||
"""Orphan-audio segments discarded since start. Surfaced on /api/status."""
|
||||
|
||||
@@ -210,10 +210,16 @@ class MQTTManager:
|
||||
logger.info("No API key on disk — requesting re-delivery from C2 server.")
|
||||
self._publish(self._t_key_request, {}, qos=1)
|
||||
|
||||
async def publish_checkin(self):
|
||||
await self._publish_checkin()
|
||||
|
||||
async def _publish_checkin(self):
|
||||
from app.internal.discord_radio import radio_bot
|
||||
from app.internal.config_manager import load_node_config
|
||||
from app.internal.op25_client import op25_client
|
||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||
config = load_node_config()
|
||||
devices = await op25_client.devices()
|
||||
payload = {
|
||||
"node_id": settings.node_id,
|
||||
"name": settings.node_name,
|
||||
@@ -225,7 +231,35 @@ class MQTTManager:
|
||||
"is_overridden": config.override_system_id is not None and config.node_type != "portable",
|
||||
"override_system_id": config.override_system_id,
|
||||
"enforce_override_timeout": config.enforce_override_timeout,
|
||||
"secondary_sdr_mode": config.secondary_sdr_mode,
|
||||
"secondary_sdr_priority": config.secondary_sdr_priority,
|
||||
}
|
||||
payload["sdr_pins"] = config.sdr_pins
|
||||
secondary = await secondary_sdr_client.status()
|
||||
# Unreachable secondary-sdr: say so with explicit nulls. C2 only
|
||||
# overwrites keys present in the checkin, so omitting them would leave
|
||||
# a dead decoder showing "Running" forever.
|
||||
payload["secondary_sdr_running"] = None
|
||||
payload["sdr_devices"] = None
|
||||
payload["op25_sdr_serial"] = None
|
||||
if secondary is not None:
|
||||
payload["secondary_sdr_running"] = [r["mode"] for r in secondary.get("running", [])]
|
||||
if secondary.get("devices") is not None:
|
||||
payload["sdr_devices"] = [
|
||||
{k: d.get(k) for k in ("index", "serial", "name", "duplicate_serial")}
|
||||
for d in secondary["devices"]
|
||||
]
|
||||
from app.internal.sdr_settings import op25_serial
|
||||
payload["op25_sdr_serial"] = op25_serial(config, secondary["devices"])
|
||||
# Best-effort hardware report — omit rather than guess. Prefer the
|
||||
# secondary-sdr container's count: op25's :stable image has no lsusb and
|
||||
# its /op25/devices answers 0 rather than "unknown" when that fails. A
|
||||
# node running op25 has at least one SDR, so 0 is never a real reading.
|
||||
count = secondary.get("sdr_count") if secondary is not None else None
|
||||
if not count and devices:
|
||||
count = devices.get("count") or None
|
||||
if count is not None:
|
||||
payload["sdr_count"] = count
|
||||
self._publish(self._t_checkin, payload, qos=1)
|
||||
|
||||
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
|
||||
|
||||
@@ -11,6 +11,11 @@ from app.internal.logger import logger
|
||||
# each id is also a top-level key holding that channel's dict
|
||||
# (freq/tgid/tag/srcaddr/svcopts/hold_tgid/…).
|
||||
#
|
||||
# trunk_update — per-system trunking state keyed "0", "1", …; each carries
|
||||
# `last_tsbk` (OP25's time.time() of the last control-channel
|
||||
# message decoded) — the only direct proof OP25 is DECODING,
|
||||
# not merely running. Surfaced on the dashboard only.
|
||||
#
|
||||
# call_log — an EVENT QUEUE, not a snapshot. `log` holds entries appended
|
||||
# by tk_p25.log_call() at channel-grant time, each stamped with
|
||||
# OP25's own time.time(). get_call_log() DRAINS the deque, so
|
||||
@@ -18,7 +23,7 @@ from app.internal.logger import logger
|
||||
# it forever. The deque is capped at CALL_LOG_MAX_LEN = 10, so
|
||||
# the consumer must keep up.
|
||||
#
|
||||
# Everything else (trunk_update, rx_update, terminal_config, …) is ignored.
|
||||
# Everything else (rx_update, terminal_config, …) is ignored.
|
||||
TERMINAL_UPDATE_COMMAND = [{"command": "update", "arg1": 0, "arg2": 0}]
|
||||
|
||||
|
||||
@@ -28,6 +33,7 @@ class TerminalUpdate:
|
||||
|
||||
channels: List[Dict[str, Any]] = field(default_factory=list)
|
||||
call_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
trunk: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
class OP25Client:
|
||||
@@ -45,6 +51,23 @@ class OP25Client:
|
||||
logger.error(f"OP25 start failed: {e}")
|
||||
return False
|
||||
|
||||
async def start_with_reason(self) -> Optional[str]:
|
||||
"""Start OP25; None on success, else a human-readable reason."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{self.api_url}/op25/start")
|
||||
except Exception as e:
|
||||
logger.error(f"OP25 start failed: {e}")
|
||||
return "The op25 container isn't answering."
|
||||
if r.is_success:
|
||||
return None
|
||||
try:
|
||||
detail = r.json().get("detail")
|
||||
except Exception:
|
||||
detail = None
|
||||
logger.error(f"OP25 start failed: HTTP {r.status_code} {detail or r.text[:200]}")
|
||||
return detail or f"op25 container returned HTTP {r.status_code}."
|
||||
|
||||
async def stop(self) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
@@ -65,15 +88,40 @@ class OP25Client:
|
||||
logger.error(f"OP25 status failed: {e}")
|
||||
return None
|
||||
|
||||
async def log(self, lines: int = 80) -> Optional[List[str]]:
|
||||
"""Tail of multi_rx/liquidsoap output, kept by the op25 container."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{self.api_url}/op25/log", params={"lines": lines})
|
||||
r.raise_for_status()
|
||||
return r.json().get("lines", [])
|
||||
except Exception as e:
|
||||
logger.error(f"OP25 log fetch failed: {e}")
|
||||
return None
|
||||
|
||||
async def devices(self) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{self.api_url}/op25/devices")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"OP25 device enumeration failed: {e}")
|
||||
return None
|
||||
|
||||
async def generate_config(self, config: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{self.api_url}/op25/generate-config", json=config)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"OP25 generate-config failed: {e}")
|
||||
return False
|
||||
# Every generated config opens its dongle by serial, never "first
|
||||
# found" (node-26#11) — here so no generation path can skip it.
|
||||
from app.internal.sdr_settings import pin_op25_device
|
||||
await pin_op25_device()
|
||||
return True
|
||||
|
||||
async def poll_terminal(self) -> Optional[TerminalUpdate]:
|
||||
"""
|
||||
@@ -130,6 +178,11 @@ def parse_terminal_messages(messages: Any) -> TerminalUpdate:
|
||||
if isinstance(channel, dict):
|
||||
update.channels.append(channel)
|
||||
|
||||
elif json_type == "trunk_update":
|
||||
for key, system in msg.items():
|
||||
if key != "json_type" and isinstance(system, dict):
|
||||
update.trunk.append(system)
|
||||
|
||||
elif json_type == "call_log":
|
||||
for entry in msg.get("log") or []:
|
||||
if isinstance(entry, dict):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Which SDR does what on this node (node-26#9, node-26#11).
|
||||
|
||||
- OP25 always has exactly one dongle. `sdr_pins["op25"]` names it by serial;
|
||||
unpinned, OP25 keeps its historical "first dongle" — but that dongle is now
|
||||
named by serial too, so the decoders can never take it out from under OP25.
|
||||
- Every other dongle runs the next enabled service in `secondary_sdr_priority`.
|
||||
`sdr_pins[mode]` optionally binds a service to the dongle carrying its
|
||||
antenna; unpinned services take any spare.
|
||||
|
||||
One apply path for the local dashboard, C2 commands and config pushes.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from app.config import settings
|
||||
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 NodeConfig, normalize_sdr_pins, normalize_secondary_priority
|
||||
|
||||
_OP25_CONFIG = Path(settings.config_path) / "active.cfg.json"
|
||||
|
||||
|
||||
def op25_serial(cfg: NodeConfig, devs: Optional[List[Dict[str, Any]]]) -> Optional[str]:
|
||||
"""The serial of OP25's dongle: the pin, else the first detected dongle
|
||||
(what OP25's plain "rtl" device string has always opened)."""
|
||||
if cfg.sdr_pins.get("op25"):
|
||||
return cfg.sdr_pins["op25"]
|
||||
return devs[0]["serial"] if devs else None
|
||||
|
||||
|
||||
async def pin_op25_device() -> Optional[str]:
|
||||
"""Rewrite OP25's generated config to open its dongle by serial.
|
||||
|
||||
Runs after every generate-config (op25_client.generate_config). A plain
|
||||
"rtl" means "device 0", and which dongle is device 0 depends on who opened
|
||||
what first — that is how OP25 lost its SDR to readsb on 2026-09-27.
|
||||
Left as "rtl" only when the serial is unknown or shared by two dongles.
|
||||
"""
|
||||
devs = await secondary_sdr_client.devices()
|
||||
serial = op25_serial(load_node_config(), devs)
|
||||
if not serial or (devs and sum(d["serial"] == serial for d in devs) > 1):
|
||||
return None
|
||||
try:
|
||||
cfg = json.loads(_OP25_CONFIG.read_text())
|
||||
for dev in cfg.get("devices", []):
|
||||
dev["args"] = f"rtl={serial}"
|
||||
_OP25_CONFIG.write_text(json.dumps(cfg, indent=2))
|
||||
except Exception as e:
|
||||
logger.error(f"Could not pin OP25 to SDR {serial}: {e}")
|
||||
return None
|
||||
return serial
|
||||
|
||||
|
||||
async def apply_secondaries() -> Optional[List[str]]:
|
||||
"""Start/stop decoders to match the saved priority and pins, never on OP25's dongle."""
|
||||
cfg = load_node_config()
|
||||
if not cfg.secondary_sdr_priority:
|
||||
return await secondary_sdr_client.apply([], {}, [])
|
||||
devs = await secondary_sdr_client.devices()
|
||||
reserved = [s for s in [op25_serial(cfg, devs)] if s]
|
||||
pins = {m: s for m, s in cfg.sdr_pins.items() if m != "op25"}
|
||||
return await secondary_sdr_client.apply(cfg.secondary_sdr_priority, pins, reserved)
|
||||
|
||||
|
||||
async def set_sdr_settings(priority: Optional[Iterable[str]] = None,
|
||||
pins: Optional[Dict[str, Optional[str]]] = None) -> Optional[List[str]]:
|
||||
"""Persist and apply priority and/or pins. OP25 restarts only if its own
|
||||
dongle changed; reordering the spare dongles never interrupts P25.
|
||||
|
||||
Returns the decoders now running (None if the container is unreachable).
|
||||
"""
|
||||
cfg = load_node_config()
|
||||
old_op25 = cfg.sdr_pins.get("op25")
|
||||
if priority is not None:
|
||||
ordered = normalize_secondary_priority(priority)
|
||||
cfg.secondary_sdr_priority = ordered
|
||||
cfg.secondary_sdr_mode = ordered[0] if ordered else "none"
|
||||
if pins is not None:
|
||||
cfg.sdr_pins = normalize_sdr_pins(pins)
|
||||
save_node_config(cfg)
|
||||
|
||||
if cfg.sdr_pins.get("op25") != old_op25:
|
||||
from app.internal.op25_client import op25_client
|
||||
# Free the new dongle first if a decoder holds it, then move OP25.
|
||||
await secondary_sdr_client.apply([], {}, [])
|
||||
serial = await pin_op25_device()
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await op25_client.start()
|
||||
logger.info(f"OP25 moved to SDR {serial or 'rtl (first dongle)'}")
|
||||
|
||||
running = await apply_secondaries()
|
||||
logger.info(f"SDR settings: priority={cfg.secondary_sdr_priority!r} pins={cfg.sdr_pins!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
|
||||
@@ -0,0 +1,84 @@
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
class SecondarySdrClient:
|
||||
"""Talks to the secondary-sdr-container (node-26#9) over its control API.
|
||||
|
||||
Mirrors op25_client.py's shape on purpose — same failure handling (log
|
||||
and return None/False rather than raise), since this container is
|
||||
optional and its absence must never break the primary op25 radio path.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = settings.secondary_sdr_api_url
|
||||
|
||||
async def devices(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Every RTL-SDR on the node with its serial, or None if unreachable."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{self.api_url}/secondary/devices")
|
||||
r.raise_for_status()
|
||||
return r.json().get("devices", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Secondary SDR device list failed: {e}")
|
||||
return None
|
||||
|
||||
async def apply(self, priority: List[str], pins: Dict[str, str], reserved: List[str]) -> Optional[List[str]]:
|
||||
"""Run decoders down the priority list until SDRs run out, honouring
|
||||
pins and never touching `reserved` (op25's) dongles. Returns the modes
|
||||
actually running, or None if the container is unreachable."""
|
||||
body = {"priority": priority, "pins": pins, "reserved": reserved}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{self.api_url}/secondary/apply", json=body)
|
||||
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:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{self.api_url}/secondary/start", json={"mode": mode})
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Secondary SDR start (mode={mode!r}) failed: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{self.api_url}/secondary/stop")
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Secondary SDR stop failed: {e}")
|
||||
return False
|
||||
|
||||
async def status(self) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{self.api_url}/secondary/status")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Secondary SDR status failed: {e}")
|
||||
return None
|
||||
|
||||
async def data(self) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{self.api_url}/secondary/data")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Secondary SDR data fetch failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
secondary_sdr_client = SecondarySdrClient()
|
||||
@@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import credentials
|
||||
from app.internal.config_manager import load_node_config
|
||||
from app.internal.logger import logger
|
||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||
|
||||
# How often the second-SDR decoder's current snapshot is forwarded to C2
|
||||
# (node-26#9). This is a live-map overlay, not a flight/vessel history, so
|
||||
# there is no backlog/retry on a missed tick — the next one supersedes it.
|
||||
UPLINK_INTERVAL_SECONDS = 10
|
||||
|
||||
|
||||
async def _post_snapshot(path: str, body: dict) -> None:
|
||||
if not settings.c2_url:
|
||||
return
|
||||
api_key = credentials.get_api_key()
|
||||
if not api_key:
|
||||
return
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{settings.c2_url}{path}", json=body, headers=headers)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.debug(f"Telemetry uplink to {path} failed: {e}")
|
||||
|
||||
|
||||
async def telemetry_uplink_loop():
|
||||
while True:
|
||||
await asyncio.sleep(UPLINK_INTERVAL_SECONDS)
|
||||
if not load_node_config().secondary_sdr_priority:
|
||||
continue
|
||||
snapshot = await secondary_sdr_client.data()
|
||||
if not snapshot:
|
||||
continue
|
||||
# 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"]})
|
||||
if snapshot.get("vessels"):
|
||||
await _post_snapshot("/telemetry/ais", {"vessels": snapshot["vessels"]})
|
||||
@@ -5,7 +5,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from app.config import settings
|
||||
from app.models import SystemConfig
|
||||
from app.models import SystemConfig, normalize_sdr_pins, normalize_secondary_priority
|
||||
from app.internal.logger import logger
|
||||
from app.internal.mqtt_manager import mqtt_manager
|
||||
from app.internal import credentials
|
||||
@@ -158,6 +158,12 @@ async def on_command(payload: dict):
|
||||
)
|
||||
elif action == "discord_leave":
|
||||
await radio_bot.leave()
|
||||
elif action in ("set_sdr_config", "set_secondary_priority"):
|
||||
from app.internal.sdr_settings import set_sdr_settings
|
||||
try:
|
||||
await set_sdr_settings(payload.get("priority"), payload.get("pins"))
|
||||
except ValueError as e:
|
||||
logger.error(f"Rejected SDR settings from C2: {e}")
|
||||
elif action == "op25_restart":
|
||||
from app.internal.op25_client import op25_client
|
||||
await op25_client.stop()
|
||||
@@ -224,6 +230,9 @@ async def on_config_push(payload: dict):
|
||||
hardware_preset = payload.pop("hardware_preset", None)
|
||||
ppm_override = payload.pop("ppm_override", None)
|
||||
node_type = payload.pop("node_type", None)
|
||||
secondary_sdr_priority = payload.pop("secondary_sdr_priority", None)
|
||||
sdr_pins = payload.pop("sdr_pins", None)
|
||||
secondary_sdr_mode = payload.pop("secondary_sdr_mode", None) # legacy single-mode C2
|
||||
enforce_override_timeout = payload.pop("enforce_override_timeout", None)
|
||||
try:
|
||||
config = SystemConfig(**payload)
|
||||
@@ -245,6 +254,16 @@ async def on_config_push(payload: dict):
|
||||
node_cfg.node_type = node_type
|
||||
if enforce_override_timeout is not None:
|
||||
node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
|
||||
if secondary_sdr_priority is None and secondary_sdr_mode is not None:
|
||||
secondary_sdr_priority = [secondary_sdr_mode]
|
||||
if secondary_sdr_priority is not None:
|
||||
node_cfg.secondary_sdr_priority = normalize_secondary_priority(secondary_sdr_priority)
|
||||
node_cfg.secondary_sdr_mode = (node_cfg.secondary_sdr_priority or ["none"])[0]
|
||||
if sdr_pins is not None:
|
||||
try:
|
||||
node_cfg.sdr_pins = normalize_sdr_pins(sdr_pins)
|
||||
except ValueError as e:
|
||||
logger.error(f"Ignoring invalid sdr_pins in config push: {e}")
|
||||
save_node_config(node_cfg)
|
||||
|
||||
from app.internal.op25_client import op25_client
|
||||
@@ -252,10 +271,16 @@ async def on_config_push(payload: dict):
|
||||
logger.error(f"Failed to generate OP25 config for {config.name}")
|
||||
return
|
||||
|
||||
# OP25's (pinned) dongle may be one a decoder holds right now: free the
|
||||
# spares, restart OP25 on its own dongle, then hand the rest back out.
|
||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||
from app.internal.sdr_settings import apply_secondaries
|
||||
await secondary_sdr_client.apply([], {}, [])
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await op25_client.start()
|
||||
logger.info(f"Config push applied: {config.name}")
|
||||
await apply_secondaries()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -302,9 +327,12 @@ async def lifespan(app: FastAPI):
|
||||
if (node_cfg.override_system_id and node_cfg.override_config)
|
||||
else node_cfg.system_config
|
||||
)
|
||||
if node_cfg.configured and active_config:
|
||||
# Not gated on `configured` (set only by a C2 assignment): a standalone or
|
||||
# not-yet-approved node listening to a system chosen on the local
|
||||
# dashboard (an override) must come back on it after a reboot too.
|
||||
if active_config:
|
||||
from app.internal.op25_client import op25_client
|
||||
logger.info("Node is configured — waiting for OP25 API then generating config.")
|
||||
logger.info("Node has a system to listen to — waiting for OP25 API then generating config.")
|
||||
for attempt in range(10):
|
||||
if await _generate_op25_config(active_config):
|
||||
await op25_client.start()
|
||||
@@ -312,12 +340,21 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# After op25 has claimed its dongle, so the decoders only get the spares.
|
||||
if node_cfg.secondary_sdr_priority:
|
||||
from app.internal.sdr_settings import apply_secondaries
|
||||
logger.info(f"Resuming secondary SDRs (priority={node_cfg.secondary_sdr_priority!r}) after restart.")
|
||||
await apply_secondaries()
|
||||
|
||||
heartbeat_task = asyncio.create_task(mqtt_manager.heartbeat_loop())
|
||||
from app.internal.telemetry_uplink import telemetry_uplink_loop
|
||||
telemetry_task = asyncio.create_task(telemetry_uplink_loop())
|
||||
|
||||
yield # --- app running ---
|
||||
|
||||
logger.info("Edge node shutting down.")
|
||||
heartbeat_task.cancel()
|
||||
telemetry_task.cancel()
|
||||
await metadata_watcher.stop()
|
||||
await call_recorder.stop()
|
||||
await radio_bot.stop()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, model_validator
|
||||
from typing import Optional, Dict, Any, Iterable, List
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
@@ -23,6 +23,34 @@ class SystemConfig(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
# Services that can be bound to a specific dongle by its USB serial.
|
||||
SDR_PIN_KEYS = ("op25",) + SECONDARY_SDR_MODES
|
||||
|
||||
|
||||
def normalize_sdr_pins(pins: Dict[str, Optional[str]]) -> Dict[str, str]:
|
||||
"""Known services only, blank = automatic (dropped). Two services can't
|
||||
share a dongle, so a serial claimed twice raises."""
|
||||
out = {k: str(v).strip() for k, v in (pins or {}).items() if k in SDR_PIN_KEYS and v and str(v).strip()}
|
||||
if len(set(out.values())) != len(out):
|
||||
raise ValueError("Two services can't be pinned to the same SDR.")
|
||||
return out
|
||||
|
||||
|
||||
class NodeConfig(BaseModel):
|
||||
node_id: str
|
||||
node_name: str
|
||||
@@ -34,11 +62,23 @@ class NodeConfig(BaseModel):
|
||||
hardware_preset: str = "rtl-sdr-v3"
|
||||
ppm_override: Optional[float] = None
|
||||
node_type: str = "fixed" # fixed or portable
|
||||
secondary_sdr_priority: List[str] = [] # ordered; SDRs beyond op25's run these top-down
|
||||
sdr_pins: Dict[str, str] = {} # service (op25/adsb/ais) -> dongle serial; absent = automatic
|
||||
# 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
|
||||
override_system_id: Optional[str] = None
|
||||
override_config: Optional[SystemConfig] = None
|
||||
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):
|
||||
call_id: str
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Literal, Optional
|
||||
import asyncio
|
||||
import httpx
|
||||
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.config_manager import load_node_config, save_node_config, apply_system_config
|
||||
from app.internal.config_manager import load_node_config, save_node_config
|
||||
from app.internal.call_recorder import call_recorder
|
||||
from app.internal.discord_radio import radio_bot
|
||||
from app.internal.metadata_watcher import metadata_watcher
|
||||
@@ -35,8 +36,10 @@ async def get_status():
|
||||
if (node_cfg.override_system_id and node_cfg.override_config)
|
||||
else node_cfg.system_config
|
||||
)
|
||||
system_type = None
|
||||
if active_config:
|
||||
system_name = active_config.name
|
||||
system_type = active_config.type
|
||||
if active_tgid:
|
||||
# Reverse engineer the TGID to talkgroup name based on active system object
|
||||
tgs = active_config.config.get("talkgroups", [])
|
||||
@@ -57,6 +60,11 @@ async def get_status():
|
||||
"configured": node_cfg.configured,
|
||||
"assigned_system_id": node_cfg.assigned_system_id,
|
||||
"system_name": system_name,
|
||||
"system_type": system_type,
|
||||
# True when what OP25 is tuned to was chosen on this dashboard rather
|
||||
# than assigned by C2 (a local override, revertible to C2's system).
|
||||
"local_choice": bool(node_cfg.override_system_id and node_cfg.override_config),
|
||||
"has_c2_system": node_cfg.system_config is not None,
|
||||
"is_recording": call_recorder.is_recording,
|
||||
# Health of the PulseAudio capture that feeds every recording — the single
|
||||
# most useful signal when recordings come back empty. Segment boundaries
|
||||
@@ -76,15 +84,27 @@ async def get_status():
|
||||
"icecast_url": (
|
||||
f"http://{settings.icecast_host}:{settings.icecast_port}{settings.icecast_mount}"
|
||||
),
|
||||
# The browser builds its own URL from these + window.location.hostname;
|
||||
# icecast_host is "localhost" from the node's point of view.
|
||||
"icecast_port": settings.icecast_port,
|
||||
"icecast_mount": settings.icecast_mount,
|
||||
"op25": op25_status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def get_health():
|
||||
"""Plain-language setup/status checklist for the dashboard (see internal/health.py)."""
|
||||
from app.internal import health
|
||||
facts = await health.gather_facts()
|
||||
return {"checks": health.build_checks(facts), "last_heard": facts["console"].get("last_heard")}
|
||||
|
||||
|
||||
@router.post("/op25/start")
|
||||
async def start_op25():
|
||||
ok = await op25_client.start()
|
||||
if not ok:
|
||||
raise HTTPException(500, "Failed to start OP25")
|
||||
err = await op25_client.start_with_reason()
|
||||
if err:
|
||||
raise HTTPException(502, err)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -92,30 +112,135 @@ async def start_op25():
|
||||
async def stop_op25():
|
||||
ok = await op25_client.stop()
|
||||
if not ok:
|
||||
raise HTTPException(500, "Failed to stop OP25")
|
||||
raise HTTPException(502, "The op25 container did not answer the stop request.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/op25/log")
|
||||
async def op25_log(lines: int = 80):
|
||||
out = await op25_client.log(lines)
|
||||
if out is None:
|
||||
raise HTTPException(502, "The op25 container isn't answering.")
|
||||
return {"lines": out}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
return load_node_config()
|
||||
|
||||
|
||||
async def _restart_op25_with(config: SystemConfig) -> None:
|
||||
"""Generate OP25's config for `config` and restart OP25 on it."""
|
||||
from app.main import _generate_op25_config
|
||||
if not await _generate_op25_config(config):
|
||||
raise HTTPException(
|
||||
502, f"The op25 container did not accept the config for {config.name} — is it running?",
|
||||
)
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
err = await op25_client.start_with_reason()
|
||||
if err:
|
||||
raise HTTPException(502, f"Config saved, but OP25 did not start: {err}")
|
||||
|
||||
|
||||
async def _apply_override(config: SystemConfig) -> None:
|
||||
"""Make `config` what this node listens to, chosen locally.
|
||||
|
||||
Stored as an override so a C2-assigned system (if any) survives and can
|
||||
be reverted to, and so C2 sees the node is overridden. A later C2 config
|
||||
push replaces it.
|
||||
"""
|
||||
node_cfg = load_node_config()
|
||||
node_cfg.override_system_id = config.system_id
|
||||
node_cfg.override_config = config
|
||||
save_node_config(node_cfg)
|
||||
await _restart_op25_with(config)
|
||||
if mqtt_manager.is_connected:
|
||||
await mqtt_manager.publish_checkin()
|
||||
|
||||
|
||||
@router.post("/config/system")
|
||||
async def set_system_config(config: SystemConfig):
|
||||
"""
|
||||
Apply a system config locally — called by the web UI or pushed by C2.
|
||||
Writes the OP25 config and persists the node config.
|
||||
Assign a system as if C2 had (persists as the node's own system, not an
|
||||
override) and restart OP25 on it.
|
||||
|
||||
Used to write the raw `config` blob straight to active.cfg.json, which is
|
||||
not an OP25 config at all — OP25 could not start from it.
|
||||
"""
|
||||
_validate_type(config.type)
|
||||
node_cfg = load_node_config()
|
||||
node_cfg.assigned_system_id = config.system_id
|
||||
node_cfg.system_config = config
|
||||
node_cfg.configured = True
|
||||
save_node_config(node_cfg)
|
||||
apply_system_config(config)
|
||||
await _restart_op25_with(config)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _validate_type(system_type: str) -> None:
|
||||
if str(system_type).upper() == "DMR":
|
||||
raise HTTPException(400, "DMR isn't supported by this node's decoder yet — use P25 or analog (NBFM).")
|
||||
if str(system_type).upper() not in ("P25", "NBFM"):
|
||||
raise HTTPException(400, f"Unknown system type {system_type!r} — use P25 or NBFM.")
|
||||
|
||||
|
||||
class Talkgroup(BaseModel):
|
||||
id: int
|
||||
name: str = ""
|
||||
|
||||
|
||||
class ListenBody(BaseModel):
|
||||
"""What to listen to, chosen on the local dashboard."""
|
||||
mode: Literal["system", "p25", "analog"]
|
||||
system_id: Optional[str] = None # mode=system: a cached C2 system
|
||||
name: Optional[str] = None
|
||||
frequencies: List[float] = [] # MHz; p25 = control channels, analog = exactly one
|
||||
talkgroups: List[Talkgroup] = [] # p25 only; empty = every talkgroup
|
||||
|
||||
|
||||
# RTL-SDR (R820T/R828D) tuning range.
|
||||
_MIN_MHZ, _MAX_MHZ = 24.0, 1766.0
|
||||
|
||||
|
||||
@router.post("/listen")
|
||||
async def listen(body: ListenBody):
|
||||
"""Point OP25 at a system or frequency and start it — no C2 needed."""
|
||||
if body.mode == "system":
|
||||
from app.internal.system_cacher import get_cached_system
|
||||
cached = get_cached_system(body.system_id or "")
|
||||
if not cached:
|
||||
raise HTTPException(404, "That system isn't in this node's saved list.")
|
||||
config = SystemConfig(**cached)
|
||||
_validate_type(config.type)
|
||||
else:
|
||||
freqs = body.frequencies
|
||||
if not freqs:
|
||||
raise HTTPException(400, "Enter at least one frequency in MHz.")
|
||||
bad = [f for f in freqs if not (_MIN_MHZ <= f <= _MAX_MHZ)]
|
||||
if bad:
|
||||
raise HTTPException(
|
||||
400, f"{bad[0]:g} isn't a frequency an RTL-SDR can tune — enter MHz between "
|
||||
f"{_MIN_MHZ:g} and {_MAX_MHZ:g} (e.g. 851.0125).",
|
||||
)
|
||||
if body.mode == "analog" and len(freqs) != 1:
|
||||
raise HTTPException(400, "Analog listens to exactly one frequency.")
|
||||
is_p25 = body.mode == "p25"
|
||||
first = f"{freqs[0]:.5f}".rstrip("0").rstrip(".")
|
||||
label = (body.name or "").strip() or (f"P25 {first} MHz" if is_p25 else f"Analog {first} MHz")
|
||||
config = SystemConfig(
|
||||
system_id=f"local-{body.mode}",
|
||||
name=label,
|
||||
type="P25" if is_p25 else "NBFM",
|
||||
config={
|
||||
"control_channels": freqs,
|
||||
"talkgroups": [tg.model_dump() for tg in body.talkgroups] if is_p25 else [],
|
||||
},
|
||||
)
|
||||
await _apply_override(config)
|
||||
return {"ok": True, "name": config.name}
|
||||
|
||||
|
||||
@router.get("/systems")
|
||||
async def get_systems():
|
||||
from app.internal.system_cacher import load_cached_systems, fetch_and_cache_systems
|
||||
@@ -128,9 +253,6 @@ async def set_override(
|
||||
system_id: Optional[str] = Body(None),
|
||||
system_config: Optional[dict] = Body(None)
|
||||
):
|
||||
node_cfg = load_node_config()
|
||||
config = None
|
||||
|
||||
if system_id:
|
||||
from app.internal.system_cacher import get_cached_system
|
||||
cached = get_cached_system(system_id)
|
||||
@@ -144,19 +266,8 @@ async def set_override(
|
||||
else:
|
||||
raise HTTPException(400, "Must specify system_id or system_config.")
|
||||
|
||||
node_cfg.override_system_id = config.system_id
|
||||
node_cfg.override_config = config
|
||||
save_node_config(node_cfg)
|
||||
|
||||
from app.main import _generate_op25_config
|
||||
if not await _generate_op25_config(config):
|
||||
raise HTTPException(500, f"Failed to generate OP25 config for override: {config.name}")
|
||||
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await op25_client.start()
|
||||
|
||||
await mqtt_manager._publish_checkin()
|
||||
_validate_type(config.type)
|
||||
await _apply_override(config)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -171,15 +282,14 @@ async def revert_config():
|
||||
save_node_config(node_cfg)
|
||||
|
||||
if node_cfg.system_config:
|
||||
from app.main import _generate_op25_config
|
||||
if not await _generate_op25_config(node_cfg.system_config):
|
||||
raise HTTPException(500, "Failed to regenerate original OP25 config.")
|
||||
|
||||
await _restart_op25_with(node_cfg.system_config)
|
||||
else:
|
||||
# Nothing from C2 to go back to: stop rather than keep decoding a
|
||||
# system the dashboard now says is cleared.
|
||||
await op25_client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await op25_client.start()
|
||||
|
||||
await mqtt_manager._publish_checkin()
|
||||
if mqtt_manager.is_connected:
|
||||
await mqtt_manager.publish_checkin()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -204,6 +314,44 @@ async def ack_override(timeout_minutes: int = Body(1440)):
|
||||
raise HTTPException(500, f"Failed to contact C2: {e}")
|
||||
|
||||
|
||||
@router.get("/sdr")
|
||||
async def get_sdr():
|
||||
"""Which SDR does what: detected dongles, pins, priority, what's running."""
|
||||
from app.internal.secondary_sdr_client import secondary_sdr_client
|
||||
from app.internal.sdr_settings import op25_serial
|
||||
cfg = load_node_config()
|
||||
devs = await secondary_sdr_client.devices()
|
||||
status = await secondary_sdr_client.status()
|
||||
return {
|
||||
"devices": devs, # None = secondary-sdr service unreachable
|
||||
"op25_serial": op25_serial(cfg, devs),
|
||||
"pins": cfg.sdr_pins,
|
||||
"priority": cfg.secondary_sdr_priority,
|
||||
"modes": list(SECONDARY_SDR_MODES),
|
||||
"running": [r["mode"] for r in status.get("running", [])] if status else None,
|
||||
}
|
||||
|
||||
|
||||
class SdrSettingsBody(BaseModel):
|
||||
priority: Optional[List[str]] = None
|
||||
pins: Optional[Dict[str, Optional[str]]] = None # service -> serial; null/"" = automatic
|
||||
|
||||
|
||||
@router.post("/sdr")
|
||||
async def set_sdr(body: SdrSettingsBody):
|
||||
"""Set priority and/or pins. Restarts OP25 only if OP25's own SDR changes."""
|
||||
from app.internal.sdr_settings import set_sdr_settings
|
||||
if body.priority is not None:
|
||||
unknown = [m for m in body.priority if m not in SECONDARY_SDR_MODES]
|
||||
if unknown:
|
||||
raise HTTPException(400, f"Unknown secondary SDR mode(s): {unknown}")
|
||||
try:
|
||||
running = await set_sdr_settings(body.priority, body.pins)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True, "running": running}
|
||||
|
||||
|
||||
@router.post("/discord/join")
|
||||
async def discord_join(guild_id: int, channel_id: int):
|
||||
ok = await radio_bot.join(guild_id, channel_id)
|
||||
|
||||
@@ -3,497 +3,568 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DRB Edge Node Dashboard</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<title>DRB Edge Node</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Tokens from UI_REDESIGN.md §2.2/§2.3. Status is triple-encoded
|
||||
(glyph + word + colour) and green is not used. */
|
||||
:root {
|
||||
--bg: #0b0f19;
|
||||
--glass-bg: rgba(20, 25, 40, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--text-main: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--page: #F4F6F9; --surface: #FFFFFF; --raised: #F7F9FB;
|
||||
--line: rgba(11,17,27,.11); --line-strong: rgba(11,17,27,.22);
|
||||
--ink: #101620; --ink-2: #46536A; --ink-muted: #6B788C;
|
||||
--accent: #2A78D6; --moderate: #B07800; --major: #C0281F;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--page: #0B0E13; --surface: #141922; --raised: #1B2230;
|
||||
--line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
|
||||
--ink: #E8EBF0; --ink-2: #A5B0C2; --ink-muted: #77839A;
|
||||
--accent: #3987E5; --moderate: #C98500; --major: #D03B3B;
|
||||
}
|
||||
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--page: #0B0E13; --surface: #141922; --raised: #1B2230;
|
||||
--line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
|
||||
--ink: #E8EBF0; --ink-2: #A5B0C2; --ink-muted: #77839A;
|
||||
--accent: #3987E5; --moderate: #C98500; --major: #D03B3B;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
a { color: var(--accent); }
|
||||
a.btn { color: var(--ink); }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
|
||||
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.15), transparent 25%);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
line-height: 1.5;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
background: var(--page); color: var(--ink);
|
||||
line-height: 1.5; padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.title-area h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(to right, #fff, #94a3b8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.title-area p {
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
|
||||
.wrap { max-width: 980px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
h1 { font-size: 1.5rem; font-weight: 600; }
|
||||
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
|
||||
.sub { color: var(--ink-muted); font-size: .875rem; }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
|
||||
.card > .sub { margin-bottom: 12px; }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font: inherit; font-size: .875rem; font-weight: 500; cursor: pointer;
|
||||
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--line-strong);
|
||||
background: var(--raised); color: var(--ink); text-decoration: none; display: inline-flex; align-items: center;
|
||||
}
|
||||
.btn:hover { border-color: var(--ink-muted); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.msg { font-size: .875rem; color: var(--ink-2); }
|
||||
.msg.err { color: var(--major); }
|
||||
|
||||
/* Checklist */
|
||||
.summary { font-weight: 600; margin-bottom: 8px; }
|
||||
.check { display: grid; grid-template-columns: 22px 1fr; gap: 2px 8px; padding: 10px 0; border-top: 1px solid var(--line); }
|
||||
.check:first-of-type { border-top: none; }
|
||||
.glyph { font-size: 1rem; line-height: 1.4; text-align: center; }
|
||||
.check .label { font-weight: 500; }
|
||||
.check .label .state { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-left: 6px; }
|
||||
.check .text { grid-column: 2; color: var(--ink-2); font-size: .9rem; }
|
||||
.check .action { grid-column: 2; color: var(--ink); font-size: .875rem; }
|
||||
.check .action:empty { display: none; }
|
||||
.s-ok .glyph, .s-ok .state { color: var(--ink-2); }
|
||||
.s-warn .glyph, .s-warn .state { color: var(--moderate); }
|
||||
.s-error .glyph, .s-error .state { color: var(--major); }
|
||||
.s-off .glyph, .s-off .state { color: var(--ink-muted); }
|
||||
.s-off .label { color: var(--ink-2); }
|
||||
|
||||
/* Now playing */
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; font-size: .9rem; }
|
||||
.kv dt { color: var(--ink-muted); }
|
||||
.live { color: var(--accent); font-weight: 600; }
|
||||
audio { width: 100%; margin-top: 4px; }
|
||||
pre.log {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .75rem; line-height: 1.45;
|
||||
background: var(--raised); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 10px; max-height: 280px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.23);
|
||||
transform: translateY(-1px);
|
||||
/* Listen form */
|
||||
.tabs { display: flex; border: 1px solid var(--line-strong); border-radius: 6px; overflow: hidden; width: fit-content; max-width: 100%; flex-wrap: wrap; }
|
||||
.tabs label { padding: 7px 12px; font-size: .875rem; cursor: pointer; border-left: 1px solid var(--line); }
|
||||
.tabs label:first-child { border-left: none; }
|
||||
.tabs input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.tabs label:has(input:checked) { background: var(--accent); color: #fff; }
|
||||
.tabs label:has(input:disabled) { color: var(--ink-muted); cursor: default; }
|
||||
.tabs label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; max-width: 520px; }
|
||||
.field label { font-size: .875rem; font-weight: 500; }
|
||||
.field .hint { font-size: .8rem; color: var(--ink-muted); }
|
||||
input[type=text], select, textarea {
|
||||
font: inherit; font-size: .9rem; color: var(--ink); background: var(--raised);
|
||||
border: 1px solid var(--line-strong); border-radius: 6px; padding: 7px 9px; width: 100%;
|
||||
}
|
||||
textarea { min-height: 84px; font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .8rem; }
|
||||
.current { padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); margin-bottom: 12px; font-size: .9rem; }
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-main);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.data-value.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-online { background: rgba(16, 185, 129, 0.15); color: var(--success); border: 1px solid rgba(16, 185, 129, 0.2); }
|
||||
.badge-offline { background: rgba(239, 68, 68, 0.15); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.badge-recording {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
animation: pulse-border 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
.badge-unconfigured { background: rgba(148, 163, 184, 0.15); color: var(--text-muted); border: 1px solid rgba(148, 163, 184, 0.2); }
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Player Section */
|
||||
.player-card {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
audio {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-panel {
|
||||
background-color: var(--text-main);
|
||||
}
|
||||
|
||||
.alert {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.5rem;
|
||||
color: #bfdbfe;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.alert code {
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 3rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Active Call Highlights */
|
||||
.active-call-highlight {
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* SDRs */
|
||||
.sdr-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
|
||||
.sdr-rank { width: 1.2rem; color: var(--ink-muted); font-size: .8rem; text-align: right; }
|
||||
.sdr-name { flex: 1; min-width: 120px; }
|
||||
.sdr-name small { display: block; color: var(--ink-muted); font-size: .75rem; }
|
||||
.sdr-move { font: inherit; background: var(--raised); border: 1px solid var(--line-strong); color: var(--ink); border-radius: 6px; width: 30px; height: 30px; cursor: pointer; }
|
||||
.sdr-move:disabled { opacity: .3; cursor: default; }
|
||||
.sdr-state { font-size: .75rem; min-width: 6.5rem; text-align: right; color: var(--ink-muted); }
|
||||
.sdr-state.on { color: var(--accent); }
|
||||
.sdr-select { width: auto; max-width: 14rem; font-size: .8rem; padding: 4px 6px; }
|
||||
footer { text-align: center; font-size: .8rem; color: var(--ink-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="title-area">
|
||||
<div>
|
||||
<h1 id="node-name">DRB Edge Node</h1>
|
||||
<p>ID: <span id="node-id">Loading…</span></p>
|
||||
<div class="sub">Node ID <span class="mono" id="node-id">…</span></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/scanner" target="_blank" class="btn btn-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
Scanner Mode
|
||||
</a>
|
||||
<a href="/logout" class="btn btn-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
Logout
|
||||
</a>
|
||||
<div class="row">
|
||||
<a href="/scanner" target="_blank" class="btn">Scanner view</a>
|
||||
<a href="/logout" class="btn">Log out</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="unconfigured-banner" class="alert" style="display:none">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<div>
|
||||
<strong>Node Unconfigured</strong> — This node has not been assigned a system yet. Connect it to a C2 server or configure it manually via the API (<code>POST /api/config/system</code>).
|
||||
<!-- 1. Status checklist (GET /api/health) -->
|
||||
<section class="card" aria-labelledby="h-status">
|
||||
<h2 id="h-status">Node status</h2>
|
||||
<div class="summary" id="health-summary">Checking…</div>
|
||||
<div id="checks"></div>
|
||||
</section>
|
||||
|
||||
<!-- 2. Now playing + OP25 controls -->
|
||||
<section class="card" aria-labelledby="h-now">
|
||||
<h2 id="h-now">Listen</h2>
|
||||
<p class="sub">The node's own live stream. It is a few seconds behind the radio, and silence between transmissions is normal.</p>
|
||||
<audio id="player" controls preload="none">Your browser can't play the stream — use the direct link.</audio>
|
||||
<dl class="kv">
|
||||
<dt>Listening to</dt><dd id="now-system">—</dd>
|
||||
<dt>Talkgroup</dt><dd id="now-tg">—</dd>
|
||||
<dt>Last heard</dt><dd id="now-heard">—</dd>
|
||||
<dt>Stream link</dt><dd><a id="stream-link" class="mono" href="#" target="_blank" rel="noopener">—</a></dd>
|
||||
</dl>
|
||||
<div class="row">
|
||||
<button class="btn" id="op25-start" onclick="op25('start')">Start OP25</button>
|
||||
<button class="btn" id="op25-stop" onclick="op25('stop')">Stop OP25</button>
|
||||
<button class="btn" id="log-toggle" onclick="toggleLog()" aria-expanded="false">Show OP25 log</button>
|
||||
<span class="msg" id="op25-msg" role="status"></span>
|
||||
</div>
|
||||
<pre class="log" id="op25-log" hidden></pre>
|
||||
</section>
|
||||
|
||||
<!-- 3. Choose what to listen to (POST /api/listen) -->
|
||||
<section class="card" aria-labelledby="h-choose">
|
||||
<h2 id="h-choose">Listen locally</h2>
|
||||
<p class="sub">Choose what this node's radio tunes to. Works with or without the DRB server. If the server assigns a system later, it takes over.</p>
|
||||
<div class="current" id="current-source">Nothing chosen yet.</div>
|
||||
<div class="row" id="source-actions" style="margin:-4px 0 12px" hidden>
|
||||
<button class="btn" id="revert-btn" onclick="revert()"></button>
|
||||
<button class="btn" id="ack-btn" onclick="ackOverride()" hidden>Keep for 24h on the server</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="radiogroup" aria-label="Source type">
|
||||
<label><input type="radio" name="mode" value="p25" checked> P25 trunked</label>
|
||||
<label><input type="radio" name="mode" value="analog"> Analog FM</label>
|
||||
<label><input type="radio" name="mode" value="system" id="mode-system"> Server's systems</label>
|
||||
</div>
|
||||
|
||||
<div class="pane" data-pane="p25">
|
||||
<div class="field">
|
||||
<label for="p25-freqs">Control channel frequencies (MHz)</label>
|
||||
<input type="text" id="p25-freqs" class="mono" placeholder="851.0125, 851.5125" inputmode="decimal">
|
||||
<span class="hint">The system's control channels, comma-separated. Find them on RadioReference under your county's trunked system.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p25-name">Name <span class="hint">(optional)</span></label>
|
||||
<input type="text" id="p25-name" placeholder="County P25">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p25-tgs">Talkgroups to keep <span class="hint">(optional)</span></label>
|
||||
<textarea id="p25-tgs" placeholder="1001 Fire Dispatch 1002 Fireground"></textarea>
|
||||
<span class="hint">One per line: decimal ID, then a name. Leave empty to hear every talkgroup.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="override-banner" class="alert" style="display:none; background: rgba(245, 158, 11, 0.1); border-color: var(--warning); color: #fef08a;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
||||
<div style="flex: 1;">
|
||||
<strong>Local Override Active</strong> — System overridden to: <span id="override-system-text"></span>.
|
||||
<span id="timeout-timer-text">Timeout is active on C2 server.</span>
|
||||
<div class="pane" data-pane="analog" hidden>
|
||||
<div class="field">
|
||||
<label for="an-freq">Frequency (MHz)</label>
|
||||
<input type="text" id="an-freq" class="mono" placeholder="154.4300" inputmode="decimal">
|
||||
<span class="hint">One conventional narrowband FM channel.</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" onclick="revertToServerConfig()">Revert</button>
|
||||
<button id="ack-override-btn" class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem; background: var(--warning); box-shadow: none;" onclick="ackOverrideC2()">Ack C2 Timer</button>
|
||||
<div class="field">
|
||||
<label for="an-name">Name <span class="hint">(optional)</span></label>
|
||||
<input type="text" id="an-name" placeholder="Fire dispatch">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Status Card -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">System Status</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">State</span>
|
||||
<span class="data-value"><span id="status-badge" class="badge badge-unconfigured">—</span></span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Discord</span>
|
||||
<span class="data-value" id="discord-status">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Recording</span>
|
||||
<span class="data-value" id="recording-status">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">OP25 Core</span>
|
||||
<span class="data-value" id="op25-status">—</span>
|
||||
<div class="pane" data-pane="system" hidden>
|
||||
<div class="field">
|
||||
<label for="sys-pick">System</label>
|
||||
<select id="sys-pick"></select>
|
||||
<span class="hint" id="sys-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Card -->
|
||||
<div class="glass-card" id="call-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Live Activity</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">System</span>
|
||||
<span class="data-value active-call-highlight" id="system-name">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Talkgroup</span>
|
||||
<span class="data-value active-call-highlight" id="tgid-name">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">TGID</span>
|
||||
<span class="data-value mono" id="tgid">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Call ID</span>
|
||||
<span class="data-value mono" id="call-id">—</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:12px">DMR isn't supported by this node's decoder yet.</p>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="btn primary" id="listen-btn" onclick="listen()">Start listening</button>
|
||||
<span class="msg" id="listen-msg" role="status"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Node Info Card -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Hardware & Config</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Location</span>
|
||||
<span class="data-value mono" id="location">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">Configured</span>
|
||||
<span class="data-value" id="configured">—</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">System ID</span>
|
||||
<span class="data-value mono" id="system-id">—</span>
|
||||
<!-- 4. SDRs (node-26#9, #11) -->
|
||||
<section class="card" aria-labelledby="h-sdr">
|
||||
<h2 id="h-sdr">SDRs</h2>
|
||||
<p class="sub">Only matters with more than one SDR plugged in. OP25 always gets one; every other SDR runs the next enabled service, top first.</p>
|
||||
<div class="row">
|
||||
<label for="sdr-op25" style="font-size:.875rem;font-weight:500">OP25 uses</label>
|
||||
<select id="sdr-op25" class="sdr-select"></select>
|
||||
</div>
|
||||
<p id="sdr-op25-note" class="sub" style="margin:6px 0 4px"></p>
|
||||
<div id="sdr-list"></div>
|
||||
<p id="sdr-warning" class="msg err" style="margin-top:8px" hidden></p>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button id="sdr-save" class="btn primary" onclick="saveSdr()" disabled>Save SDR settings</button>
|
||||
<span id="sdr-msg" class="msg"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Local Listening Card -->
|
||||
<div class="glass-card player-card">
|
||||
<div class="card-header" style="margin-bottom:0.5rem; border:none;">
|
||||
<div class="card-title">Local Audio Stream</div>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
<audio id="icecast-player" controls preload="none">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<a id="icecast-link" class="btn btn-primary" href="#" target="_blank" style="padding: 0.5rem 1rem;">Direct Link</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Last updated: <span id="last-updated">—</span>
|
||||
</div>
|
||||
<footer>Updated <span id="last-updated">—</span></footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const player = document.getElementById('icecast-player');
|
||||
let currentIcecastUrl = null;
|
||||
const $ = id => document.getElementById(id);
|
||||
function esc(v) {
|
||||
return String(v ?? '').replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
}
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(path, opts);
|
||||
let body = null;
|
||||
try { body = await r.json(); } catch (e) { /* non-JSON */ }
|
||||
if (r.status === 401) { location.href = '/login'; throw new Error('Session expired'); }
|
||||
if (!r.ok) throw new Error((body && body.detail) || r.statusText || `HTTP ${r.status}`);
|
||||
return body;
|
||||
}
|
||||
function ago(epoch) {
|
||||
const s = Math.max(0, Math.round(Date.now() / 1000 - epoch));
|
||||
return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.floor(s / 60)} min ago` : `${Math.floor(s / 3600)} h ago`;
|
||||
}
|
||||
function mhz(f) {
|
||||
const n = Number(f);
|
||||
if (!n) return '';
|
||||
return (n > 1e5 ? n / 1e6 : n).toFixed(4) + ' MHz';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
// ── Status checklist ───────────────────────────────────────────────────
|
||||
const GLYPH = { ok: '✓', warn: '△', error: '▲', off: '–' };
|
||||
const WORD = { ok: 'OK', warn: 'Attention', error: 'Problem', off: 'Not active' };
|
||||
let lastHealth = null;
|
||||
|
||||
async function refreshHealth() {
|
||||
try {
|
||||
const r = await fetch('/api/status');
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
|
||||
// Header
|
||||
document.getElementById('node-name').textContent = d.node_name || 'DRB Edge Node';
|
||||
document.getElementById('node-id').textContent = d.node_id || '';
|
||||
|
||||
// Status
|
||||
const status = d.is_recording ? 'recording' : (d.configured ? 'online' : 'unconfigured');
|
||||
const badge = document.getElementById('status-badge');
|
||||
badge.textContent = status;
|
||||
badge.className = 'badge badge-' + status;
|
||||
|
||||
document.getElementById('discord-status').textContent = d.discord_connected ? 'Connected' : 'Disconnected';
|
||||
document.getElementById('recording-status').textContent = d.is_recording ? 'Active' : 'Idle';
|
||||
document.getElementById('op25-status').textContent = d.op25?.status === 'running' ? 'Running' : 'Stopped';
|
||||
|
||||
// Call Info
|
||||
document.getElementById('system-name').textContent = d.system_name || '—';
|
||||
document.getElementById('tgid-name').textContent = d.active_tgid_name || (d.active_tgid ? `TG ${d.active_tgid}` : 'Scanning...');
|
||||
document.getElementById('tgid').textContent = d.active_tgid ?? '—';
|
||||
document.getElementById('call-id').textContent = d.active_call_id ? d.active_call_id.slice(0, 8) + '…' : '—';
|
||||
|
||||
// Highlight active call card
|
||||
const callCard = document.getElementById('call-card');
|
||||
if (d.is_recording) {
|
||||
callCard.style.borderColor = 'var(--accent)';
|
||||
callCard.style.boxShadow = '0 0 20px rgba(59, 130, 246, 0.2)';
|
||||
} else {
|
||||
callCard.style.borderColor = 'var(--glass-border)';
|
||||
callCard.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
||||
}
|
||||
|
||||
// Node Info
|
||||
document.getElementById('location').textContent = `${d.lat}, ${d.lon}`;
|
||||
document.getElementById('configured').textContent = d.configured ? 'Yes' : 'No';
|
||||
document.getElementById('system-id').textContent = d.assigned_system_id ?? '—';
|
||||
|
||||
// Audio Player
|
||||
if (d.icecast_url) {
|
||||
const dynamicUrl = d.icecast_url.replace('localhost', window.location.hostname);
|
||||
if (dynamicUrl !== currentIcecastUrl) {
|
||||
currentIcecastUrl = dynamicUrl;
|
||||
player.src = currentIcecastUrl;
|
||||
document.getElementById('icecast-link').href = currentIcecastUrl;
|
||||
const d = await api('/api/health');
|
||||
lastHealth = d;
|
||||
const box = $('checks');
|
||||
box.innerHTML = d.checks.map(c => `
|
||||
<div class="check s-${c.state}">
|
||||
<span class="glyph" aria-hidden="true">${GLYPH[c.state] || '?'}</span>
|
||||
<div class="label">${esc(c.label)}<span class="state">${WORD[c.state] || esc(c.state)}</span></div>
|
||||
<div class="text">${esc(c.summary)}</div>
|
||||
<div class="action">${esc(c.action)}</div>
|
||||
</div>`).join('');
|
||||
const bad = d.checks.filter(c => c.state === 'error').length;
|
||||
const warn = d.checks.filter(c => c.state === 'warn').length;
|
||||
$('health-summary').textContent = bad
|
||||
? `${bad} problem${bad > 1 ? 's' : ''}${warn ? ` and ${warn} thing${warn > 1 ? 's' : ''} to check` : ''} — see below.`
|
||||
: warn ? `${warn} thing${warn > 1 ? 's' : ''} need${warn > 1 ? '' : 's'} attention — see below.`
|
||||
: 'Everything is working.';
|
||||
const heard = d.last_heard;
|
||||
$('now-heard').innerHTML = heard
|
||||
? `<span class="mono">TG ${esc(heard.tgid)}</span>${heard.name ? ' ' + esc(heard.name) : ''}` +
|
||||
` · ${ago(heard.epoch)}${heard.freq ? ` · <span class="mono">${esc(mhz(heard.freq))}</span>` : ''}`
|
||||
: 'Nothing heard since the node started.';
|
||||
const op = d.checks.find(c => c.id === 'op25');
|
||||
const src = d.checks.find(c => c.id === 'source');
|
||||
const running = op && op.state === 'ok';
|
||||
$('op25-start').disabled = running || (src && src.state !== 'ok');
|
||||
$('op25-stop').disabled = !running;
|
||||
if (op && op.state === 'error' && $('op25-log').hidden) loadLog();
|
||||
} catch (e) {
|
||||
$('health-summary').textContent = `Couldn't load status: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Override Banner
|
||||
const overrideBanner = document.getElementById('override-banner');
|
||||
if (d.is_overridden) {
|
||||
overrideBanner.style.display = 'flex';
|
||||
document.getElementById('override-system-text').textContent = d.system_name || 'Manual';
|
||||
// ── Live status: system, talkgroup, stream URL ─────────────────────────
|
||||
let streamUrl = null;
|
||||
let status = null;
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
status = d;
|
||||
$('node-name').textContent = d.node_name || 'DRB Edge Node';
|
||||
$('node-id').textContent = d.node_id || '';
|
||||
document.title = `${d.node_name || 'DRB Edge Node'} — DRB`;
|
||||
$('now-system').textContent = d.system_name ? `${d.system_name} (${d.system_type})` : 'Nothing chosen yet';
|
||||
$('now-tg').innerHTML = d.active_tgid
|
||||
? `<span class="live">${esc(d.active_tgid_name || 'Talkgroup')}</span> <span class="mono">TG ${esc(d.active_tgid)}</span>`
|
||||
: 'No call right now';
|
||||
|
||||
const ackBtn = document.getElementById('ack-override-btn');
|
||||
const timerText = document.getElementById('timeout-timer-text');
|
||||
|
||||
if (d.enforce_override_timeout) {
|
||||
timerText.textContent = "C2 reset timer active (24h default).";
|
||||
ackBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
timerText.textContent = "No C2 timeout is enforced.";
|
||||
ackBtn.style.display = 'none';
|
||||
// The node calls Icecast "localhost"; the browser must use the node's address.
|
||||
const url = `${location.protocol}//${location.hostname}:${d.icecast_port}${d.icecast_mount}`;
|
||||
if (url !== streamUrl) {
|
||||
streamUrl = url;
|
||||
$('player').src = url;
|
||||
$('stream-link').href = url;
|
||||
$('stream-link').textContent = url;
|
||||
}
|
||||
} else {
|
||||
overrideBanner.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex';
|
||||
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
|
||||
renderSource(d);
|
||||
$('last-updated').textContent = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
console.error('Status fetch failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function revertToServerConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/config/revert', { method: 'POST' });
|
||||
if (r.ok) {
|
||||
alert('Config reverted successfully.');
|
||||
refresh();
|
||||
} else {
|
||||
alert('Failed to revert config.');
|
||||
function renderSource(d) {
|
||||
const cur = $('current-source');
|
||||
const actions = $('source-actions');
|
||||
if (!d.system_name) {
|
||||
cur.textContent = 'Nothing chosen yet. Pick a source below and press Start listening.';
|
||||
actions.hidden = true;
|
||||
return;
|
||||
}
|
||||
cur.innerHTML = `Now: <strong>${esc(d.system_name)}</strong> (${esc(d.system_type)}) — ` +
|
||||
(d.local_choice ? 'chosen on this dashboard.' : 'assigned by the DRB server.');
|
||||
actions.hidden = !d.local_choice;
|
||||
$('revert-btn').textContent = d.has_c2_system ? "Go back to the server's system" : 'Clear and stop';
|
||||
$('ack-btn').hidden = !(d.local_choice && d.has_c2_system && d.enforce_override_timeout);
|
||||
}
|
||||
|
||||
// ── OP25 controls + log ────────────────────────────────────────────────
|
||||
async function op25(action) {
|
||||
$('op25-msg').className = 'msg';
|
||||
$('op25-msg').textContent = action === 'start' ? 'Starting…' : 'Stopping…';
|
||||
try {
|
||||
await api(`/api/op25/${action}`, { method: 'POST' });
|
||||
$('op25-msg').textContent = action === 'start' ? 'Started. Give it ~20 seconds to lock on.' : 'Stopped.';
|
||||
} catch (e) {
|
||||
console.error('Revert failed:', e);
|
||||
$('op25-msg').className = 'msg err';
|
||||
$('op25-msg').textContent = e.message;
|
||||
}
|
||||
refreshHealth();
|
||||
}
|
||||
async function loadLog() {
|
||||
const pre = $('op25-log');
|
||||
try {
|
||||
const d = await api('/api/op25/log?lines=120');
|
||||
pre.textContent = d.lines.length ? d.lines.join('\n') : 'OP25 has printed nothing since it last started.';
|
||||
} catch (e) {
|
||||
pre.textContent = `Couldn't load the log: ${e.message}`;
|
||||
}
|
||||
pre.hidden = false;
|
||||
$('log-toggle').textContent = 'Hide OP25 log';
|
||||
$('log-toggle').setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function toggleLog() {
|
||||
const pre = $('op25-log');
|
||||
if (pre.hidden) return loadLog();
|
||||
pre.hidden = true;
|
||||
$('log-toggle').textContent = 'Show OP25 log';
|
||||
$('log-toggle').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
// ── Listen locally ─────────────────────────────────────────────────────
|
||||
let systems = [];
|
||||
function mode() { return document.querySelector('input[name=mode]:checked').value; }
|
||||
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => {
|
||||
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.dataset.pane !== mode(); });
|
||||
$('listen-msg').textContent = '';
|
||||
}));
|
||||
|
||||
async function loadSystems() {
|
||||
try { systems = await api('/api/systems'); } catch (e) { systems = []; }
|
||||
const pick = $('sys-pick');
|
||||
const usable = systems.filter(s => String(s.type).toUpperCase() !== 'DMR');
|
||||
pick.innerHTML = usable.map(s => `<option value="${esc(s.system_id)}">${esc(s.name)} (${esc(s.type)})</option>`).join('');
|
||||
const none = usable.length === 0;
|
||||
$('mode-system').disabled = none;
|
||||
pick.disabled = none;
|
||||
$('sys-hint').textContent = none
|
||||
? "The server's systems appear here once this node is approved and online."
|
||||
: `${usable.length} system${usable.length > 1 ? 's' : ''} saved from the DRB server.`;
|
||||
}
|
||||
|
||||
function parseFreqs(text) {
|
||||
return text.split(/[\s,;]+/).filter(Boolean).map(Number);
|
||||
}
|
||||
|
||||
async function listen() {
|
||||
const m = mode();
|
||||
const msg = $('listen-msg');
|
||||
let body;
|
||||
if (m === 'system') {
|
||||
body = { mode: 'system', system_id: $('sys-pick').value };
|
||||
} else if (m === 'p25') {
|
||||
const freqs = parseFreqs($('p25-freqs').value);
|
||||
if (!freqs.length || freqs.some(isNaN)) { msg.className = 'msg err'; msg.textContent = 'Enter control channel frequencies in MHz, e.g. 851.0125'; return; }
|
||||
const tgs = [];
|
||||
for (const line of $('p25-tgs').value.split('\n').map(l => l.trim()).filter(Boolean)) {
|
||||
const mm = line.match(/^(\d+)\s*[,\t ]?\s*(.*)$/);
|
||||
if (!mm) { msg.className = 'msg err'; msg.textContent = `Talkgroup line "${line}" should start with a decimal ID.`; return; }
|
||||
tgs.push({ id: Number(mm[1]), name: mm[2] });
|
||||
}
|
||||
body = { mode: 'p25', frequencies: freqs, name: $('p25-name').value, talkgroups: tgs };
|
||||
} else {
|
||||
const freqs = parseFreqs($('an-freq').value);
|
||||
if (freqs.length !== 1 || isNaN(freqs[0])) { msg.className = 'msg err'; msg.textContent = 'Enter one frequency in MHz, e.g. 154.4300'; return; }
|
||||
body = { mode: 'analog', frequencies: freqs, name: $('an-name').value };
|
||||
}
|
||||
$('listen-btn').disabled = true;
|
||||
msg.className = 'msg';
|
||||
msg.textContent = 'Applying — restarting OP25…';
|
||||
try {
|
||||
const d = await api('/api/listen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
msg.textContent = `Listening to ${d.name}. Give it ~20 seconds, then press play above.`;
|
||||
} catch (e) {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = e.message;
|
||||
} finally {
|
||||
$('listen-btn').disabled = false;
|
||||
refreshStatus(); refreshHealth();
|
||||
}
|
||||
}
|
||||
|
||||
async function ackOverrideC2() {
|
||||
async function revert() {
|
||||
const toServer = status && status.has_c2_system;
|
||||
if (!toServer && !confirm('Clear this choice and stop OP25?')) return;
|
||||
const msg = $('listen-msg');
|
||||
msg.className = 'msg';
|
||||
msg.textContent = toServer ? "Switching back to the server's system…" : 'Stopping…';
|
||||
try {
|
||||
const r = await fetch('/api/config/override/ack', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ timeout_minutes: 1440 })
|
||||
await api('/api/config/revert', { method: 'POST' });
|
||||
msg.textContent = toServer ? "Back on the server's system." : 'Cleared. OP25 is stopped.';
|
||||
} catch (e) {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = e.message;
|
||||
}
|
||||
refreshStatus(); refreshHealth();
|
||||
}
|
||||
|
||||
async function ackOverride() {
|
||||
const msg = $('listen-msg');
|
||||
try {
|
||||
await api('/api/config/override/ack', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timeout_minutes: 1440 }) });
|
||||
msg.className = 'msg';
|
||||
msg.textContent = 'The server will keep this choice for 24 hours.';
|
||||
} catch (e) {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = `Couldn't reach the server: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SDRs: OP25's dongle, secondary priority and per-service pins ──────
|
||||
const SDR_LABELS = { adsb: ['ADS-B', 'Aircraft · 1090 MHz'], ais: ['AIS', 'Vessels · 162 MHz'] };
|
||||
let sdr = null, sdrRows = [], sdrOp25Pin = '', sdrDirty = false;
|
||||
|
||||
function deviceOptions(selected, autoLabel) {
|
||||
const devs = sdr.devices || [];
|
||||
const known = devs.some(d => d.serial === selected);
|
||||
return `<option value="">${esc(autoLabel)}</option>` +
|
||||
devs.map(d => `<option value="${esc(d.serial)}" ${d.serial === selected ? 'selected' : ''}>` +
|
||||
`SDR ${d.index + 1} · serial ${esc(d.serial)}${d.duplicate_serial ? ' (shared serial!)' : ''}</option>`).join('') +
|
||||
(selected && !known ? `<option value="${esc(selected)}" selected>serial ${esc(selected)} ` +
|
||||
`(${sdr.devices === null ? 'not reported' : 'not plugged in'})</option>` : '');
|
||||
}
|
||||
|
||||
function renderSdr() {
|
||||
const op25sel = $('sdr-op25');
|
||||
op25sel.innerHTML = deviceOptions(sdrOp25Pin, 'Automatic (first SDR)');
|
||||
op25sel.onchange = () => { sdrOp25Pin = op25sel.value; markSdrDirty(); };
|
||||
$('sdr-op25-note').textContent = sdrOp25Pin !== (sdr.pins.op25 || '')
|
||||
? 'Saving will restart OP25 on the selected SDR.'
|
||||
: (sdr.op25_serial ? `OP25 is using serial ${sdr.op25_serial}.` : '');
|
||||
const list = $('sdr-list');
|
||||
list.innerHTML = '';
|
||||
let rank = 0;
|
||||
const running = sdr.running || [];
|
||||
sdrRows.forEach((row, i) => {
|
||||
const [name, hint] = SDR_LABELS[row.mode] || [row.mode, ''];
|
||||
const pinMissing = row.pin && sdr.devices !== null && !sdr.devices.some(d => d.serial === row.pin);
|
||||
const state = !row.enabled ? 'Off' : sdrDirty ? 'Unsaved' : sdr.running === null ? 'Unknown'
|
||||
: running.includes(row.mode) ? 'Running' : pinMissing ? 'Pinned SDR missing' : '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>
|
||||
<select class="sdr-select" aria-label="${name} SDR">${deviceOptions(row.pin, 'Any spare SDR')}</select>
|
||||
<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 ${state === 'Running' ? 'on' : ''}">${state}</span>`;
|
||||
const [box] = el.getElementsByTagName('input');
|
||||
const [pick] = el.getElementsByTagName('select');
|
||||
const [up, down] = el.getElementsByTagName('button');
|
||||
box.onchange = () => { row.enabled = box.checked; markSdrDirty(); };
|
||||
pick.onchange = () => { row.pin = pick.value; 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);
|
||||
});
|
||||
if (r.ok) {
|
||||
alert('Override acknowledged on C2.');
|
||||
} else {
|
||||
alert('Failed to send acknowledgement.');
|
||||
const warn = $('sdr-warning');
|
||||
const pinned = [sdrOp25Pin, ...sdrRows.map(r => r.pin)].filter(Boolean);
|
||||
const problems = [];
|
||||
if (sdr.devices === null) problems.push("The SDR service isn't responding, so SDRs can't be listed.");
|
||||
if ((sdr.devices || []).some(d => d.duplicate_serial)) {
|
||||
problems.push("Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s) before pinning.");
|
||||
}
|
||||
if (new Set(pinned).size !== pinned.length) problems.push('Two services are pinned to the same SDR.');
|
||||
warn.textContent = problems.join(' ');
|
||||
warn.hidden = !problems.length;
|
||||
$('sdr-save').disabled = !sdrDirty || new Set(pinned).size !== pinned.length;
|
||||
}
|
||||
function markSdrDirty() { sdrDirty = true; $('sdr-msg').textContent = ''; renderSdr(); }
|
||||
|
||||
async function loadSdr() {
|
||||
if (sdrDirty) return; // never clobber unsaved edits with a poll
|
||||
try {
|
||||
sdr = await api('/api/sdr');
|
||||
sdrOp25Pin = sdr.pins.op25 || '';
|
||||
sdrRows = [
|
||||
...sdr.priority.map(mode => ({ mode, enabled: true, pin: sdr.pins[mode] || '' })),
|
||||
...sdr.modes.filter(m => !sdr.priority.includes(m)).map(mode => ({ mode, enabled: false, pin: sdr.pins[mode] || '' })),
|
||||
];
|
||||
renderSdr();
|
||||
} catch (e) {
|
||||
console.error('Ack failed:', e);
|
||||
console.error('SDR load failed:', e);
|
||||
}
|
||||
}
|
||||
async function saveSdr() {
|
||||
const pins = { op25: sdrOp25Pin || null };
|
||||
sdrRows.forEach(r => { pins[r.mode] = r.pin || null; });
|
||||
const btn = $('sdr-save'), msg = $('sdr-msg');
|
||||
btn.disabled = true;
|
||||
msg.textContent = 'Applying…';
|
||||
try {
|
||||
const d = await api('/api/sdr', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ priority: sdrRows.filter(r => r.enabled).map(r => r.mode), pins }),
|
||||
});
|
||||
sdrDirty = false;
|
||||
msg.textContent = d.running === null ? "Saved, but the SDR service didn't respond." : 'Saved.';
|
||||
await loadSdr();
|
||||
} catch (e) {
|
||||
msg.textContent = `Save failed: ${e.message}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 2000); // Polling every 2 seconds
|
||||
refreshStatus(); setInterval(refreshStatus, 2000);
|
||||
refreshHealth(); setInterval(refreshHealth, 5000);
|
||||
loadSystems();
|
||||
loadSdr(); setInterval(loadSdr, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
}
|
||||
.softkey:last-child { border-right: none; }
|
||||
|
||||
/* Overlay UI (Volume, Hold) */
|
||||
/* Overlay UI (Volume, status) */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
@@ -153,7 +153,6 @@
|
||||
<ul>
|
||||
<li><span>Up Arrow</span> Volume Up</li>
|
||||
<li><span>Down Arrow</span> Volume Down</li>
|
||||
<li><span>Enter / H</span> Toggle Hold (UI mock)</li>
|
||||
<li><span>P</span> Play/Pause Stream</li>
|
||||
<li><span>S</span> Select System</li>
|
||||
<li><span>R</span> Revert Override</li>
|
||||
@@ -171,15 +170,14 @@
|
||||
<h3 style="color: var(--dept-color); margin-bottom: 6px; font-size: 13px;">Or Enter Freq Manually:</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 5px; font-size: 11px; margin-bottom: 8px;">
|
||||
<div>
|
||||
<label>Control Freq (MHz/Hz):</label>
|
||||
<label>Freq (MHz) — P25 control channel or analog:</label>
|
||||
<input type="text" id="manual-freq" style="background:#222; color:#fff; border:1px solid #555; padding:3px; width:100%; border-radius:2px;">
|
||||
</div>
|
||||
<div>
|
||||
<label>System Type:</label>
|
||||
<select id="manual-type" style="background:#222; color:#fff; border:1px solid #555; padding:3px; width:100%; border-radius:2px;">
|
||||
<option value="P25">P25</option>
|
||||
<option value="DMR">DMR</option>
|
||||
<option value="NBFM">NBFM</option>
|
||||
<option value="NBFM">Analog FM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,11 +206,11 @@
|
||||
|
||||
<div class="softkeys">
|
||||
<div class="softkey">VOL (+/-)</div>
|
||||
<div class="softkey">HOLD (H)</div>
|
||||
<div class="softkey">SYSTEM (S)</div>
|
||||
<div class="softkey">PLAY (P)</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="overlay">HOLD</div>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
<!-- Audio element for local playback -->
|
||||
<audio id="audio-player" autoplay></audio>
|
||||
@@ -231,7 +229,6 @@
|
||||
const elOverlay = document.getElementById('overlay');
|
||||
|
||||
let currentIcecastUrl = null;
|
||||
let isHolding = false;
|
||||
let volume = 1.0;
|
||||
|
||||
// Clock updater
|
||||
@@ -309,7 +306,7 @@
|
||||
const container = document.getElementById('system-list');
|
||||
container.innerHTML = '';
|
||||
if (systems.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 10px; color: #888;">No cached systems found.</div>';
|
||||
container.innerHTML = '<div style="padding: 10px; color: #888;">No server systems saved yet (they arrive once the node is approved). Enter a frequency below.</div>';
|
||||
return;
|
||||
}
|
||||
systems.forEach((sys, index) => {
|
||||
@@ -373,25 +370,20 @@
|
||||
}
|
||||
closeSystemModal();
|
||||
showOverlay("APPLYING MANUAL", 3000);
|
||||
const manualConfig = {
|
||||
system_id: "manual-override",
|
||||
name: `Freq ${freqVal} (${typeVal})`,
|
||||
type: typeVal,
|
||||
config: {
|
||||
control_channels: [freqVal],
|
||||
talkgroups: []
|
||||
}
|
||||
};
|
||||
// Same validated path as the dashboard's "Listen locally" (MHz range check).
|
||||
const mode = typeVal === 'NBFM' ? 'analog' : 'p25';
|
||||
const freqs = freqVal.split(/[\s,;]+/).filter(Boolean).map(Number);
|
||||
try {
|
||||
const res = await fetch('/api/config/override', {
|
||||
const res = await fetch('/api/listen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system_config: manualConfig })
|
||||
body: JSON.stringify({ mode, frequencies: freqs })
|
||||
});
|
||||
if (res.ok) {
|
||||
showOverlay('MANUAL OVERRIDE OK', 1500);
|
||||
showOverlay('MANUAL OK', 1500);
|
||||
} else {
|
||||
showOverlay('MANUAL OVERRIDE FAIL', 2000);
|
||||
const d = await res.json().catch(() => ({}));
|
||||
showOverlay((d.detail || 'MANUAL FAILED').slice(0, 60), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
showOverlay('ERROR APPLYING', 2000);
|
||||
@@ -444,9 +436,6 @@
|
||||
audioPlayer.volume = volume;
|
||||
elVol.textContent = `VOL: ${Math.round(volume * 100)}%`;
|
||||
showOverlay(`VOL: ${Math.round(volume * 100)}%`);
|
||||
} else if (e.key === 'Enter' || e.key === 'h' || e.key === 'H') {
|
||||
isHolding = !isHolding;
|
||||
showOverlay(isHolding ? 'HOLD ON' : 'HOLD OFF');
|
||||
} else if (e.key === 'p' || e.key === 'P') {
|
||||
if (audioPlayer.paused) {
|
||||
audioPlayer.play();
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Local dashboard: the health checklist (/api/health) and choosing what to
|
||||
listen to without C2 (/api/listen, /api/config/system, /api/config/revert).
|
||||
|
||||
Same standalone-app pattern as test_auth_endpoints.py — the real routers,
|
||||
none of app.main's lifespan. OP25/MQTT are mocked at the client boundary.
|
||||
"""
|
||||
import base64
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import config_manager, credentials, health
|
||||
from app.internal.op25_client import parse_terminal_messages
|
||||
from app.routers import api, ui
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(credentials, "_CREDS_FILE", tmp_path / "credentials.json")
|
||||
monkeypatch.setattr(credentials, "_api_key", None)
|
||||
monkeypatch.setattr(credentials, "_auth_salt", None)
|
||||
monkeypatch.setattr(credentials, "_session_secret", None)
|
||||
monkeypatch.setattr(config_manager, "_CONFIG_FILE", tmp_path / "node_config.json")
|
||||
monkeypatch.setattr(settings, "dashboard_username", "tester")
|
||||
monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
app.include_router(ui.router)
|
||||
auth = base64.b64encode(b"tester:s3cret-pass").decode()
|
||||
with TestClient(app, headers={"Authorization": f"Basic {auth}"}) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def op25():
|
||||
"""Mock the OP25 side of an apply: generate, stop, start, checkin."""
|
||||
with patch("app.main._generate_op25_config", AsyncMock(return_value=True)) as gen, \
|
||||
patch("app.routers.api.op25_client.stop", AsyncMock(return_value=True)) as stop, \
|
||||
patch("app.routers.api.op25_client.start_with_reason", AsyncMock(return_value=None)) as start, \
|
||||
patch("app.routers.api.asyncio.sleep", AsyncMock()), \
|
||||
patch("app.routers.api.mqtt_manager.publish_checkin", AsyncMock()) as checkin:
|
||||
yield {"gen": gen, "stop": stop, "start": start, "checkin": checkin}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health checklist — pure function of facts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _facts(**over):
|
||||
now = 1_000_000.0
|
||||
f = {
|
||||
"now": now, "node_id": "toughny", "default_password": False,
|
||||
"c2_url": "https://api.example", "has_api_key": True, "mqtt_connected": True,
|
||||
"mqtt_broker": "mqtt.example", "mqtt_port": 8883, "sdr_count": 1,
|
||||
"source": {"name": "County", "type": "P25", "system_id": "s1", "local": False},
|
||||
"op25_status": "running",
|
||||
"console": {"last_poll_epoch": now - 1, "channels": [], "trunk": [{"last_tsbk": now - 1, "rxchan": 851012500}],
|
||||
"last_heard": None},
|
||||
"audio_capturing": True, "last_voice_epoch": now - 30, "icecast_live": True, "icecast_port": 8000,
|
||||
}
|
||||
f.update(over)
|
||||
return f
|
||||
|
||||
|
||||
def _by_id(checks):
|
||||
return {c["id"]: c for c in checks}
|
||||
|
||||
|
||||
def test_healthy_node_is_all_ok():
|
||||
checks = health.build_checks(_facts())
|
||||
assert all(c["state"] == "ok" for c in checks), [c for c in checks if c["state"] != "ok"]
|
||||
assert "851.0125 MHz" in _by_id(checks)["decode"]["summary"]
|
||||
|
||||
|
||||
def test_unapproved_node_explains_wait_and_mqtt_refusal():
|
||||
"""Toughny's state: C2 set, no API key, broker refuses, nothing chosen."""
|
||||
c = _by_id(health.build_checks(_facts(has_api_key=False, mqtt_connected=False, source=None,
|
||||
op25_status="stopped")))
|
||||
assert c["c2"]["state"] == "warn" and "approve" in c["c2"]["action"]
|
||||
assert "toughny" in c["c2"]["action"]
|
||||
assert c["mqtt"]["state"] == "warn"
|
||||
assert c["source"]["state"] == "warn" and "Listen locally" in c["source"]["action"]
|
||||
assert c["op25"]["state"] == "off"
|
||||
assert c["decode"]["state"] == "off"
|
||||
|
||||
|
||||
def test_key_on_disk_but_mqtt_down_says_restart():
|
||||
c = _by_id(health.build_checks(_facts(mqtt_connected=False)))
|
||||
assert c["mqtt"]["state"] == "error"
|
||||
assert "restart the edge-node container" in c["mqtt"]["action"]
|
||||
|
||||
|
||||
def test_standalone_node_is_not_an_error():
|
||||
c = _by_id(health.build_checks(_facts(c2_url=None, has_api_key=False, mqtt_connected=False)))
|
||||
assert c["c2"]["state"] == "off"
|
||||
assert c["mqtt"]["state"] == "off"
|
||||
|
||||
|
||||
def test_no_sdr_and_crashed_op25():
|
||||
c = _by_id(health.build_checks(_facts(sdr_count=0, op25_status="crashed")))
|
||||
assert c["sdr"]["state"] == "error"
|
||||
assert c["op25"]["state"] == "error" and "log" in c["op25"]["action"]
|
||||
|
||||
|
||||
def test_running_but_no_control_channel():
|
||||
now = 1_000_000.0
|
||||
c = _by_id(health.build_checks(_facts(console={"last_poll_epoch": now, "channels": [],
|
||||
"trunk": [{"last_tsbk": 0}], "last_heard": None})))
|
||||
assert c["decode"]["state"] == "error"
|
||||
|
||||
|
||||
def test_analog_needs_no_control_channel():
|
||||
now = 1_000_000.0
|
||||
c = _by_id(health.build_checks(_facts(
|
||||
source={"name": "Fire", "type": "NBFM", "system_id": "local-analog", "local": True},
|
||||
console={"last_poll_epoch": now, "channels": [{"freq": 154430000}], "trunk": [], "last_heard": None},
|
||||
)))
|
||||
assert c["decode"]["state"] == "ok" and "154.4300 MHz" in c["decode"]["summary"]
|
||||
assert "not recorded" in c["decode"]["action"]
|
||||
assert "this dashboard" in c["source"]["summary"]
|
||||
|
||||
|
||||
def test_op25_unreachable_and_icecast_down():
|
||||
c = _by_id(health.build_checks(_facts(op25_status=None, icecast_live=None)))
|
||||
assert c["op25"]["state"] == "error"
|
||||
assert c["stream"]["state"] == "error"
|
||||
|
||||
|
||||
def test_default_password_warns():
|
||||
assert _by_id(health.build_checks(_facts(default_password=True)))["password"]["state"] == "warn"
|
||||
|
||||
|
||||
def test_trunk_update_is_parsed():
|
||||
update = parse_terminal_messages([
|
||||
{"json_type": "trunk_update", "0": {"last_tsbk": 5.0, "rxchan": 851012500}, "nac": 0},
|
||||
{"json_type": "channel_update", "channels": ["0"], "0": {"freq": 1, "tgid": None}},
|
||||
])
|
||||
assert update.trunk == [{"last_tsbk": 5.0, "rxchan": 851012500}]
|
||||
assert len(update.channels) == 1
|
||||
|
||||
|
||||
def test_health_route_returns_checks(client):
|
||||
facts = _facts(now=time.time())
|
||||
with patch("app.internal.health.gather_facts", AsyncMock(return_value=facts)):
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert [c["id"] for c in r.json()["checks"]][:3] == ["password", "c2", "mqtt"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Choosing what to listen to
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_listen_p25_saves_override_and_restarts_op25(client, op25):
|
||||
r = client.post("/api/listen", json={
|
||||
"mode": "p25", "frequencies": [851.0125, 851.5125], "talkgroups": [{"id": 1001, "name": "Fire"}],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = config_manager.load_node_config()
|
||||
assert cfg.override_config.type == "P25"
|
||||
assert cfg.override_config.config["control_channels"] == [851.0125, 851.5125]
|
||||
assert cfg.override_config.config["talkgroups"] == [{"id": 1001, "name": "Fire"}]
|
||||
op25["gen"].assert_awaited_once()
|
||||
op25["start"].assert_awaited_once()
|
||||
op25["checkin"].assert_not_awaited() # MQTT not connected in tests
|
||||
|
||||
|
||||
def test_listen_analog_single_frequency(client, op25):
|
||||
r = client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43], "name": "Fire"})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = config_manager.load_node_config().override_config
|
||||
assert (cfg.type, cfg.name, cfg.config["control_channels"]) == ("NBFM", "Fire", [154.43])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,needle", [
|
||||
({"mode": "p25", "frequencies": []}, "at least one"),
|
||||
({"mode": "p25", "frequencies": [851012500]}, "MHz"),
|
||||
({"mode": "analog", "frequencies": [154.43, 155.1]}, "exactly one"),
|
||||
({"mode": "system", "system_id": "nope"}, "saved list"),
|
||||
])
|
||||
def test_listen_rejects_bad_input_with_a_readable_reason(client, op25, body, needle):
|
||||
r = client.post("/api/listen", json=body)
|
||||
assert r.status_code in (400, 404)
|
||||
assert needle in r.json()["detail"]
|
||||
op25["gen"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_listen_reports_op25_start_failure(client, op25):
|
||||
op25["start"].return_value = "No OP25 config yet — choose a system or frequency first."
|
||||
r = client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43]})
|
||||
assert r.status_code == 502
|
||||
assert "did not start" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_dmr_is_refused_up_front(client, op25):
|
||||
r = client.post("/api/config/override", json={"system_config": {
|
||||
"system_id": "x", "name": "x", "type": "DMR", "config": {"control_channels": [450.1]},
|
||||
}})
|
||||
assert r.status_code == 400 and "DMR" in r.json()["detail"]
|
||||
op25["gen"].assert_not_awaited()
|
||||
|
||||
|
||||
def test_config_system_goes_through_op25_generation(client, op25, tmp_path, monkeypatch):
|
||||
"""It used to dump the raw blob into active.cfg.json — not an OP25 config."""
|
||||
monkeypatch.setattr(settings, "config_path", str(tmp_path))
|
||||
r = client.post("/api/config/system", json={
|
||||
"system_id": "s1", "name": "County", "type": "P25", "config": {"control_channels": [851.0125]},
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
op25["gen"].assert_awaited_once()
|
||||
assert not (tmp_path / "active.cfg.json").exists()
|
||||
cfg = config_manager.load_node_config()
|
||||
assert cfg.configured and cfg.system_config.name == "County"
|
||||
|
||||
|
||||
def test_revert_without_c2_system_stops_op25(client, op25):
|
||||
client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43]})
|
||||
op25["stop"].reset_mock()
|
||||
r = client.post("/api/config/revert")
|
||||
assert r.status_code == 200
|
||||
assert config_manager.load_node_config().override_config is None
|
||||
op25["stop"].assert_awaited_once()
|
||||
|
||||
|
||||
def test_status_exposes_stream_parts_and_source(client, op25):
|
||||
client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43]})
|
||||
with patch("app.routers.api.op25_client.status", AsyncMock(return_value={"status": "running"})):
|
||||
d = client.get("/api/status").json()
|
||||
assert d["icecast_port"] == settings.icecast_port and d["icecast_mount"] == settings.icecast_mount
|
||||
assert d["local_choice"] is True and d["has_c2_system"] is False
|
||||
assert d["system_type"] == "NBFM"
|
||||
|
||||
|
||||
def test_dashboard_page_has_no_dead_api_instruction(client):
|
||||
client.post("/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False)
|
||||
html = client.get("/").text
|
||||
assert "POST /api/config/system" not in html
|
||||
assert "/api/listen" in html and "/api/health" in html
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
node-26#9 / #11 — which SDR does what: secondary priority, per-service pins,
|
||||
and OP25 always opening its dongle by serial.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import NodeConfig, normalize_sdr_pins, normalize_secondary_priority
|
||||
|
||||
DEVS = [
|
||||
{"index": 0, "serial": "69420", "name": "RTL", "duplicate_serial": False},
|
||||
{"index": 1, "serial": "00000001", "name": "RTL", "duplicate_serial": False},
|
||||
]
|
||||
|
||||
|
||||
def _cfg(**kw) -> NodeConfig:
|
||||
return NodeConfig(node_id="n1", node_name="N1", lat=0.0, lon=0.0, **kw)
|
||||
|
||||
|
||||
def test_normalize_priority_keeps_order_drops_unknown_and_duplicates():
|
||||
assert normalize_secondary_priority(["ais", "bogus", "adsb", "ais"]) == ["ais", "adsb"]
|
||||
|
||||
|
||||
def test_legacy_single_mode_migrates_to_priority():
|
||||
assert _cfg(secondary_sdr_mode="adsb").secondary_sdr_priority == ["adsb"]
|
||||
assert _cfg(secondary_sdr_mode="adsb", secondary_sdr_priority=["ais"]).secondary_sdr_priority == ["ais"]
|
||||
|
||||
|
||||
def test_pins_drop_blanks_and_unknown_services():
|
||||
assert normalize_sdr_pins({"op25": "00000001", "adsb": "", "ais": None, "x": "1"}) == {"op25": "00000001"}
|
||||
|
||||
|
||||
def test_two_services_cannot_share_a_dongle():
|
||||
with pytest.raises(ValueError):
|
||||
normalize_sdr_pins({"op25": "69420", "adsb": "69420"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def node(tmp_path):
|
||||
"""Isolated node_config.json + OP25 active.cfg.json, mocked decoders/op25/mqtt."""
|
||||
import app.internal.config_manager as cm
|
||||
from app.internal import sdr_settings as ss
|
||||
op25_cfg = tmp_path / "active.cfg.json"
|
||||
op25_cfg.write_text(json.dumps({"devices": [{"args": "rtl", "name": "sdr"}]}))
|
||||
with patch.object(cm, "_CONFIG_FILE", tmp_path / "node_config.json"), \
|
||||
patch.object(ss, "_OP25_CONFIG", op25_cfg), \
|
||||
patch.object(ss.secondary_sdr_client, "devices", AsyncMock(return_value=DEVS)), \
|
||||
patch.object(ss.secondary_sdr_client, "apply", AsyncMock(return_value=["adsb"])) as apply, \
|
||||
patch("app.internal.op25_client.op25_client.stop", AsyncMock()) as op25_stop, \
|
||||
patch("app.internal.op25_client.op25_client.start", AsyncMock()), \
|
||||
patch("app.internal.mqtt_manager.mqtt_manager.publish_checkin", AsyncMock()), \
|
||||
patch("asyncio.sleep", AsyncMock()):
|
||||
cm.save_node_config(_cfg())
|
||||
yield {"cm": cm, "ss": ss, "apply": apply, "op25_stop": op25_stop,
|
||||
"op25_args": lambda: json.loads(op25_cfg.read_text())["devices"][0]["args"]}
|
||||
|
||||
|
||||
def test_unpinned_op25_is_still_opened_by_serial_of_the_first_dongle(node):
|
||||
assert asyncio.run(node["ss"].pin_op25_device()) == "69420"
|
||||
assert node["op25_args"]() == "rtl=69420"
|
||||
|
||||
|
||||
def test_pinned_op25_opens_its_pinned_dongle(node):
|
||||
node["cm"].save_node_config(_cfg(sdr_pins={"op25": "00000001"}))
|
||||
asyncio.run(node["ss"].pin_op25_device())
|
||||
assert node["op25_args"]() == "rtl=00000001"
|
||||
|
||||
|
||||
def test_shared_serial_leaves_op25_on_plain_rtl(node):
|
||||
dup = [dict(d, serial="00000001") for d in DEVS]
|
||||
with patch.object(node["ss"].secondary_sdr_client, "devices", AsyncMock(return_value=dup)):
|
||||
assert asyncio.run(node["ss"].pin_op25_device()) is None
|
||||
assert node["op25_args"]() == "rtl"
|
||||
|
||||
|
||||
def test_priority_change_never_restarts_op25_and_reserves_its_dongle(node):
|
||||
node["cm"].save_node_config(_cfg(sdr_pins={"op25": "00000001"}))
|
||||
asyncio.run(node["ss"].set_sdr_settings(priority=["adsb", "ais"]))
|
||||
node["op25_stop"].assert_not_awaited()
|
||||
node["apply"].assert_awaited_with(["adsb", "ais"], {}, ["00000001"])
|
||||
|
||||
|
||||
def test_moving_op25_restarts_it_on_the_new_dongle(node):
|
||||
asyncio.run(node["ss"].set_sdr_settings(priority=["adsb"], pins={"op25": "00000001", "adsb": "69420"}))
|
||||
node["op25_stop"].assert_awaited_once()
|
||||
assert node["op25_args"]() == "rtl=00000001"
|
||||
node["apply"].assert_awaited_with(["adsb"], {"adsb": "69420"}, ["00000001"])
|
||||
assert node["cm"].load_node_config().sdr_pins == {"op25": "00000001", "adsb": "69420"}
|
||||
@@ -181,6 +181,13 @@ else
|
||||
warn "no RTL-SDR dongle detected on USB — plug one in before expecting audio"
|
||||
fi
|
||||
|
||||
# The kernel's DVB-TV driver auto-binds RTL2838 dongles. op25 detaches it from
|
||||
# the one it opens, but any other dongle stays claimed and the secondary-sdr
|
||||
# decoder fails with "usb_claim_interface error -6" (node-26#9).
|
||||
echo "blacklist dvb_usb_rtl28xxu" > /etc/modprobe.d/blacklist-rtl-sdr.conf
|
||||
modprobe -r rtl2832_sdr dvb_usb_rtl28xxu 2>/dev/null || true
|
||||
ok "DVB-TV kernel driver blacklisted for RTL-SDR"
|
||||
|
||||
# ── 2. Dependencies ─────────────────────────────────────────────────────────
|
||||
say "Installing dependencies"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -12,7 +12,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# Install system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install git pulseaudio pulseaudio-utils liquidsoap -y
|
||||
apt-get install git pulseaudio pulseaudio-utils liquidsoap usbutils -y
|
||||
|
||||
# Install custom PulseAudio system config (enables anonymous access for edge-node)
|
||||
COPY system.pa /etc/pulse/system.pa
|
||||
|
||||
@@ -52,12 +52,16 @@ class IcecastConfig(BaseModel):
|
||||
class ConfigGenerator(BaseModel):
|
||||
type: DecodeMode
|
||||
systemName: str
|
||||
# P25: control channel list (Hz). NBFM: channels[0] is the one analog
|
||||
# frequency to listen to (Hz).
|
||||
channels: List[Union[int, str]]
|
||||
tags: Optional[List[TalkgroupTag]]
|
||||
whitelist: Optional[List[int]]
|
||||
icecastConfig: Optional[IcecastConfig]
|
||||
tags: Optional[List[TalkgroupTag]] = None
|
||||
whitelist: Optional[List[int]] = None
|
||||
icecastConfig: Optional[IcecastConfig] = None
|
||||
hardware_preset: str = "rtl-sdr-v3"
|
||||
ppm_override: Optional[float] = None # when set, overrides the preset's default PPM
|
||||
# NBFM only: power squelch threshold in dB (op25_nbfm default is -60).
|
||||
nbfm_squelch: Optional[float] = None
|
||||
|
||||
class DemodType(str, Enum):
|
||||
CQPSK = "cqpsk"
|
||||
@@ -69,15 +73,16 @@ class FilterType(str, Enum):
|
||||
|
||||
class ChannelConfig(BaseModel):
|
||||
name: str
|
||||
trunking_sysname: Optional[str]
|
||||
trunking_sysname: Optional[str] = None
|
||||
enable_analog: str
|
||||
meta_stream_name: str
|
||||
demod_type: DemodType
|
||||
filter_type: FilterType
|
||||
device: Optional[str] = "sdr"
|
||||
cqpsk_tracking: Optional[bool] = None
|
||||
frequency: Optional[float] = None
|
||||
nbfmSquelch: Optional[float] = None
|
||||
frequency: Optional[int] = None
|
||||
# Key name op25_nbfm.py actually reads (from_dict(config, 'nbfm_squelch_threshold', -60)).
|
||||
nbfm_squelch_threshold: Optional[float] = None
|
||||
destination: Optional[str] = "udp://127.0.0.1:23456"
|
||||
tracking_threshold: Optional[int] = 120
|
||||
tracking_feedback: Optional[float] = 0.75
|
||||
@@ -93,6 +98,9 @@ class DeviceConfig(BaseModel):
|
||||
gains: Optional[str] = "lna:39"
|
||||
gain_mode: Optional[bool] = False
|
||||
name: Optional[str] = "sdr"
|
||||
# Centre frequency (Hz). Unset for trunked P25, where tk_p25 tunes the
|
||||
# device; required for a fixed analog channel (tunable=False).
|
||||
frequency: Optional[int] = None
|
||||
offset: Optional[int] = 0
|
||||
ppm: Optional[float] = 0.0
|
||||
rate: Optional[int] = 1920000
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from fastapi import HTTPException, APIRouter
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
|
||||
from internal.logger import create_logger
|
||||
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
|
||||
@@ -13,6 +18,16 @@ LOGGER = create_logger(__name__)
|
||||
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
|
||||
OP25_SCRIPT = "run_multi-rx_service.sh"
|
||||
_PGID_FILE = "/tmp/op25.pgid"
|
||||
_ACTIVE_CFG = "/configs/active.cfg.json"
|
||||
|
||||
# Analog: park the dongle's centre this far from the channel so the channel
|
||||
# does not sit on the RTL-SDR's DC spike. Well inside the 1.92 MS/s window.
|
||||
ANALOG_CENTER_OFFSET_HZ = 250_000
|
||||
|
||||
# Last lines multi_rx/liquidsoap printed — the only place the reason OP25
|
||||
# died (SDR busy, bad config, no lock) is visible without SSH + docker logs.
|
||||
_log: deque = deque(maxlen=300)
|
||||
_proc: Optional[subprocess.Popen] = None
|
||||
|
||||
|
||||
def _save_pgid(pgid: int) -> None:
|
||||
@@ -27,15 +42,60 @@ def _read_pgid():
|
||||
return None
|
||||
|
||||
|
||||
def _multi_rx_alive(pgid: int) -> bool:
|
||||
"""True only if a live multi_rx.py process is in OP25's process group.
|
||||
|
||||
node-26#12: killpg(pgid, 0) succeeds while the wrapper shell or
|
||||
liquidsoap outlives a dead multi_rx (and even for a zombie leader), so
|
||||
the old check reported a dead scanner as "running".
|
||||
"""
|
||||
try:
|
||||
pids = [d for d in os.listdir("/proc") if d.isdigit()]
|
||||
except OSError:
|
||||
return False
|
||||
for pid in pids:
|
||||
try:
|
||||
with open(f"/proc/{pid}/stat") as f:
|
||||
fields = f.read().rsplit(")", 1)[1].split()
|
||||
state, pgrp = fields[0], int(fields[2])
|
||||
if pgrp != pgid or state == "Z":
|
||||
continue
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
if b"multi_rx.py" in f.read():
|
||||
return True
|
||||
except (OSError, IndexError, ValueError):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _reap() -> None:
|
||||
if _proc is not None:
|
||||
_proc.poll()
|
||||
|
||||
|
||||
def _is_running() -> bool:
|
||||
pgid = _read_pgid()
|
||||
if pgid is None:
|
||||
return False
|
||||
_reap()
|
||||
return _multi_rx_alive(pgid)
|
||||
|
||||
|
||||
def _kill_group(pgid: int) -> None:
|
||||
try:
|
||||
os.killpg(pgid, 0)
|
||||
return True
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except OSError:
|
||||
return False
|
||||
pass
|
||||
|
||||
|
||||
def _pump(proc: subprocess.Popen) -> None:
|
||||
"""Mirror OP25's output to the container log and keep the tail for /log."""
|
||||
for line in proc.stdout:
|
||||
sys.stdout.write(line)
|
||||
sys.stdout.flush()
|
||||
_log.append(line.rstrip("\n"))
|
||||
rc = proc.wait()
|
||||
_log.append(f"[OP25 exited with code {rc}]")
|
||||
|
||||
|
||||
def create_op25_router():
|
||||
@@ -43,11 +103,28 @@ def create_op25_router():
|
||||
|
||||
@router.post("/start")
|
||||
async def start_op25():
|
||||
global _proc
|
||||
if _is_running():
|
||||
return {"status": "OP25 already running"}
|
||||
if not os.path.exists(_ACTIVE_CFG):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="No OP25 config yet — choose a system or frequency first.",
|
||||
)
|
||||
stale = _read_pgid()
|
||||
if stale is not None:
|
||||
# multi_rx died but liquidsoap/the wrapper may still hold the
|
||||
# Icecast mount — clear the old group before starting a new one.
|
||||
_kill_group(stale)
|
||||
try:
|
||||
proc = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
|
||||
_log.clear()
|
||||
proc = subprocess.Popen(
|
||||
os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||||
)
|
||||
_proc = proc
|
||||
_save_pgid(proc.pid)
|
||||
threading.Thread(target=_pump, args=(proc,), daemon=True).start()
|
||||
LOGGER.debug(proc)
|
||||
return {"status": "OP25 started"}
|
||||
except Exception as e:
|
||||
@@ -56,10 +133,10 @@ def create_op25_router():
|
||||
@router.post("/stop")
|
||||
async def stop_op25():
|
||||
pgid = _read_pgid()
|
||||
if pgid is None or not _is_running():
|
||||
if pgid is None:
|
||||
return {"status": "OP25 is not running"}
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
_kill_group(pgid)
|
||||
os.remove(_PGID_FILE)
|
||||
return {"status": "OP25 stopped"}
|
||||
except Exception as e:
|
||||
@@ -67,7 +144,36 @@ def create_op25_router():
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
return {"status": "running" if _is_running() else "stopped"}
|
||||
if _is_running():
|
||||
return {"status": "running"}
|
||||
# Started (pgid file present, never stopped via the API) but multi_rx
|
||||
# is gone: it crashed. The tail of /log says why.
|
||||
if _read_pgid() is not None:
|
||||
return {"status": "crashed", "configured": os.path.exists(_ACTIVE_CFG)}
|
||||
return {"status": "stopped", "configured": os.path.exists(_ACTIVE_CFG)}
|
||||
|
||||
@router.get("/log")
|
||||
async def get_log(lines: int = 80):
|
||||
lines = max(1, min(lines, _log.maxlen))
|
||||
return {"lines": list(_log)[-lines:]}
|
||||
|
||||
@router.get("/devices")
|
||||
async def list_sdr_devices():
|
||||
"""Enumerate connected SDR-looking USB devices via lsusb.
|
||||
|
||||
Same match heuristic as install.sh's one-shot host check — good enough
|
||||
to answer "is a second SDR plugged in", not a serial-level device
|
||||
binding (op25's DeviceConfig.args has no serial concept yet either).
|
||||
"""
|
||||
devices = []
|
||||
try:
|
||||
out = subprocess.run(["lsusb"], capture_output=True, text=True, timeout=5).stdout
|
||||
for line in out.splitlines():
|
||||
if re.search(r"rtl2838|realtek.*283[28]|sdr", line, re.IGNORECASE):
|
||||
devices.append(line.strip())
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"SDR device enumeration failed: {e}")
|
||||
return {"count": len(devices), "devices": devices}
|
||||
|
||||
@router.post("/generate-config")
|
||||
async def generate_config(generator: ConfigGenerator):
|
||||
@@ -85,7 +191,7 @@ def create_op25_router():
|
||||
meta_stream_name="stream_0"
|
||||
)]
|
||||
devices = [DeviceConfig(gains=preset["gains"], ppm=ppm)]
|
||||
save_talkgroup_tags(generator.tags)
|
||||
save_talkgroup_tags(generator.tags or [])
|
||||
save_whitelist(generator.whitelist)
|
||||
has_talkgroups = bool(generator.whitelist)
|
||||
trunking = TrunkingConfig(
|
||||
@@ -123,30 +229,48 @@ def create_op25_router():
|
||||
"terminal": terminal.dict()
|
||||
}
|
||||
|
||||
|
||||
elif generator.type == DecodeMode.ANALOG:
|
||||
generator = generator.config
|
||||
if not generator.channels:
|
||||
raise HTTPException(status_code=400, detail="Analog needs a frequency.")
|
||||
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
|
||||
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
|
||||
freq = int(float(generator.channels[0]))
|
||||
# One fixed conventional channel: no trunking section, so
|
||||
# multi_rx adds its default tk_p25 with no chans and reports
|
||||
# the channel as `conventional` on the HTTP terminal.
|
||||
channels = [ChannelConfig(
|
||||
channelName=generator.systemName,
|
||||
enableAnalog="on",
|
||||
demodType="fsk4",
|
||||
frequency=generator.frequency,
|
||||
filterType="widepulse",
|
||||
nbfmSquelch=generator.nbfmSquelch
|
||||
name=generator.systemName,
|
||||
trunking_sysname="",
|
||||
enable_analog="on",
|
||||
demod_type="fsk4",
|
||||
filter_type="rc",
|
||||
frequency=freq,
|
||||
meta_stream_name="",
|
||||
nbfm_squelch_threshold=generator.nbfm_squelch,
|
||||
)]
|
||||
devices = [DeviceConfig(gain="LNA:32")]
|
||||
devices = [DeviceConfig(
|
||||
gains=preset["gains"], ppm=ppm, tunable=False,
|
||||
frequency=freq + ANALOG_CENTER_OFFSET_HZ,
|
||||
)]
|
||||
if generator.icecastConfig:
|
||||
generate_liquid_script(generator.icecastConfig)
|
||||
|
||||
config_dict = {
|
||||
"channels": [channel.dict() for channel in channels],
|
||||
"devices": [device.dict() for device in devices]
|
||||
"devices": [device.dict() for device in devices],
|
||||
"terminal": TerminalConfig().dict(),
|
||||
}
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
|
||||
raise HTTPException(status_code=400, detail="Unsupported system type. Must be 'P25' or 'NBFM' (DMR is not implemented).")
|
||||
|
||||
with open('/configs/active.cfg.json', 'w') as f:
|
||||
with open(_ACTIVE_CFG, 'w') as f:
|
||||
json.dump(del_none_in_dict(config_dict), f, indent=2)
|
||||
|
||||
return {"message": "Config exported to '/configs/active.cfg.json'"}
|
||||
return {"message": f"Config exported to '{_ACTIVE_CFG}'"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -20,3 +20,12 @@ LIQ_PID=$! # Store the PID of the op25.liq process
|
||||
# The trap command ensures that SIGTERM is passed to the background jobs.
|
||||
trap "kill $MULTI_RX_PID $LIQ_PID" SIGTERM SIGINT
|
||||
wait $MULTI_RX_PID
|
||||
RC=$?
|
||||
|
||||
# multi_rx is gone (crashed, or SDR could not be opened). Take liquidsoap down
|
||||
# with it so nothing in this process group outlives the decoder — a leftover
|
||||
# liquidsoap kept /op25/status reporting "running" (node-26#12) and held the
|
||||
# Icecast mount against the next start.
|
||||
echo "multi_rx.py exited (code $RC) — stopping liquidsoap."
|
||||
kill $LIQ_PID 2>/dev/null
|
||||
exit $RC
|
||||
@@ -0,0 +1,54 @@
|
||||
# Secondary-SDR Container — node-26#9
|
||||
#
|
||||
# Claims the node's SECOND physical SDR (the first is always op25's). Mode is
|
||||
# chosen at runtime via the control API, not baked in: dump1090 for ADS-B,
|
||||
# AIS-catcher for AIS (op25_2 mode is not handled here yet — see node-26#9).
|
||||
#
|
||||
# Device claiming is by RTL-SDR index, not serial (op25's DeviceConfig.args
|
||||
# has no serial concept either — see op25-container/app/models.py). Index 0
|
||||
# is reserved for op25; this container always addresses index 1. That's a
|
||||
# real limitation once serial-stable device binding matters (hot-unplug /
|
||||
# replug can swap indices) — tracked in node-26#9, not fixed here.
|
||||
#
|
||||
# UNVERIFIED: this image has not been built or run against real hardware in
|
||||
# this session (sandboxed authoring machine, no docker). dump1090 and
|
||||
# AIS-catcher's exact CLI flags below are believed correct from their
|
||||
# published docs but not confirmed against a real capture — the CTO/QA
|
||||
# review before this ships to a real node should build and smoke-test it.
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git build-essential cmake pkg-config \
|
||||
librtlsdr-dev libusb-1.0-0-dev libssl-dev zlib1g-dev libzstd-dev libncurses-dev usbutils
|
||||
|
||||
# readsb (wiedehopf) — ADS-B decoder. antirez/dump1090 was used here first but
|
||||
# has no --write-json at all (it only serves /data.json over --net), so the
|
||||
# decoder exited on an unknown flag. readsb writes the dump1090-fa style
|
||||
# aircraft.json that _read_adsb_snapshot() parses.
|
||||
RUN git clone --depth 1 https://github.com/wiedehopf/readsb /opt/readsb && \
|
||||
cd /opt/readsb && make RTLSDR=yes
|
||||
|
||||
# AIS-catcher — AIS decoder.
|
||||
RUN git clone https://github.com/jvde-github/AIS-catcher /opt/AIS-catcher && \
|
||||
cd /opt/AIS-catcher && mkdir build && cd build && cmake .. && make
|
||||
|
||||
EXPOSE 8002
|
||||
|
||||
VOLUME ["/configs"]
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./app /app
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,20 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Same rationale as op25-container's OP25_DEBUG_EXPOSE: both containers
|
||||
# share the host network namespace (network_mode: host), so edge-node
|
||||
# reaches this control API over localhost regardless of this flag. False
|
||||
# (default) binds 127.0.0.1; true exposes unauthenticated start/stop to
|
||||
# the node's LAN and should only ever be set for local development.
|
||||
secondary_sdr_debug_expose: bool = False
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def bind_host() -> str:
|
||||
return "0.0.0.0" if settings.secondary_sdr_debug_expose else "127.0.0.1"
|
||||
@@ -0,0 +1,353 @@
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from internal.logger import create_logger
|
||||
|
||||
LOGGER = create_logger(__name__)
|
||||
|
||||
# One decoder per mode, each on its own SDR (node-26#9). The node's secondary
|
||||
# SDR *priority* (e.g. ["adsb", "ais"]) is applied by apply(): decoders start
|
||||
# down the list until no free dongle is left, so every SDR the node has gets
|
||||
# used and the ones beyond the list's reach simply aren't started. OP25 always
|
||||
# keeps its own dongle — it is started first and never part of this list.
|
||||
MODES = ("adsb", "ais")
|
||||
|
||||
# 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
|
||||
# dongle another process has claimed, and the decoders exit within ~50ms when
|
||||
# that happens, so _start_one() tries each index and keeps the first that
|
||||
# stays up. op25 is never disturbed: a failed claim doesn't touch its dongle.
|
||||
MAX_SDR_INDEX = 4
|
||||
_STARTUP_GRACE_S = 2.0
|
||||
|
||||
_STATE_DIR = Path("/tmp/secondary_sdr")
|
||||
ADSB_JSON_DIR = Path("/tmp/adsb")
|
||||
|
||||
# Live decoder handles. poll() is the only reliable liveness check: a decoder
|
||||
# that dies on startup stays an unreaped zombie, and killpg(pgid, 0) still
|
||||
# succeeds on a zombie.
|
||||
_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 _pgid_file(mode: str) -> Path:
|
||||
return _STATE_DIR / f"{mode}.pgid"
|
||||
|
||||
|
||||
def _reap_orphans() -> None:
|
||||
"""Kill decoders left behind by a previous API process (uvicorn --reload
|
||||
restarts this process, but decoders run in their own session and would
|
||||
otherwise keep holding their SDRs)."""
|
||||
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)
|
||||
|
||||
|
||||
_reap_orphans()
|
||||
|
||||
|
||||
def _adsb_command(index: int) -> List[str]:
|
||||
ADSB_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return [
|
||||
"/opt/readsb/readsb",
|
||||
"--net",
|
||||
"--device-type", "rtlsdr",
|
||||
"--device", str(index),
|
||||
"--write-json", str(ADSB_JSON_DIR),
|
||||
"--write-json-every", "1",
|
||||
]
|
||||
|
||||
|
||||
def _ais_command(index: int) -> List[str]:
|
||||
return [
|
||||
"/opt/AIS-catcher/build/AIS-catcher",
|
||||
f"-d:{index}", # "-d <x>" would select by serial, not index
|
||||
"-o", "5", # JSON Full: decoded fields (4 = sparse, "JSON" is rejected)
|
||||
]
|
||||
|
||||
|
||||
_COMMANDS = {"adsb": _adsb_command, "ais": _ais_command}
|
||||
|
||||
|
||||
def _ais_reader(proc: subprocess.Popen) -> None:
|
||||
"""
|
||||
Consume AIS-catcher's stdout, one JSON message per line, and keep the
|
||||
latest report per mmsi. Field names (mmsi/lat/lon/speed/course or
|
||||
heading/shipname or name) are believed correct from AIS-catcher's
|
||||
published JSON output docs but UNVERIFIED against a real capture in
|
||||
this session — same caveat as dump1090's aircraft.json mapping.
|
||||
Malformed/partial lines (e.g. static-data-only messages with no
|
||||
position) are skipped rather than raising, since dropping one line must
|
||||
never kill the reader thread.
|
||||
"""
|
||||
if not proc.stdout:
|
||||
return
|
||||
for line in proc.stdout:
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
mmsi = msg.get("mmsi")
|
||||
if not mmsi:
|
||||
continue
|
||||
# AIS-catcher emits separate message TYPES per mmsi — static data
|
||||
# (name, no position) and position reports (lat/lon, no name) arrive
|
||||
# as distinct lines. Merge onto the existing entry, only overwriting
|
||||
# a field the new message actually carries, so a position-only
|
||||
# report doesn't blank out a name learned from an earlier message.
|
||||
name = (msg.get("shipname") or msg.get("name") or "").strip() or None
|
||||
heading = msg.get("heading") if msg.get("heading") is not None else msg.get("course")
|
||||
updates = {
|
||||
"mmsi": str(mmsi),
|
||||
"name": name,
|
||||
"lat": msg.get("lat"),
|
||||
"lon": msg.get("lon"),
|
||||
"speed_kt": msg.get("speed"),
|
||||
"heading_deg": heading,
|
||||
}
|
||||
with _ais_lock:
|
||||
existing = _ais_vessels.get(str(mmsi), {})
|
||||
for key, value in updates.items():
|
||||
if value is not None:
|
||||
existing[key] = value
|
||||
_ais_vessels[str(mmsi)] = existing
|
||||
|
||||
|
||||
def is_running(mode: str) -> bool:
|
||||
proc = _procs.get(mode)
|
||||
return proc is not None and proc.poll() is None
|
||||
|
||||
|
||||
def running() -> List[str]:
|
||||
return [m for m in MODES if is_running(m)]
|
||||
|
||||
|
||||
def _start_one(mode: str, candidates: Optional[List[int]] = None) -> bool:
|
||||
"""Start one decoder on the first free SDR among `candidates` (default:
|
||||
every index). 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:
|
||||
_ais_vessels.clear()
|
||||
|
||||
needs_stdout = mode == "ais"
|
||||
for index in candidates if candidates is not None else range(MAX_SDR_INDEX):
|
||||
if index in {_indices[m] for m in running() if m in _indices}:
|
||||
continue
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
_COMMANDS[mode](index),
|
||||
preexec_fn=os.setsid,
|
||||
stdout=subprocess.PIPE if needs_stdout else None,
|
||||
text=True if needs_stdout else None,
|
||||
bufsize=1 if needs_stdout else -1,
|
||||
)
|
||||
except Exception as e:
|
||||
LOGGER.error(f"Failed to start secondary SDR decoder mode={mode!r}: {e}")
|
||||
return False
|
||||
try:
|
||||
proc.wait(timeout=_STARTUP_GRACE_S)
|
||||
LOGGER.info(f"Secondary SDR decoder mode={mode!r} could not use SDR index {index}, trying next")
|
||||
continue
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
if needs_stdout:
|
||||
threading.Thread(target=_ais_reader, args=(proc,), daemon=True).start()
|
||||
_procs[mode] = proc
|
||||
_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}")
|
||||
return True
|
||||
|
||||
LOGGER.info(f"Secondary SDR decoder mode={mode!r}: no free SDR left")
|
||||
return False
|
||||
|
||||
|
||||
def _stop_one(mode: str) -> None:
|
||||
proc = _procs.pop(mode, None)
|
||||
_indices.pop(mode, None)
|
||||
if proc is not None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
_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 _candidates(mode: str, pins: Dict[str, str], reserved: List[str], devs: List[Dict[str, Any]]) -> List[int]:
|
||||
"""SDR indices `mode` may use. A pinned mode gets exactly its dongle; an
|
||||
unpinned one gets any dongle that isn't op25's (reserved) or pinned to
|
||||
another service. Without enumeration, fall back to probing every index."""
|
||||
if not devs:
|
||||
return [] if pins.get(mode) else list(range(MAX_SDR_INDEX))
|
||||
if pins.get(mode):
|
||||
idx = _index_of(pins[mode], devs)
|
||||
return [] if idx is None else [idx]
|
||||
taken = set(reserved) | {s for m, s in pins.items() if m != mode and s}
|
||||
return [d["index"] for d in devs if d["serial"] not in taken]
|
||||
|
||||
|
||||
def apply(priority: List[str], pins: Optional[Dict[str, str]] = None,
|
||||
reserved: Optional[List[str]] = None) -> List[str]:
|
||||
"""Run decoders in priority order until SDRs run out; stop everything else.
|
||||
|
||||
`pins` maps a mode to the serial of the dongle carrying its antenna;
|
||||
`reserved` lists serials no decoder may touch (op25's). Pins only bind
|
||||
enabled modes — a disabled service's dongle is free for the others.
|
||||
Unchanged when the right decoders already run on allowed dongles, so
|
||||
re-applying the same settings is a no-op rather than a restart.
|
||||
"""
|
||||
for m in priority:
|
||||
if m not in _COMMANDS:
|
||||
raise ValueError(f"Unknown secondary SDR mode: {m!r}")
|
||||
pins = {m: s for m, s in (pins or {}).items() if m in priority and s}
|
||||
reserved = [s for s in (reserved or []) if s]
|
||||
devs = devices()
|
||||
with _lock:
|
||||
for m in running():
|
||||
if m not in priority or _indices.get(m) not in _candidates(m, pins, reserved, devs):
|
||||
_stop_one(m)
|
||||
for i, m in enumerate(priority):
|
||||
if m in running():
|
||||
continue
|
||||
cands = _candidates(m, pins, reserved, devs)
|
||||
if _start_one(m, cands):
|
||||
continue
|
||||
# Out of free dongles: take one from the lowest-priority decoder
|
||||
# holding a dongle this mode may use, which then gets its own turn.
|
||||
lower = [x for x in priority[i + 1:] if x in running() and _indices.get(x) in cands]
|
||||
if lower:
|
||||
_stop_one(lower[-1])
|
||||
_start_one(m, cands)
|
||||
return running()
|
||||
|
||||
|
||||
def devices() -> List[Dict[str, Any]]:
|
||||
"""Every RTL-SDR on the node with its USB serial — readable even while a
|
||||
dongle is claimed (op25's included), since it doesn't open the device.
|
||||
Cheap dongles often ship with the same serial (00000001); those are
|
||||
flagged, because pinning a service to a shared serial is ambiguous."""
|
||||
try:
|
||||
lib = ctypes.CDLL(ctypes.util.find_library("rtlsdr") or "librtlsdr.so.0")
|
||||
lib.rtlsdr_get_device_name.restype = ctypes.c_char_p
|
||||
out = []
|
||||
for i in range(lib.rtlsdr_get_device_count()):
|
||||
manufact, product, serial = (ctypes.create_string_buffer(256) for _ in range(3))
|
||||
lib.rtlsdr_get_device_usb_strings(i, manufact, product, serial)
|
||||
out.append({
|
||||
"index": i,
|
||||
"serial": serial.value.decode(errors="replace") or None,
|
||||
"name": (lib.rtlsdr_get_device_name(i) or b"").decode(errors="replace"),
|
||||
})
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"SDR enumeration failed: {e}")
|
||||
return []
|
||||
serials = [d["serial"] for d in out]
|
||||
for d in out:
|
||||
d["duplicate_serial"] = d["serial"] is not None and serials.count(d["serial"]) > 1
|
||||
return out
|
||||
|
||||
|
||||
def _index_of(serial: str, devs: List[Dict[str, Any]]) -> Optional[int]:
|
||||
"""Index of a uniquely-identified serial; None if absent or ambiguous."""
|
||||
matches = [d["index"] for d in devs if d["serial"] == serial]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def status() -> Dict[str, Any]:
|
||||
devs = devices()
|
||||
by_index = {d["index"]: d["serial"] for d in devs}
|
||||
return {
|
||||
"running": [
|
||||
{"mode": m, "sdr_index": _indices.get(m), "serial": by_index.get(_indices.get(m))} for m in running()
|
||||
],
|
||||
"sdr_count": len(devs) if devs else None,
|
||||
"devices": devs,
|
||||
}
|
||||
|
||||
|
||||
def _altitude(a: Dict[str, Any]) -> Optional[int]:
|
||||
alt = a.get("alt_baro", a.get("altitude"))
|
||||
if alt == "ground":
|
||||
return 0
|
||||
return alt if isinstance(alt, (int, float)) else None
|
||||
|
||||
|
||||
def _read_adsb_snapshot() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Map readsb's aircraft.json (--write-json output) to the server's
|
||||
telemetry schema. readsb uses the dump1090-fa field names: alt_baro (int,
|
||||
or the string "ground"), gs, track. Older dump1090 forks used
|
||||
altitude/speed, kept as a fallback.
|
||||
"""
|
||||
path = ADSB_JSON_DIR / "aircraft.json"
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
out = []
|
||||
for a in raw.get("aircraft", []):
|
||||
icao = a.get("hex")
|
||||
if not icao:
|
||||
continue
|
||||
out.append({
|
||||
"icao": icao.upper(),
|
||||
"callsign": (a.get("flight") or "").strip() or None,
|
||||
"lat": a.get("lat"),
|
||||
"lon": a.get("lon"),
|
||||
"altitude_ft": _altitude(a),
|
||||
"ground_speed_kt": a.get("gs", a.get("speed")),
|
||||
"track_deg": a.get("track"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def data() -> Dict[str, Any]:
|
||||
with _ais_lock:
|
||||
vessels = list(_ais_vessels.values()) if is_running("ais") else []
|
||||
return {
|
||||
"running": running(),
|
||||
"aircraft": _read_adsb_snapshot() if is_running("adsb") else [],
|
||||
"vessels": vessels,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
||||
def create_logger(name, level=logging.DEBUG, max_bytes=10485760, backup_count=2):
|
||||
debug_log_file = "./secondary-sdr.debug.log"
|
||||
info_log_file = "./secondary-sdr.log"
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
if not logger.hasHandlers():
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(level)
|
||||
|
||||
debug_file_handler = RotatingFileHandler(debug_log_file, maxBytes=max_bytes, backupCount=backup_count)
|
||||
debug_file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
info_file_handler = RotatingFileHandler(info_log_file, maxBytes=max_bytes, backupCount=backup_count)
|
||||
info_file_handler.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(formatter)
|
||||
debug_file_handler.setFormatter(formatter)
|
||||
info_file_handler.setFormatter(formatter)
|
||||
|
||||
logger.addHandler(console_handler)
|
||||
logger.addHandler(debug_file_handler)
|
||||
logger.addHandler(info_file_handler)
|
||||
|
||||
return logger
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import FastAPI
|
||||
import routers.secondary_controller as secondary_controller
|
||||
from config import bind_host
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(secondary_controller.create_secondary_router(), prefix="/secondary")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("main:app", host=bind_host(), port=8002, reload=True)
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from internal import decoder_control
|
||||
from internal.logger import create_logger
|
||||
|
||||
LOGGER = create_logger(__name__)
|
||||
|
||||
|
||||
class StartBody(BaseModel):
|
||||
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"]
|
||||
pins: Dict[str, str] = {} # mode -> serial of the dongle with its antenna
|
||||
reserved: List[str] = [] # serials no decoder may touch (op25's)
|
||||
|
||||
|
||||
def create_secondary_router():
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply(body: ApplyBody):
|
||||
try:
|
||||
live = decoder_control.apply(body.priority, body.pins, body.reserved)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"running": live}
|
||||
|
||||
@router.post("/start")
|
||||
async def start(body: StartBody):
|
||||
try:
|
||||
ok = decoder_control.start(body.mode)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=409, detail="No free SDR for this decoder")
|
||||
return {"status": f"secondary SDR started ({body.mode})"}
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop(body: Optional[StopBody] = None):
|
||||
decoder_control.stop(body.mode if body else None)
|
||||
return {"status": "secondary SDR stopped"}
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
return decoder_control.status()
|
||||
|
||||
@router.get("/devices")
|
||||
async def get_devices():
|
||||
return {"devices": decoder_control.devices()}
|
||||
|
||||
@router.get("/data")
|
||||
async def get_data():
|
||||
return decoder_control.data()
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
mkdir -p /tmp/adsb
|
||||
exec "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
uvicorn
|
||||
fastapi
|
||||
pydantic-settings
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
node-26#11 — which dongle each secondary decoder may use.
|
||||
|
||||
Run from secondary-sdr-container/: PYTHONPATH=app python -m pytest -q tests
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from internal import decoder_control as dc
|
||||
|
||||
# radio-box's real pair: op25's dongle and the one with the 1090 antenna.
|
||||
DEVS = [
|
||||
{"index": 0, "serial": "69420", "name": "RTL", "duplicate_serial": False},
|
||||
{"index": 1, "serial": "00000001", "name": "RTL", "duplicate_serial": False},
|
||||
]
|
||||
|
||||
|
||||
def test_unpinned_mode_never_gets_op25s_dongle():
|
||||
assert dc._candidates("adsb", {}, ["00000001"], DEVS) == [0]
|
||||
|
||||
|
||||
def test_pinned_mode_gets_exactly_its_dongle():
|
||||
assert dc._candidates("ais", {"ais": "69420"}, ["00000001"], DEVS) == [0]
|
||||
|
||||
|
||||
def test_unpinned_mode_skips_a_dongle_pinned_to_another_service():
|
||||
assert dc._candidates("ais", {"adsb": "69420"}, ["00000001"], DEVS) == []
|
||||
|
||||
|
||||
def test_missing_or_ambiguous_pin_gets_nothing():
|
||||
assert dc._candidates("adsb", {"adsb": "nope"}, [], DEVS) == []
|
||||
dup = [dict(d, serial="00000001") for d in DEVS]
|
||||
assert dc._candidates("adsb", {"adsb": "00000001"}, [], dup) == []
|
||||
|
||||
|
||||
class FakeDecoders:
|
||||
"""Stands in for real processes: one decoder per free index."""
|
||||
|
||||
def __init__(self):
|
||||
self.live = {}
|
||||
|
||||
def start(self, mode, candidates=None):
|
||||
free = [i for i in candidates if i not in self.live.values()]
|
||||
if not free:
|
||||
return False
|
||||
self.live[mode] = free[0]
|
||||
return True
|
||||
|
||||
def stop(self, mode):
|
||||
self.live.pop(mode, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake():
|
||||
f = FakeDecoders()
|
||||
with patch.object(dc, "devices", return_value=DEVS), \
|
||||
patch.object(dc, "_start_one", side_effect=f.start), \
|
||||
patch.object(dc, "_stop_one", side_effect=f.stop), \
|
||||
patch.object(dc, "running", side_effect=lambda: [m for m in dc.MODES if m in f.live]), \
|
||||
patch.dict(dc._indices, clear=True):
|
||||
dc._indices.update(f.live)
|
||||
yield f
|
||||
|
||||
|
||||
def _apply(fake, priority, pins=None):
|
||||
dc.apply(priority, pins, ["00000001"])
|
||||
dc._indices.clear()
|
||||
dc._indices.update(fake.live)
|
||||
return fake.live
|
||||
|
||||
|
||||
def test_one_spare_goes_to_the_top_pick(fake):
|
||||
assert _apply(fake, ["ais", "adsb"]) == {"ais": 0}
|
||||
|
||||
|
||||
def test_reordering_hands_the_spare_to_the_new_top_pick(fake):
|
||||
_apply(fake, ["adsb", "ais"])
|
||||
assert _apply(fake, ["ais", "adsb"]) == {"ais": 0}
|
||||
|
||||
|
||||
def test_pinned_lower_priority_still_runs_when_top_pick_has_no_dongle(fake):
|
||||
# AIS ranked first but its only candidate is ADS-B's pinned antenna dongle.
|
||||
assert _apply(fake, ["ais", "adsb"], {"adsb": "69420"}) == {"adsb": 0}
|
||||
Reference in New Issue
Block a user