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>
92 lines
3.9 KiB
Python
92 lines
3.9 KiB
Python
"""
|
|
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"}
|