Compare commits

...
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 797e14481b Scanner manual entry uses validated /api/listen; drop Hold mock; flag analog as live-only (node-26#20)
CI / lint (push) Successful in 6s
CI / lint (pull_request) Successful in 6s
CI / test (push) Successful in 41s
CI / test (pull_request) Successful in 39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:22:12 -04:00
Logan CusanoandClaude Opus 5.5 10f7db0a2c Local dashboard: plain-language status checklist and C2-free listening
- /api/health: ordered checklist (password, C2 approval, MQTT, SDR, source,
  OP25, decoding, audio, Icecast) with what's wrong and what to do; pure
  build_checks() so every state (incl. Toughny's unapproved node) is tested
- /api/listen: pick P25 control channels / one analog freq / a cached C2
  system, validated in MHz, stored as a local override; OP25 restarted with
  the start failure reason surfaced
- /api/config/system no longer writes a raw blob OP25 can't read
- OP25 resumes after reboot on a locally chosen system, not only C2-assigned
- op25-container: analog (NBFM) config generation fixed (AttributeError +
  wrong field names), /op25/log tail, start refuses without a config,
  status detects a dead multi_rx ("crashed") -- node-26#12
- index.html rebuilt on UI_REDESIGN tokens; scanner drops DMR, shows System key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:20:11 -04:00
Logan CusanoandClaude Opus 5.5 6bf2c9dd93 Merge feat/sdr-pins: pin each SDR service to a dongle by serial; OP25 always by serial (closes #11)
CI / lint (push) Successful in 10s
CI / test (push) Successful in 51s
Build edge-node / build (push) Successful in 58s
Build secondary-sdr / build (push) Successful in 2m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:41:28 -04:00
Logan CusanoandClaude Opus 5.5 575a99feb5 checkin: report an unreachable secondary-sdr as null, never keep stale 'Running'
QA blocker: when the secondary-sdr container was down, the checkin
omitted secondary_sdr_running / sdr_devices / op25_sdr_serial, and C2
only overwrites keys that are present, so the dashboard kept the last
'Running' forever. Send explicit nulls. The local card also says 'not
reported' rather than 'not plugged in' for a pin it can't check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:41:27 -04:00
Logan CusanoandClaude Opus 5.5 9b50ba8114 Pin each SDR service to a dongle by serial; OP25 always opens its SDR by serial
CI / lint (push) Successful in 6s
CI / test (push) Successful in 41s
Fixes node-26#11. OP25's generated config said "rtl" (= whichever dongle
enumerates first), so on 2-SDR nodes a decoder could take OP25's dongle
and stop recording. Now:

- sdr_pins: {op25|adsb|ais: serial}, absent = automatic. Every OP25
  config generation (op25_client.generate_config) rewrites the device to
  rtl=<serial>: the pin, else the first dongle's serial, which is what
  "rtl" always opened. Left as "rtl" only for unknown/shared serials.
- secondary-sdr: /secondary/devices lists dongles + serials via librtlsdr
  (works while claimed). apply(priority, pins, reserved) never touches
  OP25's dongle, gives a pinned service only its own dongle, lets a
  higher-priority service take a spare from a lower one, and still runs a
  pinned lower-priority service when the top pick has no dongle.
- sdr_settings.py replaces secondary_priority.py: one apply path for the
  local dashboard, the new set_sdr_config C2 command (set_secondary_priority
  kept as an alias) and config pushes. OP25 restarts only when its own
  dongle changes. Checkin reports sdr_devices, sdr_pins, op25_sdr_serial.
- Local dashboard: 'SDRs' card with an OP25 SDR dropdown and a per-service
  dongle dropdown, duplicate-serial and double-pin warnings.

Verified: edge-node pytest 194 passed; secondary-sdr tests 7 passed;
flake8 clean; page JS passes node --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:35:22 -04:00
Logan CusanoandClaude Opus 5.5 3ecc7eff1a checkin: take sdr_count from secondary-sdr first; op25's 0 is never real
CI / lint (push) Successful in 7s
Build edge-node / build (push) Successful in 44s
CI / test (push) Successful in 48s
op25's :stable image has no lsusb, and /op25/devices answers count 0
instead of unknown, so the dashboard said radio-box 'reports 0 SDRs'
with 2 plugged in. Prefer the secondary-sdr container's lsusb count;
treat 0 from op25 as unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:23:30 -04:00
Logan CusanoandClaude Opus 5.5 358766d88b Merge feat/secondary-sdr-priority: every spare SDR runs the next decoder in priority order
CI / lint (push) Successful in 11s
CI / test (push) Successful in 55s
Build edge-node / build (push) Successful in 1m12s
Build secondary-sdr / build (push) Successful in 3m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:14:53 -04:00
Logan CusanoandClaude Opus 5.5 c82fc61910 Local Secondary SDRs card: 'Unsaved' outranks 'Running'; unknown when unreachable
QA blocker: a reordered but unsaved list still showed the old order's
decoder as 'Running'. Also shows 'Unknown' rather than 'Waiting for SDR'
when the secondary-sdr service doesn't answer (server-26#187).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:14:49 -04:00
Logan CusanoandClaude Opus 5.5 9b6c64fbbf Secondary SDR priority: every spare SDR runs the next decoder in an ordered list
CI / lint (push) Successful in 6s
CI / test (push) Successful in 39s
Replaces the single secondary_sdr_mode with secondary_sdr_priority, e.g.
["adsb", "ais"]. OP25 always keeps its own dongle; the secondary-sdr
container starts decoders top-down until it runs out of free SDRs, so a
3-SDR node runs ADS-B and AIS at once and a 2-SDR node runs the top pick.

- secondary-sdr: one decoder per mode, POST /secondary/apply(priority)
  (no-op when the right prefix is already running), orphaned decoders
  from a uvicorn reload are reaped on start, /status reports sdr_count
  via lsusb (op25's :stable image has none).
- edge-node: one apply path (set_secondary_priority) for the local
  dashboard, a new C2 'set_secondary_priority' MQTT command, and config
  pushes; it never restarts op25. Legacy mode migrates on load. Checkin
  reports priority, what's running, and sdr_count. Uplink forwards
  aircraft and vessels whenever either is present.
- Local dashboard: 'Secondary SDRs' card to enable/reorder/save.

Verified: edge-node pytest 190 passed, flake8 clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:08:07 -04:00
Logan CusanoandClaude Opus 5.5 1c74edffea secondary-sdr: AIS-catcher output flag is -o 5, not -o JSON
CI / lint (push) Successful in 7s
CI / test (push) Successful in 48s
Build secondary-sdr / build (push) Successful in 47m56s
AIS-catcher rejects '-o JSON' (Unknown message format) and exited on
every index. -o 5 is JSON Full, whose field names the reader already
expects. Verified on radio-box: 16 AIS transmitters (Hudson AtoN buoys)
decoded on a 9cm whip. (node-26#9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:32:23 -04:00
Logan CusanoandClaude Opus 5.5 c0a05a8ad6 Merge feat/second-sdr-adsb-ais: second SDR (ADS-B verified on hardware, AIS untested) (node-26#9)
CI / lint (push) Successful in 7s
Build op25 / build (push) Failing after 31s
CI / test (push) Successful in 48s
Build edge-node / build (push) Successful in 8m40s
Build secondary-sdr / build (push) Successful in 47m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:17:00 -04:00
Logan CusanoandClaude Opus 5.5 52d11c1546 ci: build and publish the secondary-sdr image
Without it, compose references secondary-sdr:latest that no registry
has, and 'make pull' on every node fails once this branch lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:16:58 -04:00
Logan CusanoandClaude Opus 5.5 b54624e176 secondary-sdr: fix ADS-B on real hardware (tested on radio-box)
CI / lint (push) Successful in 6s
CI / lint (pull_request) Successful in 7s
CI / test (push) Successful in 39s
CI / test (pull_request) Successful in 40s
First hardware test of node-26#9/#10 found three blockers:
- antirez/dump1090 has no --write-json, so ADS-B mode exited on start.
  Swapped to wiedehopf/readsb; map alt_baro/gs (old names as fallback).
- op25 is not always on RTL-SDR index 0 (radio-box: op25 on 1, 0 free).
  start() now tries each index and keeps the first decoder that stays up.
  AIS-catcher index flag fixed to -d:N ("-d N" selects by serial).
- status() reported "running" for a decoder that died on startup: the
  zombie still answered killpg(pgid, 0). Liveness now via Popen.poll().

install.sh blacklists dvb_usb_rtl28xxu, which claimed the second dongle.

Verified on radio-box: 831 msgs/min, 6 aircraft (3 with position) on a
9cm whip; op25 unaffected. AIS still untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:34:18 -04:00
Logan CusanoandClaude Sonnet 5 52f31bbcc0 Wire AIS end to end: AIS-catcher support in secondary-sdr container
CI / lint (push) Successful in 5s
CI / lint (pull_request) Successful in 5s
CI / test (push) Successful in 38s
CI / test (pull_request) Successful in 38s
node-26#9. decoder_control.py's AIS mode now actually launches AIS-catcher
instead of 400ing: a background thread reads its stdout JSON stream
(one message per line) and keeps a live in-memory snapshot keyed by mmsi,
since AIS-catcher streams rather than writing a periodic file the way
dump1090's --write-json does. Messages are merged onto the existing entry
per mmsi rather than replacing it, since AIS-catcher emits static data
(name) and position reports (lat/lon) as separate message types — a naive
overwrite would blank the name back to null on every position-only update
(caught by a standalone unit check against a fake stdout stream before
this fix, not by pytest — this container has no test suite yet).

edge-node's telemetry_uplink_loop now posts non-empty vessel snapshots to
the new /telemetry/ais the same way it already does for aircraft.

UNVERIFIED against real hardware/binary in this session, same caveat as
the ADS-B commit — AIS-catcher's JSON field names are believed correct
from its docs, not confirmed against a real capture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:10:30 -04:00
Logan CusanoandClaude Sonnet 5 b2e3804dc3 Wire ADS-B end to end: secondary-sdr container running dump1090
node-26#9. New secondary-sdr-container claims the node's SECOND physical
SDR (RTL-SDR index 1 — op25 always claims index 0; no serial-based binding
yet, same gap op25 itself has). Its control API (start/stop/status/data,
mirroring op25_controller.py) launches dump1090 in adsb mode and exposes
the decoded aircraft.json snapshot; AIS mode 400s until it's wired next.

edge-node: on_config_push starts/stops it when secondary_sdr_mode changes,
lifespan resumes it after a restart if already configured, and a new
telemetry_uplink_loop polls its /secondary/data every 10s and POSTs
non-empty snapshots to C2's new /telemetry/adsb (same bearer-key pattern
call_recorder.py already uses for audio upload).

UNVERIFIED: this container has not been built or run against real hardware
in this session (sandboxed authoring machine, no docker) — dump1090's
--write-json field names are believed correct from its docs but not
confirmed against a real capture. Build + hardware smoke test before this
reaches a real node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:10:24 -04:00
Logan CusanoandClaude Sonnet 5 8f5fca8757 Add second-SDR plumbing: secondary_sdr_mode config + hardware reporting
Adds a per-node secondary_sdr_mode config field (none|adsb|ais|op25_2),
applied the same way as hardware_preset/ppm_override so it survives system
reassignment. op25-container gets a GET /devices endpoint that counts
connected SDRs via lsusb; the edge-node checkin now reports sdr_count and
secondary_sdr_mode up to the server, the first node-initiated hardware
report (everything else was C2 pushing config down). Tracked as node-26#9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:10:17 -04:00
logan a53000a092 ci: publish node images to a public registry on a version tag, DISABLED (#8)
CI / lint (push) Successful in 6s
CI / test (push) Successful in 37s
2026-09-06 23:49:02 -04:00
Logan CusanoandClaude Sonnet 5 bee9e173e6 ci: workflow to publish node images to a public registry on a version tag (DISABLED)
CI / lint (pull_request) Successful in 6s
CI / lint (push) Successful in 7s
CI / test (pull_request) Successful in 37s
CI / test (push) Successful in 39s
git.vpn.cusano.net is now behind REQUIRE_SIGNIN_VIEW (INCIDENT-2026-09-06),
so a fresh Pi can no longer anonymously `docker pull` the node images or
fetch install.sh. Plan (owner, option 3): on a `v*` tag, build the three
node images (edge-node, icecast, op25-client, arm64) and push them to a
PUBLIC registry; source and the private Gitea registry stay walled.

This workflow is wired but INERT — the job is gated on
`vars.NODE_PUBLIC_PUBLISH == 'true'`, which is unset. It triggers on tags
and skips. Enabling is three repo variables + two secrets, documented in the
file header; no code change. Reuses the existing Gitea buildcache refs so
op25 doesn't recompile from scratch.

Not turning it on now — still building the core.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:39:42 -04:00
logan 31e1176c45 install.sh: re-run collects the key via pickup_secret, never re-enrolls (#7)
CI / test (push) Successful in 39s
CI / lint (push) Successful in 5s
2026-09-06 22:32:19 -04:00
33 changed files with 3047 additions and 819 deletions
+7
View File
@@ -111,6 +111,13 @@ OP25_TERMINAL_URL=http://localhost:8081
# for local development off a real node; leave false everywhere else. # for local development off a real node; leave false everywhere else.
OP25_DEBUG_EXPOSE=false 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 --------------------------------------------- # --- Local dashboard / API login ---------------------------------------------
# Protects the node's local dashboard (port 80) and JSON API. The node is # 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 # reachable by anyone on whatever site's LAN it's deployed to, so this MUST be
+52
View File
@@ -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
+103
View File
@@ -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
+15
View File
@@ -32,6 +32,21 @@ services:
depends_on: depends_on:
- icecast - 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: edge-node:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest
build: ./drb-edge-node build: ./drb-edge-node
+3
View File
@@ -136,6 +136,9 @@ class Settings(BaseSettings):
op25_api_url: str = "http://localhost:8001" op25_api_url: str = "http://localhost:8001"
op25_terminal_url: str = "http://localhost:8081" 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) # Paths (volume mounts)
config_path: str = "/configs" config_path: str = "/configs"
recordings_path: str = "/recordings" recordings_path: str = "/recordings"
+300
View File
@@ -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_tx_end: Optional[float] = None # epoch of the srcaddr 1→0 edge
self._last_ok_poll: float = 0.0 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. # Rolling console history for close-time attribution.
self._console: deque[ConsoleEvent] = deque(maxlen=CONSOLE_HISTORY_MAX) self._console: deque[ConsoleEvent] = deque(maxlen=CONSOLE_HISTORY_MAX)
@@ -294,6 +301,7 @@ class MetadataWatcher:
self._last_ok_poll = now self._last_ok_poll = now
self._record_console(update, now) self._record_console(update, now)
self._note_terminal(update, now)
activity = self._snapshot() activity = self._snapshot()
if activity is None or not activity.capturing: if activity is None or not activity.capturing:
@@ -350,6 +358,18 @@ class MetadataWatcher:
while self._console and self._console[0].epoch < cutoff: while self._console and self._console[0].epoch < cutoff:
self._console.popleft() 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: def _is_duplicate_grant(self, event: ConsoleEvent) -> bool:
for index in range(len(self._console) - 1, -1, -1): for index in range(len(self._console) - 1, -1, -1):
if len(self._console) - index > _GRANT_DEDUPE_DEPTH: if len(self._console) - index > _GRANT_DEDUPE_DEPTH:
@@ -898,6 +918,19 @@ class MetadataWatcher:
def is_active(self) -> bool: def is_active(self) -> bool:
return self._active_call_id is not None 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 @property
def unattributed_segments(self) -> int: def unattributed_segments(self) -> int:
"""Orphan-audio segments discarded since start. Surfaced on /api/status.""" """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.") logger.info("No API key on disk — requesting re-delivery from C2 server.")
self._publish(self._t_key_request, {}, qos=1) self._publish(self._t_key_request, {}, qos=1)
async def publish_checkin(self):
await self._publish_checkin()
async def _publish_checkin(self): async def _publish_checkin(self):
from app.internal.discord_radio import radio_bot from app.internal.discord_radio import radio_bot
from app.internal.config_manager import load_node_config from app.internal.config_manager import load_node_config
from app.internal.op25_client import op25_client
from app.internal.secondary_sdr_client import secondary_sdr_client
config = load_node_config() config = load_node_config()
devices = await op25_client.devices()
payload = { payload = {
"node_id": settings.node_id, "node_id": settings.node_id,
"name": settings.node_name, "name": settings.node_name,
@@ -225,7 +231,35 @@ class MQTTManager:
"is_overridden": config.override_system_id is not None and config.node_type != "portable", "is_overridden": config.override_system_id is not None and config.node_type != "portable",
"override_system_id": config.override_system_id, "override_system_id": config.override_system_id,
"enforce_override_timeout": config.enforce_override_timeout, "enforce_override_timeout": config.enforce_override_timeout,
"secondary_sdr_mode": config.secondary_sdr_mode,
"secondary_sdr_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) self._publish(self._t_checkin, payload, qos=1)
def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False): def _publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False):
+55 -2
View File
@@ -11,6 +11,11 @@ from app.internal.logger import logger
# each id is also a top-level key holding that channel's dict # each id is also a top-level key holding that channel's dict
# (freq/tgid/tag/srcaddr/svcopts/hold_tgid/…). # (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 # call_log — an EVENT QUEUE, not a snapshot. `log` holds entries appended
# by tk_p25.log_call() at channel-grant time, each stamped with # by tk_p25.log_call() at channel-grant time, each stamped with
# OP25's own time.time(). get_call_log() DRAINS the deque, so # 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 # it forever. The deque is capped at CALL_LOG_MAX_LEN = 10, so
# the consumer must keep up. # 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}] TERMINAL_UPDATE_COMMAND = [{"command": "update", "arg1": 0, "arg2": 0}]
@@ -28,6 +33,7 @@ class TerminalUpdate:
channels: List[Dict[str, Any]] = field(default_factory=list) channels: List[Dict[str, Any]] = field(default_factory=list)
call_log: 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: class OP25Client:
@@ -45,6 +51,23 @@ class OP25Client:
logger.error(f"OP25 start failed: {e}") logger.error(f"OP25 start failed: {e}")
return False 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: async def stop(self) -> bool:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
@@ -65,15 +88,40 @@ class OP25Client:
logger.error(f"OP25 status failed: {e}") logger.error(f"OP25 status failed: {e}")
return None 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: async def generate_config(self, config: Dict[str, Any]) -> bool:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(f"{self.api_url}/op25/generate-config", json=config) r = await client.post(f"{self.api_url}/op25/generate-config", json=config)
r.raise_for_status() r.raise_for_status()
return True
except Exception as e: except Exception as e:
logger.error(f"OP25 generate-config failed: {e}") logger.error(f"OP25 generate-config failed: {e}")
return False 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]: async def poll_terminal(self) -> Optional[TerminalUpdate]:
""" """
@@ -130,6 +178,11 @@ def parse_terminal_messages(messages: Any) -> TerminalUpdate:
if isinstance(channel, dict): if isinstance(channel, dict):
update.channels.append(channel) 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": elif json_type == "call_log":
for entry in msg.get("log") or []: for entry in msg.get("log") or []:
if isinstance(entry, dict): if isinstance(entry, dict):
+102
View File
@@ -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"]})
+40 -3
View File
@@ -5,7 +5,7 @@ from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI
from app.config import settings 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.logger import logger
from app.internal.mqtt_manager import mqtt_manager from app.internal.mqtt_manager import mqtt_manager
from app.internal import credentials from app.internal import credentials
@@ -158,6 +158,12 @@ async def on_command(payload: dict):
) )
elif action == "discord_leave": elif action == "discord_leave":
await radio_bot.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": elif action == "op25_restart":
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
await op25_client.stop() await op25_client.stop()
@@ -224,6 +230,9 @@ async def on_config_push(payload: dict):
hardware_preset = payload.pop("hardware_preset", None) hardware_preset = payload.pop("hardware_preset", None)
ppm_override = payload.pop("ppm_override", None) ppm_override = payload.pop("ppm_override", None)
node_type = payload.pop("node_type", None) node_type = payload.pop("node_type", None)
secondary_sdr_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) enforce_override_timeout = payload.pop("enforce_override_timeout", None)
try: try:
config = SystemConfig(**payload) config = SystemConfig(**payload)
@@ -245,6 +254,16 @@ async def on_config_push(payload: dict):
node_cfg.node_type = node_type node_cfg.node_type = node_type
if enforce_override_timeout is not None: if enforce_override_timeout is not None:
node_cfg.enforce_override_timeout = bool(enforce_override_timeout) node_cfg.enforce_override_timeout = bool(enforce_override_timeout)
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) save_node_config(node_cfg)
from app.internal.op25_client import op25_client 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}") logger.error(f"Failed to generate OP25 config for {config.name}")
return 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 op25_client.stop()
await asyncio.sleep(2) await asyncio.sleep(2)
await op25_client.start() await op25_client.start()
logger.info(f"Config push applied: {config.name}") 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) if (node_cfg.override_system_id and node_cfg.override_config)
else node_cfg.system_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 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): for attempt in range(10):
if await _generate_op25_config(active_config): if await _generate_op25_config(active_config):
await op25_client.start() 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…") logger.warning(f"OP25 not ready yet (attempt {attempt + 1}/10), retrying in 3s…")
await asyncio.sleep(3) 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()) 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 --- yield # --- app running ---
logger.info("Edge node shutting down.") logger.info("Edge node shutting down.")
heartbeat_task.cancel() heartbeat_task.cancel()
telemetry_task.cancel()
await metadata_watcher.stop() await metadata_watcher.stop()
await call_recorder.stop() await call_recorder.stop()
await radio_bot.stop() await radio_bot.stop()
+42 -2
View File
@@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel, model_validator
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, Iterable, List
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime
@@ -23,6 +23,34 @@ class SystemConfig(BaseModel):
config: Dict[str, Any] # OP25-compatible config blob passed through to op25-container config: Dict[str, Any] # OP25-compatible config blob passed through to op25-container
# Decoders a node can run on the SDRs beyond op25's (node-26#9). op25 always
# keeps its own dongle; each further dongle runs the next entry of the node's
# secondary_sdr_priority, so a 3-SDR node with ["adsb", "ais"] runs both.
SECONDARY_SDR_MODES = ("adsb", "ais")
def normalize_secondary_priority(items: Iterable[str]) -> List[str]:
"""Known modes only, first occurrence wins, order preserved."""
out: List[str] = []
for m in items or []:
if m in SECONDARY_SDR_MODES and m not in out:
out.append(m)
return out
# 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): class NodeConfig(BaseModel):
node_id: str node_id: str
node_name: str node_name: str
@@ -34,11 +62,23 @@ class NodeConfig(BaseModel):
hardware_preset: str = "rtl-sdr-v3" hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None ppm_override: Optional[float] = None
node_type: str = "fixed" # fixed or portable node_type: str = "fixed" # fixed or portable
secondary_sdr_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 enforce_override_timeout: bool = True
override_system_id: Optional[str] = None override_system_id: Optional[str] = None
override_config: Optional[SystemConfig] = None override_config: Optional[SystemConfig] = None
offline_call_buffer_size: int = 35 # max call_end events to buffer while MQTT is offline offline_call_buffer_size: int = 35 # max call_end events to buffer while MQTT is offline
@model_validator(mode="after")
def _migrate_secondary_mode(self) -> "NodeConfig":
if not self.secondary_sdr_priority and self.secondary_sdr_mode in SECONDARY_SDR_MODES:
self.secondary_sdr_priority = [self.secondary_sdr_mode]
return self
class CallEvent(BaseModel): class CallEvent(BaseModel):
call_id: str call_id: str
+181 -33
View File
@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, Body 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 asyncio
import httpx import httpx
from app.config import settings from app.config import settings
from app.models import SystemConfig from app.models import SystemConfig, SECONDARY_SDR_MODES
from app.internal.op25_client import op25_client from app.internal.op25_client import op25_client
from app.internal.config_manager import load_node_config, save_node_config, apply_system_config from app.internal.config_manager import load_node_config, save_node_config
from app.internal.call_recorder import call_recorder from app.internal.call_recorder import call_recorder
from app.internal.discord_radio import radio_bot from app.internal.discord_radio import radio_bot
from app.internal.metadata_watcher import metadata_watcher 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) if (node_cfg.override_system_id and node_cfg.override_config)
else node_cfg.system_config else node_cfg.system_config
) )
system_type = None
if active_config: if active_config:
system_name = active_config.name system_name = active_config.name
system_type = active_config.type
if active_tgid: if active_tgid:
# Reverse engineer the TGID to talkgroup name based on active system object # Reverse engineer the TGID to talkgroup name based on active system object
tgs = active_config.config.get("talkgroups", []) tgs = active_config.config.get("talkgroups", [])
@@ -57,6 +60,11 @@ async def get_status():
"configured": node_cfg.configured, "configured": node_cfg.configured,
"assigned_system_id": node_cfg.assigned_system_id, "assigned_system_id": node_cfg.assigned_system_id,
"system_name": system_name, "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, "is_recording": call_recorder.is_recording,
# Health of the PulseAudio capture that feeds every recording — the single # Health of the PulseAudio capture that feeds every recording — the single
# most useful signal when recordings come back empty. Segment boundaries # most useful signal when recordings come back empty. Segment boundaries
@@ -76,15 +84,27 @@ async def get_status():
"icecast_url": ( "icecast_url": (
f"http://{settings.icecast_host}:{settings.icecast_port}{settings.icecast_mount}" 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, "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") @router.post("/op25/start")
async def start_op25(): async def start_op25():
ok = await op25_client.start() err = await op25_client.start_with_reason()
if not ok: if err:
raise HTTPException(500, "Failed to start OP25") raise HTTPException(502, err)
return {"ok": True} return {"ok": True}
@@ -92,30 +112,135 @@ async def start_op25():
async def stop_op25(): async def stop_op25():
ok = await op25_client.stop() ok = await op25_client.stop()
if not ok: 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} 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") @router.get("/config")
async def get_config(): async def get_config():
return load_node_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") @router.post("/config/system")
async def set_system_config(config: SystemConfig): async def set_system_config(config: SystemConfig):
""" """
Apply a system config locally — called by the web UI or pushed by C2. Assign a system as if C2 had (persists as the node's own system, not an
Writes the OP25 config and persists the node config. 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 = load_node_config()
node_cfg.assigned_system_id = config.system_id node_cfg.assigned_system_id = config.system_id
node_cfg.system_config = config node_cfg.system_config = config
node_cfg.configured = True node_cfg.configured = True
save_node_config(node_cfg) save_node_config(node_cfg)
apply_system_config(config) await _restart_op25_with(config)
return {"ok": True} 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") @router.get("/systems")
async def get_systems(): async def get_systems():
from app.internal.system_cacher import load_cached_systems, fetch_and_cache_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_id: Optional[str] = Body(None),
system_config: Optional[dict] = Body(None) system_config: Optional[dict] = Body(None)
): ):
node_cfg = load_node_config()
config = None
if system_id: if system_id:
from app.internal.system_cacher import get_cached_system from app.internal.system_cacher import get_cached_system
cached = get_cached_system(system_id) cached = get_cached_system(system_id)
@@ -144,19 +266,8 @@ async def set_override(
else: else:
raise HTTPException(400, "Must specify system_id or system_config.") raise HTTPException(400, "Must specify system_id or system_config.")
node_cfg.override_system_id = config.system_id _validate_type(config.type)
node_cfg.override_config = config await _apply_override(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()
return {"ok": True} return {"ok": True}
@@ -171,15 +282,14 @@ async def revert_config():
save_node_config(node_cfg) save_node_config(node_cfg)
if node_cfg.system_config: if node_cfg.system_config:
from app.main import _generate_op25_config await _restart_op25_with(node_cfg.system_config)
if not await _generate_op25_config(node_cfg.system_config): else:
raise HTTPException(500, "Failed to regenerate original OP25 config.") # 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 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} 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}") 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") @router.post("/discord/join")
async def discord_join(guild_id: int, channel_id: int): async def discord_join(guild_id: int, channel_id: int):
ok = await radio_bot.join(guild_id, channel_id) ok = await radio_bot.join(guild_id, channel_id)
+502 -431
View File
@@ -3,497 +3,568 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DRB Edge Node Dashboard</title> <title>DRB Edge Node</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"> <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> <style>
/* Tokens from UI_REDESIGN.md §2.2/§2.3. Status is triple-encoded
(glyph + word + colour) and green is not used. */
:root { :root {
--bg: #0b0f19; --page: #F4F6F9; --surface: #FFFFFF; --raised: #F7F9FB;
--glass-bg: rgba(20, 25, 40, 0.6); --line: rgba(11,17,27,.11); --line-strong: rgba(11,17,27,.22);
--glass-border: rgba(255, 255, 255, 0.08); --ink: #101620; --ink-2: #46536A; --ink-muted: #6B788C;
--accent: #3b82f6; --accent: #2A78D6; --moderate: #B07800; --major: #C0281F;
--accent-hover: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--text-main: #f8fafc;
--text-muted: #94a3b8;
} }
@media (prefers-color-scheme: dark) {
*, *::before, *::after { :root:not([data-theme="light"]) {
box-sizing: border-box; --page: #0B0E13; --surface: #141922; --raised: #1B2230;
margin: 0; --line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
padding: 0; --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 { body {
font-family: 'Inter', sans-serif; font-family: 'IBM Plex Sans', system-ui, sans-serif;
background: var(--bg); background: var(--page); color: var(--ink);
background-image: line-height: 1.5; padding: 24px 16px 48px;
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;
} }
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.container { .wrap { max-width: 980px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
max-width: 1200px; header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
margin: 0 auto; 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; }
header { .card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
display: flex; .card > .sub { margin-bottom: 12px; }
justify-content: space-between; .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
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;
}
.btn { .btn {
display: inline-flex; font: inherit; font-size: .875rem; font-weight: 500; cursor: pointer;
align-items: center; padding: 8px 14px; border-radius: 6px; border: 1px solid var(--line-strong);
justify-content: center; background: var(--raised); color: var(--ink); text-decoration: none; display: inline-flex; align-items: center;
padding: 0.75rem 1.5rem; }
border-radius: 8px; .btn:hover { border-color: var(--ink-muted); }
font-weight: 600; .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
font-size: 0.9rem; .btn:disabled { opacity: .45; cursor: default; }
text-decoration: none; .msg { font-size: .875rem; color: var(--ink-2); }
transition: all 0.2s ease; .msg.err { color: var(--major); }
cursor: pointer;
border: none; /* 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 { /* Listen form */
background: var(--accent); .tabs { display: flex; border: 1px solid var(--line-strong); border-radius: 6px; overflow: hidden; width: fit-content; max-width: 100%; flex-wrap: wrap; }
color: white; .tabs label { padding: 7px 12px; font-size: .875rem; cursor: pointer; border-left: 1px solid var(--line); }
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39); .tabs label:first-child { border-left: none; }
} .tabs input { position: absolute; opacity: 0; pointer-events: none; }
.btn-primary:hover { .tabs label:has(input:checked) { background: var(--accent); color: #fff; }
background: var(--accent-hover); .tabs label:has(input:disabled) { color: var(--ink-muted); cursor: default; }
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.23); .tabs label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
transform: translateY(-1px); .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 { /* SDRs */
background: rgba(255, 255, 255, 0.05); .sdr-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
color: white; .sdr-rank { width: 1.2rem; color: var(--ink-muted); font-size: .8rem; text-align: right; }
border: 1px solid var(--glass-border); .sdr-name { flex: 1; min-width: 120px; }
backdrop-filter: blur(10px); .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; }
.btn-secondary:hover { .sdr-move:disabled { opacity: .3; cursor: default; }
background: rgba(255, 255, 255, 0.1); .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; }
.grid { footer { text-align: center; font-size: .8rem; color: var(--ink-muted); }
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;
}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="wrap">
<header> <header>
<div class="title-area"> <div>
<h1 id="node-name">DRB Edge Node</h1> <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>
<div class="header-actions"> <div class="row">
<a href="/scanner" target="_blank" class="btn btn-secondary"> <a href="/scanner" target="_blank" class="btn">Scanner view</a>
<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> <a href="/logout" class="btn">Log out</a>
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> </div>
</header> </header>
<div id="unconfigured-banner" class="alert" style="display:none"> <!-- 1. Status checklist (GET /api/health) -->
<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> <section class="card" aria-labelledby="h-status">
<div> <h2 id="h-status">Node status</h2>
<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>). <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&#10;1002 Fireground"></textarea>
<span class="hint">One per line: decimal ID, then a name. Leave empty to hear every talkgroup.</span>
</div> </div>
</div> </div>
<div id="override-banner" class="alert" style="display:none; background: rgba(245, 158, 11, 0.1); border-color: var(--warning); color: #fef08a;"> <div class="pane" data-pane="analog" hidden>
<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 class="field">
<div style="flex: 1;"> <label for="an-freq">Frequency (MHz)</label>
<strong>Local Override Active</strong> — System overridden to: <span id="override-system-text"></span>. <input type="text" id="an-freq" class="mono" placeholder="154.4300" inputmode="decimal">
<span id="timeout-timer-text">Timeout is active on C2 server.</span> <span class="hint">One conventional narrowband FM channel.</span>
</div> </div>
<div style="display: flex; gap: 0.5rem; align-items: center;"> <div class="field">
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" onclick="revertToServerConfig()">Revert</button> <label for="an-name">Name <span class="hint">(optional)</span></label>
<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> <input type="text" id="an-name" placeholder="Fire dispatch">
</div> </div>
</div> </div>
<div class="grid"> <div class="pane" data-pane="system" hidden>
<!-- Status Card --> <div class="field">
<div class="glass-card"> <label for="sys-pick">System</label>
<div class="card-header"> <select id="sys-pick"></select>
<div class="card-title">System Status</div> <span class="hint" id="sys-hint"></span>
</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> </div>
</div> </div>
<!-- Call Card --> <p class="sub" style="margin-top:12px">DMR isn't supported by this node's decoder yet.</p>
<div class="glass-card" id="call-card"> <div class="row" style="margin-top:8px">
<div class="card-header"> <button class="btn primary" id="listen-btn" onclick="listen()">Start listening</button>
<div class="card-title">Live Activity</div> <span class="msg" id="listen-msg" role="status"></span>
</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>
</div> </div>
</section>
<!-- Node Info Card --> <!-- 4. SDRs (node-26#9, #11) -->
<div class="glass-card"> <section class="card" aria-labelledby="h-sdr">
<div class="card-header"> <h2 id="h-sdr">SDRs</h2>
<div class="card-title">Hardware & Config</div> <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> <div class="row">
<div class="data-row"> <label for="sdr-op25" style="font-size:.875rem;font-weight:500">OP25 uses</label>
<span class="data-label">Location</span> <select id="sdr-op25" class="sdr-select"></select>
<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>
</div> </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> </div>
</section>
<!-- Local Listening Card --> <footer>Updated <span id="last-updated">—</span></footer>
<div class="glass-card player-card"> </div>
<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"> <script>
Last updated: <span id="last-updated">—</span> const $ = id => document.getElementById(id);
</div> function esc(v) {
</div> return String(v ?? '').replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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';
}
<script> // ── Status checklist ───────────────────────────────────────────────────
const player = document.getElementById('icecast-player'); const GLYPH = { ok: '✓', warn: '△', error: '▲', off: '–' };
let currentIcecastUrl = null; const WORD = { ok: 'OK', warn: 'Attention', error: 'Problem', off: 'Not active' };
let lastHealth = null;
async function refresh() { async function refreshHealth() {
try { try {
const r = await fetch('/api/status'); const d = await api('/api/health');
if (!r.ok) return; lastHealth = d;
const d = await r.json(); const box = $('checks');
box.innerHTML = d.checks.map(c => `
// Header <div class="check s-${c.state}">
document.getElementById('node-name').textContent = d.node_name || 'DRB Edge Node'; <span class="glyph" aria-hidden="true">${GLYPH[c.state] || '?'}</span>
document.getElementById('node-id').textContent = d.node_id || ''; <div class="label">${esc(c.label)}<span class="state">${WORD[c.state] || esc(c.state)}</span></div>
<div class="text">${esc(c.summary)}</div>
// Status <div class="action">${esc(c.action)}</div>
const status = d.is_recording ? 'recording' : (d.configured ? 'online' : 'unconfigured'); </div>`).join('');
const badge = document.getElementById('status-badge'); const bad = d.checks.filter(c => c.state === 'error').length;
badge.textContent = status; const warn = d.checks.filter(c => c.state === 'warn').length;
badge.className = 'badge badge-' + status; $('health-summary').textContent = bad
? `${bad} problem${bad > 1 ? 's' : ''}${warn ? ` and ${warn} thing${warn > 1 ? 's' : ''} to check` : ''} — see below.`
document.getElementById('discord-status').textContent = d.discord_connected ? 'Connected' : 'Disconnected'; : warn ? `${warn} thing${warn > 1 ? 's' : ''} need${warn > 1 ? '' : 's'} attention — see below.`
document.getElementById('recording-status').textContent = d.is_recording ? 'Active' : 'Idle'; : 'Everything is working.';
document.getElementById('op25-status').textContent = d.op25?.status === 'running' ? 'Running' : 'Stopped'; const heard = d.last_heard;
$('now-heard').innerHTML = heard
// Call Info ? `<span class="mono">TG ${esc(heard.tgid)}</span>${heard.name ? ' ' + esc(heard.name) : ''}` +
document.getElementById('system-name').textContent = d.system_name || '—'; ` · ${ago(heard.epoch)}${heard.freq ? ` · <span class="mono">${esc(mhz(heard.freq))}</span>` : ''}`
document.getElementById('tgid-name').textContent = d.active_tgid_name || (d.active_tgid ? `TG ${d.active_tgid}` : 'Scanning...'); : 'Nothing heard since the node started.';
document.getElementById('tgid').textContent = d.active_tgid ?? '—'; const op = d.checks.find(c => c.id === 'op25');
document.getElementById('call-id').textContent = d.active_call_id ? d.active_call_id.slice(0, 8) + '…' : '—'; const src = d.checks.find(c => c.id === 'source');
const running = op && op.state === 'ok';
// Highlight active call card $('op25-start').disabled = running || (src && src.state !== 'ok');
const callCard = document.getElementById('call-card'); $('op25-stop').disabled = !running;
if (d.is_recording) { if (op && op.state === 'error' && $('op25-log').hidden) loadLog();
callCard.style.borderColor = 'var(--accent)'; } catch (e) {
callCard.style.boxShadow = '0 0 20px rgba(59, 130, 246, 0.2)'; $('health-summary').textContent = `Couldn't load status: ${e.message}`;
} 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;
} }
} }
// Override Banner // ── Live status: system, talkgroup, stream URL ─────────────────────────
const overrideBanner = document.getElementById('override-banner'); let streamUrl = null;
if (d.is_overridden) { let status = null;
overrideBanner.style.display = 'flex'; async function refreshStatus() {
document.getElementById('override-system-text').textContent = d.system_name || 'Manual'; 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'); // The node calls Icecast "localhost"; the browser must use the node's address.
const timerText = document.getElementById('timeout-timer-text'); const url = `${location.protocol}//${location.hostname}:${d.icecast_port}${d.icecast_mount}`;
if (url !== streamUrl) {
if (d.enforce_override_timeout) { streamUrl = url;
timerText.textContent = "C2 reset timer active (24h default)."; $('player').src = url;
ackBtn.style.display = 'inline-flex'; $('stream-link').href = url;
} else { $('stream-link').textContent = url;
timerText.textContent = "No C2 timeout is enforced.";
ackBtn.style.display = 'none';
} }
} else { renderSource(d);
overrideBanner.style.display = 'none'; $('last-updated').textContent = new Date().toLocaleTimeString();
}
document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex';
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
} catch (e) { } catch (e) {
console.error('Status fetch failed:', e); console.error('Status fetch failed:', e);
} }
} }
async function revertToServerConfig() { function renderSource(d) {
try { const cur = $('current-source');
const r = await fetch('/api/config/revert', { method: 'POST' }); const actions = $('source-actions');
if (r.ok) { if (!d.system_name) {
alert('Config reverted successfully.'); cur.textContent = 'Nothing chosen yet. Pick a source below and press Start listening.';
refresh(); actions.hidden = true;
} else { return;
alert('Failed to revert config.');
} }
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) { } 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 { try {
const r = await fetch('/api/config/override/ack', { await api('/api/config/revert', { method: 'POST' });
method: 'POST', msg.textContent = toServer ? "Back on the server's system." : 'Cleared. OP25 is stopped.';
headers: { 'Content-Type': 'application/json' }, } catch (e) {
body: JSON.stringify({ timeout_minutes: 1440 }) 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) { const warn = $('sdr-warning');
alert('Override acknowledged on C2.'); const pinned = [sdrOp25Pin, ...sdrRows.map(r => r.pin)].filter(Boolean);
} else { const problems = [];
alert('Failed to send acknowledgement.'); 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) { } 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(); refreshStatus(); setInterval(refreshStatus, 2000);
setInterval(refresh, 2000); // Polling every 2 seconds refreshHealth(); setInterval(refreshHealth, 5000);
</script> loadSystems();
loadSdr(); setInterval(loadSdr, 10000);
</script>
</body> </body>
</html> </html>
+14 -25
View File
@@ -124,7 +124,7 @@
} }
.softkey:last-child { border-right: none; } .softkey:last-child { border-right: none; }
/* Overlay UI (Volume, Hold) */ /* Overlay UI (Volume, status) */
.overlay { .overlay {
position: absolute; position: absolute;
top: 50%; left: 50%; top: 50%; left: 50%;
@@ -153,7 +153,6 @@
<ul> <ul>
<li><span>Up Arrow</span> Volume Up</li> <li><span>Up Arrow</span> Volume Up</li>
<li><span>Down Arrow</span> Volume Down</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>P</span> Play/Pause Stream</li>
<li><span>S</span> Select System</li> <li><span>S</span> Select System</li>
<li><span>R</span> Revert Override</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> <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 style="display: grid; grid-template-columns: 1fr 1fr; gap: 5px; font-size: 11px; margin-bottom: 8px;">
<div> <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;"> <input type="text" id="manual-freq" style="background:#222; color:#fff; border:1px solid #555; padding:3px; width:100%; border-radius:2px;">
</div> </div>
<div> <div>
<label>System Type:</label> <label>System Type:</label>
<select id="manual-type" style="background:#222; color:#fff; border:1px solid #555; padding:3px; width:100%; border-radius:2px;"> <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="P25">P25</option>
<option value="DMR">DMR</option> <option value="NBFM">Analog FM</option>
<option value="NBFM">NBFM</option>
</select> </select>
</div> </div>
</div> </div>
@@ -208,11 +206,11 @@
<div class="softkeys"> <div class="softkeys">
<div class="softkey">VOL (+/-)</div> <div class="softkey">VOL (+/-)</div>
<div class="softkey">HOLD (H)</div> <div class="softkey">SYSTEM (S)</div>
<div class="softkey">PLAY (P)</div> <div class="softkey">PLAY (P)</div>
</div> </div>
<div class="overlay" id="overlay">HOLD</div> <div class="overlay" id="overlay"></div>
<!-- Audio element for local playback --> <!-- Audio element for local playback -->
<audio id="audio-player" autoplay></audio> <audio id="audio-player" autoplay></audio>
@@ -231,7 +229,6 @@
const elOverlay = document.getElementById('overlay'); const elOverlay = document.getElementById('overlay');
let currentIcecastUrl = null; let currentIcecastUrl = null;
let isHolding = false;
let volume = 1.0; let volume = 1.0;
// Clock updater // Clock updater
@@ -309,7 +306,7 @@
const container = document.getElementById('system-list'); const container = document.getElementById('system-list');
container.innerHTML = ''; container.innerHTML = '';
if (systems.length === 0) { 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; return;
} }
systems.forEach((sys, index) => { systems.forEach((sys, index) => {
@@ -373,25 +370,20 @@
} }
closeSystemModal(); closeSystemModal();
showOverlay("APPLYING MANUAL", 3000); showOverlay("APPLYING MANUAL", 3000);
const manualConfig = { // Same validated path as the dashboard's "Listen locally" (MHz range check).
system_id: "manual-override", const mode = typeVal === 'NBFM' ? 'analog' : 'p25';
name: `Freq ${freqVal} (${typeVal})`, const freqs = freqVal.split(/[\s,;]+/).filter(Boolean).map(Number);
type: typeVal,
config: {
control_channels: [freqVal],
talkgroups: []
}
};
try { try {
const res = await fetch('/api/config/override', { const res = await fetch('/api/listen', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system_config: manualConfig }) body: JSON.stringify({ mode, frequencies: freqs })
}); });
if (res.ok) { if (res.ok) {
showOverlay('MANUAL OVERRIDE OK', 1500); showOverlay('MANUAL OK', 1500);
} else { } else {
showOverlay('MANUAL OVERRIDE FAIL', 2000); const d = await res.json().catch(() => ({}));
showOverlay((d.detail || 'MANUAL FAILED').slice(0, 60), 4000);
} }
} catch (err) { } catch (err) {
showOverlay('ERROR APPLYING', 2000); showOverlay('ERROR APPLYING', 2000);
@@ -444,9 +436,6 @@
audioPlayer.volume = volume; audioPlayer.volume = volume;
elVol.textContent = `VOL: ${Math.round(volume * 100)}%`; elVol.textContent = `VOL: ${Math.round(volume * 100)}%`;
showOverlay(`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') { } else if (e.key === 'p' || e.key === 'P') {
if (audioPlayer.paused) { if (audioPlayer.paused) {
audioPlayer.play(); audioPlayer.play();
+248
View File
@@ -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
+91
View File
@@ -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"}
+7
View File
@@ -181,6 +181,13 @@ else
warn "no RTL-SDR dongle detected on USB — plug one in before expecting audio" warn "no RTL-SDR dongle detected on USB — plug one in before expecting audio"
fi 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 ───────────────────────────────────────────────────────── # ── 2. Dependencies ─────────────────────────────────────────────────────────
say "Installing dependencies" say "Installing dependencies"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
+1 -1
View File
@@ -12,7 +12,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies # Install system dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y && \ 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) # Install custom PulseAudio system config (enables anonymous access for edge-node)
COPY system.pa /etc/pulse/system.pa COPY system.pa /etc/pulse/system.pa
+14 -6
View File
@@ -52,12 +52,16 @@ class IcecastConfig(BaseModel):
class ConfigGenerator(BaseModel): class ConfigGenerator(BaseModel):
type: DecodeMode type: DecodeMode
systemName: str systemName: str
# P25: control channel list (Hz). NBFM: channels[0] is the one analog
# frequency to listen to (Hz).
channels: List[Union[int, str]] channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]] tags: Optional[List[TalkgroupTag]] = None
whitelist: Optional[List[int]] whitelist: Optional[List[int]] = None
icecastConfig: Optional[IcecastConfig] icecastConfig: Optional[IcecastConfig] = None
hardware_preset: str = "rtl-sdr-v3" hardware_preset: str = "rtl-sdr-v3"
ppm_override: Optional[float] = None # when set, overrides the preset's default PPM 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): class DemodType(str, Enum):
CQPSK = "cqpsk" CQPSK = "cqpsk"
@@ -69,15 +73,16 @@ class FilterType(str, Enum):
class ChannelConfig(BaseModel): class ChannelConfig(BaseModel):
name: str name: str
trunking_sysname: Optional[str] trunking_sysname: Optional[str] = None
enable_analog: str enable_analog: str
meta_stream_name: str meta_stream_name: str
demod_type: DemodType demod_type: DemodType
filter_type: FilterType filter_type: FilterType
device: Optional[str] = "sdr" device: Optional[str] = "sdr"
cqpsk_tracking: Optional[bool] = None cqpsk_tracking: Optional[bool] = None
frequency: Optional[float] = None frequency: Optional[int] = None
nbfmSquelch: Optional[float] = 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" destination: Optional[str] = "udp://127.0.0.1:23456"
tracking_threshold: Optional[int] = 120 tracking_threshold: Optional[int] = 120
tracking_feedback: Optional[float] = 0.75 tracking_feedback: Optional[float] = 0.75
@@ -93,6 +98,9 @@ class DeviceConfig(BaseModel):
gains: Optional[str] = "lna:39" gains: Optional[str] = "lna:39"
gain_mode: Optional[bool] = False gain_mode: Optional[bool] = False
name: Optional[str] = "sdr" 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 offset: Optional[int] = 0
ppm: Optional[float] = 0.0 ppm: Optional[float] = 0.0
rate: Optional[int] = 1920000 rate: Optional[int] = 1920000
+144 -20
View File
@@ -1,8 +1,13 @@
from fastapi import HTTPException, APIRouter from fastapi import HTTPException, APIRouter
import subprocess import subprocess
import os import os
import re
import signal import signal
import sys
import json 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 models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
from internal.logger import create_logger 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 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_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh" OP25_SCRIPT = "run_multi-rx_service.sh"
_PGID_FILE = "/tmp/op25.pgid" _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: def _save_pgid(pgid: int) -> None:
@@ -27,15 +42,60 @@ def _read_pgid():
return None 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: def _is_running() -> bool:
pgid = _read_pgid() pgid = _read_pgid()
if pgid is None: if pgid is None:
return False return False
_reap()
return _multi_rx_alive(pgid)
def _kill_group(pgid: int) -> None:
try: try:
os.killpg(pgid, 0) os.killpg(pgid, signal.SIGTERM)
return True
except OSError: 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(): def create_op25_router():
@@ -43,11 +103,28 @@ def create_op25_router():
@router.post("/start") @router.post("/start")
async def start_op25(): async def start_op25():
global _proc
if _is_running(): if _is_running():
return {"status": "OP25 already 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: 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) _save_pgid(proc.pid)
threading.Thread(target=_pump, args=(proc,), daemon=True).start()
LOGGER.debug(proc) LOGGER.debug(proc)
return {"status": "OP25 started"} return {"status": "OP25 started"}
except Exception as e: except Exception as e:
@@ -56,10 +133,10 @@ def create_op25_router():
@router.post("/stop") @router.post("/stop")
async def stop_op25(): async def stop_op25():
pgid = _read_pgid() pgid = _read_pgid()
if pgid is None or not _is_running(): if pgid is None:
return {"status": "OP25 is not running"} return {"status": "OP25 is not running"}
try: try:
os.killpg(pgid, signal.SIGTERM) _kill_group(pgid)
os.remove(_PGID_FILE) os.remove(_PGID_FILE)
return {"status": "OP25 stopped"} return {"status": "OP25 stopped"}
except Exception as e: except Exception as e:
@@ -67,7 +144,36 @@ def create_op25_router():
@router.get("/status") @router.get("/status")
async def 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") @router.post("/generate-config")
async def generate_config(generator: ConfigGenerator): async def generate_config(generator: ConfigGenerator):
@@ -85,7 +191,7 @@ def create_op25_router():
meta_stream_name="stream_0" meta_stream_name="stream_0"
)] )]
devices = [DeviceConfig(gains=preset["gains"], ppm=ppm)] devices = [DeviceConfig(gains=preset["gains"], ppm=ppm)]
save_talkgroup_tags(generator.tags) save_talkgroup_tags(generator.tags or [])
save_whitelist(generator.whitelist) save_whitelist(generator.whitelist)
has_talkgroups = bool(generator.whitelist) has_talkgroups = bool(generator.whitelist)
trunking = TrunkingConfig( trunking = TrunkingConfig(
@@ -123,30 +229,48 @@ def create_op25_router():
"terminal": terminal.dict() "terminal": terminal.dict()
} }
elif generator.type == DecodeMode.ANALOG: 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( channels = [ChannelConfig(
channelName=generator.systemName, name=generator.systemName,
enableAnalog="on", trunking_sysname="",
demodType="fsk4", enable_analog="on",
frequency=generator.frequency, demod_type="fsk4",
filterType="widepulse", filter_type="rc",
nbfmSquelch=generator.nbfmSquelch 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 = { config_dict = {
"channels": [channel.dict() for channel in channels], "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: 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) 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
+9
View File
@@ -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. # The trap command ensures that SIGTERM is passed to the background jobs.
trap "kill $MULTI_RX_PID $LIQ_PID" SIGTERM SIGINT trap "kill $MULTI_RX_PID $LIQ_PID" SIGTERM SIGINT
wait $MULTI_RX_PID 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
+54
View File
@@ -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"]
+20
View File
@@ -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
+13
View File
@@ -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 "$@"
+3
View File
@@ -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}