Push Tracker
ria-toolkit-oss/tests/agent/test_streamer.py
J jrhughes003 bf64604bcf feat(agent): agent-owned device discovery, identifiers, and udev install
Make `ria-agent stream` work with any SDR the agent has drivers for, with
no per-device config in the hub:

- heartbeat advertises rich `hardware` entries {device, identifier, label,
  connected} via hardware.detect_devices(); USRP is enumerated into concrete
  instances (uhd_find_devices), others advertise driver-only entries. The
  identifier is chosen to round-trip through parse_ident (None=auto-select or
  name=...), so a device address is never a bare value.
- ship udev rules (Pluto/RTL-SDR/HackRF/USRP B2x0/bladeRF) + `ria-agent
  install-udev` so USB radios open without sudo — one privileged step, all
  inside the toolkit.
- streamer surfaces a "run: sudo ria-agent install-udev" hint on USB
  permission errors instead of the cryptic UHD message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:03:14 -04:00

219 lines
7.1 KiB
Python
Raw RIA Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for the streamer: drive it with a fake WsClient + MockSDR."""
from __future__ import annotations
import asyncio
import numpy as np
from ria_toolkit_oss.agent.streamer import (
Streamer,
_apply_sdr_config,
_friendly_sdr_error,
_samples_to_interleaved_float32,
)
from ria_toolkit_oss.sdr.mock import MockSDR
def test_friendly_sdr_error_adds_udev_hint_on_permission():
msg = _friendly_sdr_error("usrp", RuntimeError("USB open failed: insufficient permissions."))
assert "install-udev" in msg
assert "insufficient permissions" in msg
def test_friendly_sdr_error_passes_through_other_errors():
msg = _friendly_sdr_error("usrp", RuntimeError("No USRP device found for identifier 'name=x'"))
assert "install-udev" not in msg
assert "No USRP device found" in msg
class FakeWs:
def __init__(self):
self.json_sent: list[dict] = []
self.bytes_sent: list[bytes] = []
async def send_json(self, payload: dict) -> None:
self.json_sent.append(payload)
async def send_bytes(self, data: bytes) -> None:
self.bytes_sent.append(data)
def _factory(device: str, identifier):
return MockSDR(buffer_size=32, seed=0)
def test_samples_to_interleaved_float32_roundtrip():
c = np.array([1 + 2j, 3 + 4j], dtype=np.complex64)
raw = _samples_to_interleaved_float32(c)
arr = np.frombuffer(raw, dtype=np.float32)
assert arr.tolist() == [1.0, 2.0, 3.0, 4.0]
def test_apply_sdr_config_sets_attributes():
sdr = MockSDR(buffer_size=16)
_apply_sdr_config(sdr, {"sample_rate": 2e6, "center_frequency": 9.15e8, "gain": 30})
assert sdr.sample_rate == 2e6
assert sdr.center_freq == 9.15e8
assert sdr.gain == 30
def test_heartbeat_reflects_status_and_app():
async def scenario():
s = Streamer(ws=FakeWs(), sdr_factory=_factory)
hb = s.build_heartbeat()
assert hb["type"] == "heartbeat"
assert hb["status"] == "idle"
# capabilities default to rx-only
assert hb["capabilities"] == ["rx"]
assert hb["tx_enabled"] is False
await s.on_message(
{
"type": "start",
"app_id": "app-42",
"radio_config": {"device": "mock", "buffer_size": 32},
}
)
hb2 = s.build_heartbeat()
assert hb2["status"] == "streaming"
assert hb2["app_id"] == "app-42"
assert hb2["sessions"]["rx"]["app_id"] == "app-42"
await s.on_message({"type": "stop", "app_id": "app-42"})
asyncio.run(scenario())
def test_full_start_stream_stop_cycle():
async def scenario():
ws = FakeWs()
streamer = Streamer(ws=ws, sdr_factory=_factory)
await streamer.on_message(
{
"type": "start",
"app_id": "abc",
"radio_config": {
"device": "mock",
"sample_rate": 1_000_000,
"center_frequency": 2.45e9,
"gain": 40,
"buffer_size": 64,
},
}
)
for _ in range(30):
if len(ws.bytes_sent) >= 2:
break
await asyncio.sleep(0.02)
await streamer.on_message({"type": "stop", "app_id": "abc"})
return ws, streamer
ws, streamer = asyncio.run(scenario())
assert len(ws.bytes_sent) >= 1
for frame in ws.bytes_sent:
assert len(frame) == 64 * 2 * 4 # 64 samples × (I,Q) × float32
statuses = [m for m in ws.json_sent if m.get("type") == "status"]
assert statuses[0]["status"] == "streaming"
assert statuses[-1]["status"] == "idle"
assert streamer._rx is None
def test_start_without_device_emits_error():
async def scenario():
ws = FakeWs()
streamer = Streamer(ws=ws, sdr_factory=_factory)
await streamer.on_message({"type": "start", "app_id": "x", "radio_config": {}})
return ws
ws = asyncio.run(scenario())
errors = [m for m in ws.json_sent if m.get("type") == "error"]
assert errors and "device" in errors[0]["message"]
def test_configure_queues_update():
async def scenario():
streamer = Streamer(ws=FakeWs(), sdr_factory=_factory)
await streamer.on_message({"type": "configure", "app_id": "x", "radio_config": {"center_frequency": 915e6}})
# Before start(), pending config lives on the standalone dict exposed via the _pending_config shim.
return streamer._pending_config
pending = asyncio.run(scenario())
assert pending == {"center_frequency": 915e6}
def test_unknown_message_type_is_ignored():
async def scenario():
s = Streamer(ws=FakeWs(), sdr_factory=_factory)
await s.on_message({"type": "nope"})
asyncio.run(scenario())
def test_tx_data_available_is_a_silent_noop():
# Hub sends this as a keepalive; we should accept and ignore without
# emitting a WARNING or treating it as an error.
async def scenario():
ws = FakeWs()
s = Streamer(ws=ws, sdr_factory=_factory)
await s.on_message({"type": "tx_data_available", "app_id": "x"})
return ws
ws = asyncio.run(scenario())
# No outbound frames emitted.
assert ws.json_sent == []
assert ws.bytes_sent == []
def test_registry_shares_sdr_across_start_stop_cycles():
# Two sequential start/stop cycles with the same (device, identifier)
# should hit the registry's cache path rather than constructing a new SDR.
built: list[MockSDR] = []
def counting_factory(device: str, identifier):
sdr = MockSDR(buffer_size=16, seed=0)
built.append(sdr)
return sdr
async def scenario():
s = Streamer(ws=FakeWs(), sdr_factory=counting_factory)
for _ in range(2):
await s.on_message(
{
"type": "start",
"app_id": "a",
"radio_config": {"device": "mock", "buffer_size": 16},
}
)
# Let one capture buffer flow before stopping so the loop is engaged.
await asyncio.sleep(0.02)
await s.on_message({"type": "stop", "app_id": "a"})
asyncio.run(scenario())
# A new SDR per cycle (we fully close between starts) — registry refcount
# drops to zero on each stop. This test confirms close-and-rebuild works;
# the ref-counting share-while-open case is covered in the full-duplex tests.
assert len(built) == 2
def test_tx_start_rejected_when_tx_disabled():
from ria_toolkit_oss.agent.config import AgentConfig
async def scenario():
ws = FakeWs()
s = Streamer(ws=ws, sdr_factory=_factory, cfg=AgentConfig(tx_enabled=False))
await s.on_message(
{
"type": "tx_start",
"app_id": "a",
"radio_config": {"device": "mock", "tx_center_frequency": 2.45e9, "tx_gain": -20},
}
)
return ws
ws = asyncio.run(scenario())
tx_statuses = [m for m in ws.json_sent if m.get("type") == "tx_status"]
assert tx_statuses, "expected a tx_status frame"
assert tx_statuses[-1]["state"] == "error"
assert "disabled" in tx_statuses[-1]["message"].lower()