Ria Composer Support #23

Merged
benchinnery merged 3 commits from zfp-oss into main 2026-04-17 10:11:56 -04:00
2 changed files with 197 additions and 5 deletions
Showing only changes of commit 638fe5df1f - Show all commits

View File

@ -32,17 +32,22 @@ def _make_mock_sdr():
class TestSetRadio: class TestSetRadio:
def _pluto_module(self, mock_sdr):
mod = MagicMock()
mod.Pluto = MagicMock(return_value=mock_sdr)
return mod
def test_pluto_alias(self): def test_pluto_alias(self):
tx = RemoteTransmitter() tx = RemoteTransmitter()
mock_sdr = _make_mock_sdr() mock_sdr = _make_mock_sdr()
with patch("ria_toolkit_oss.sdr.pluto.Pluto", return_value=mock_sdr): with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.pluto": self._pluto_module(mock_sdr)}):
tx.set_radio("pluto", "ip:192.168.2.1") tx.set_radio("pluto", "ip:192.168.2.1")
assert tx._sdr is mock_sdr assert tx._sdr is mock_sdr
def test_plutosdr_alias(self): def test_plutosdr_alias(self):
tx = RemoteTransmitter() tx = RemoteTransmitter()
mock_sdr = _make_mock_sdr() mock_sdr = _make_mock_sdr()
with patch("ria_toolkit_oss.sdr.pluto.Pluto", return_value=mock_sdr): with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.pluto": self._pluto_module(mock_sdr)}):
tx.set_radio("PlutoSDR", "ip:192.168.2.1") tx.set_radio("PlutoSDR", "ip:192.168.2.1")
assert tx._sdr is mock_sdr assert tx._sdr is mock_sdr
@ -78,10 +83,20 @@ class TestSetRadio:
tx.set_radio("blade", "") tx.set_radio("blade", "")
assert tx._sdr is mock_sdr assert tx._sdr is mock_sdr
def test_bladerf_string_alias(self):
"""'bladerf' string (not 'blade') must also resolve to blade.Blade."""
tx = RemoteTransmitter()
mock_sdr = _make_mock_sdr()
mock_module = MagicMock()
mock_module.Blade = MagicMock(return_value=mock_sdr)
with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.blade": mock_module}):
tx.set_radio("bladerf", "")
assert tx._sdr is mock_sdr
def test_case_insensitive(self): def test_case_insensitive(self):
tx = RemoteTransmitter() tx = RemoteTransmitter()
mock_sdr = _make_mock_sdr() mock_sdr = _make_mock_sdr()
with patch("ria_toolkit_oss.sdr.pluto.Pluto", return_value=mock_sdr): with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.pluto": self._pluto_module(mock_sdr)}):
tx.set_radio("PLUTO", "ip:192.168.2.1") tx.set_radio("PLUTO", "ip:192.168.2.1")
assert tx._sdr is mock_sdr assert tx._sdr is mock_sdr
@ -91,8 +106,12 @@ class TestSetRadio:
tx.set_radio("nonexistent_radio") tx.set_radio("nonexistent_radio")
def test_import_error_raises_runtime(self): def test_import_error_raises_runtime(self):
"""ImportError during SDR driver load is re-raised as RuntimeError."""
tx = RemoteTransmitter() tx = RemoteTransmitter()
with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.pluto": None}): # Inject a fake module whose Pluto class raises ImportError on import
bad_module = MagicMock()
bad_module.Pluto = MagicMock(side_effect=ImportError("pyadi-iio not installed"))
with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.pluto": bad_module}):
with pytest.raises((RuntimeError, ImportError)): with pytest.raises((RuntimeError, ImportError)):
tx.set_radio("pluto") tx.set_radio("pluto")
@ -209,7 +228,9 @@ class TestRunFunction:
def test_set_radio_success(self): def test_set_radio_success(self):
tx = RemoteTransmitter() tx = RemoteTransmitter()
mock_sdr = _make_mock_sdr() mock_sdr = _make_mock_sdr()
with patch("ria_toolkit_oss.sdr.pluto.Pluto", return_value=mock_sdr): mod = MagicMock()
mod.Pluto = MagicMock(return_value=mock_sdr)
with patch.dict("sys.modules", {"ria_toolkit_oss.sdr.pluto": mod}):
resp = tx.run_function({"function_name": "set_radio", "radio_str": "pluto", "identifier": "ip:1.2.3.4"}) resp = tx.run_function({"function_name": "set_radio", "radio_str": "pluto", "identifier": "ip:1.2.3.4"})
assert resp["status"] is True assert resp["status"] is True

View File

@ -389,3 +389,174 @@ class TestRunWithSdrRemote:
# Both must appear # Both must appear
assert "init_sdr" in call_order assert "init_sdr" in call_order
assert "init_remote_tx" in call_order assert "init_remote_tx" in call_order
# ---------------------------------------------------------------------------
# Additional coverage gaps
# ---------------------------------------------------------------------------
class TestTransmitBufferAndTimeout:
"""Verify the exact buffer and timeout constants used in start/stop."""
def _executor_with_ctrl(self):
from ria_toolkit_oss.orchestration.executor import CampaignExecutor
cfg = CampaignConfig.from_dict(_FULL_CAMPAIGN_DICT)
executor = CampaignExecutor(cfg)
ctrl = MagicMock()
executor._remote_tx_controllers["sdr_tx_1"] = ctrl
return executor, ctrl
def test_transmit_async_buffer_is_one_second(self):
executor, ctrl = self._executor_with_ctrl()
tx = executor.config.transmitters[0]
step = tx.schedule[0] # duration = 10s
executor._start_transmitter(tx, step)
duration_arg = ctrl.transmit_async.call_args[0][0]
assert duration_arg == pytest.approx(step.duration + 1.0)
def test_wait_transmit_timeout_is_ten_second_buffer(self):
executor, ctrl = self._executor_with_ctrl()
tx = executor.config.transmitters[0]
step = tx.schedule[0] # duration = 10s
executor._stop_transmitter(tx, step)
timeout = ctrl.wait_transmit.call_args[1]["timeout"]
assert timeout == pytest.approx(step.duration + 10.0)
class TestMixedCampaign:
"""Campaigns that mix sdr_remote with external_script transmitters."""
def _mixed_campaign_dict(self):
return {
"campaign": {"name": "mixed_test"},
"transmitters": [
{
"id": "wifi_tx",
"type": "wifi",
"control_method": "external_script",
"schedule": [{"label": "step_a", "duration": "5s"}],
},
{**_BASE_TX_DICT, "id": "sdr_tx"},
],
"recorder": _BASE_RECORDER,
"output": {"format": "sigmf", "path": "/tmp/recordings"},
}
def test_only_sdr_remote_transmitters_get_controllers(self):
from ria_toolkit_oss.orchestration.executor import CampaignExecutor
cfg = CampaignConfig.from_dict(self._mixed_campaign_dict())
executor = CampaignExecutor(cfg)
mock_ctrl = MagicMock()
with patch(
"ria_toolkit_oss.remote_control.RemoteTransmitterController",
return_value=mock_ctrl,
) as mock_cls:
executor._init_remote_tx_controllers()
mock_cls.assert_called_once() # only the sdr_remote one
assert "sdr_tx" in executor._remote_tx_controllers
assert "wifi_tx" not in executor._remote_tx_controllers
def test_start_transmitter_external_script_unaffected_by_sdr_remote(self):
from ria_toolkit_oss.orchestration.executor import CampaignExecutor
cfg = CampaignConfig.from_dict(self._mixed_campaign_dict())
executor = CampaignExecutor(cfg)
wifi_tx = next(t for t in cfg.transmitters if t.id == "wifi_tx")
step = wifi_tx.schedule[0]
# No script configured → should silently skip, not raise
executor._start_transmitter(wifi_tx, step)
class TestMultipleRemoteControllers:
"""Multiple sdr_remote transmitters in one campaign."""
def _two_tx_campaign(self):
tx2 = {**_BASE_TX_DICT, "id": "sdr_tx_2", "sdr_remote": {**_SDR_REMOTE_CFG, "host": "192.168.1.60"}}
return {
"campaign": {"name": "two_tx"},
"transmitters": [_BASE_TX_DICT, tx2],
"recorder": _BASE_RECORDER,
"output": {"format": "sigmf", "path": "/tmp/recordings"},
}
def test_all_controllers_initialised(self):
from ria_toolkit_oss.orchestration.executor import CampaignExecutor
cfg = CampaignConfig.from_dict(self._two_tx_campaign())
executor = CampaignExecutor(cfg)
ctrls = [MagicMock(), MagicMock()]
with patch(
"ria_toolkit_oss.remote_control.RemoteTransmitterController",
side_effect=ctrls,
):
executor._init_remote_tx_controllers()
assert len(executor._remote_tx_controllers) == 2
assert "sdr_tx_1" in executor._remote_tx_controllers
assert "sdr_tx_2" in executor._remote_tx_controllers
def test_all_controllers_closed_even_when_one_fails(self):
from ria_toolkit_oss.orchestration.executor import CampaignExecutor
cfg = CampaignConfig.from_dict(self._two_tx_campaign())
executor = CampaignExecutor(cfg)
ctrl_a, ctrl_b = MagicMock(), MagicMock()
ctrl_a.close.side_effect = RuntimeError("ssh gone")
executor._remote_tx_controllers = {"sdr_tx_1": ctrl_a, "sdr_tx_2": ctrl_b}
executor._close_remote_tx_controllers() # must not raise
ctrl_a.close.assert_called_once()
ctrl_b.close.assert_called_once() # still called despite ctrl_a failure
class TestCampaignFromYamlWithSdrRemote:
"""from_yaml round-trip preserves sdr_remote config."""
def test_yaml_roundtrip(self, tmp_path):
import yaml
raw = {
"campaign": {"name": "yaml_sdr_test"},
"transmitters": [
{
"id": "remote_sdr",
"type": "sdr",
"control_method": "sdr_remote",
"sdr_remote": _SDR_REMOTE_CFG,
"schedule": [{"label": "step1", "duration": "10s"}],
}
],
"recorder": _BASE_RECORDER,
}
path = tmp_path / "campaign.yml"
path.write_text(yaml.dump(raw))
cfg = CampaignConfig.from_yaml(str(path))
tx = cfg.transmitters[0]
assert tx.control_method == "sdr_remote"
assert tx.sdr_remote["host"] == "192.168.1.50"
assert tx.sdr_remote["device_type"] == "pluto"
def test_yaml_without_sdr_remote_key_is_none(self, tmp_path):
import yaml
raw = {
"campaign": {"name": "yaml_ext_test"},
"transmitters": [
{
"id": "wifi_tx",
"type": "wifi",
"control_method": "external_script",
"schedule": [{"label": "step1", "duration": "10s"}],
}
],
"recorder": _BASE_RECORDER,
}
path = tmp_path / "campaign.yml"
path.write_text(yaml.dump(raw))
cfg = CampaignConfig.from_yaml(str(path))
assert cfg.transmitters[0].sdr_remote is None