From 3e6e7f40830815cb560b09bd1dbe3a6f05105940 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:39:48 -0500 Subject: [PATCH 01/62] test(MOR-4274): pin scope session demand and restore contract Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 115 +++++++++++++++++++++++++ tests/scope/test_radio_scope_source.py | 21 +++++ 2 files changed, 136 insertions(+) create mode 100644 tests/runtime/test_scope_session.py diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py new file mode 100644 index 000000000..d811c9d57 --- /dev/null +++ b/tests/runtime/test_scope_session.py @@ -0,0 +1,115 @@ + +import pytest + +from rigplane.core.types import ScopeFixedEdge +from rigplane.runtime.scope_session import ScopeSession, ScopeState, ScopeView +from rigplane.scope.models import ScopeDemand +from rigplane.scope import ScopeFrame + + +class Writer: + def __init__(self): + self.state = ScopeState(False, False) + self.views = [] + self.writes = [] + self.fail = False + + async def _scope_read_state(self): + return self.state + + async def _scope_write_state(self, previous, desired, *, policy, timeout): + if previous.scope_on != desired.scope_on: + self.writes.append((0x10, desired.scope_on)) + if previous.output_on != desired.output_on: + self.writes.append((0x11, desired.output_on)) + if self.fail: + raise RuntimeError("no acknowledgement") + self.state = desired + + async def _scope_read_view(self): + return ScopeView(0, 0, 2, 1) + + async def _scope_write_view(self, view): + self.views.append(view) + + async def _scope_read_fixed(self, range_index, edge): + return ScopeFixedEdge(range_index, edge, 14000000, 14350000) + + +async def test_one_enable_exact_restore_once(): + writer = Writer() + session = ScopeSession(writer) + await session.set_inputs(ready=True, tx=False) + await session.set_demand(ScopeDemand(1, 1)) + await session.set_demand(ScopeDemand(2, 2)) + assert session.enabled + assert writer.writes == [(0x10, True), (0x11, True)] + await session.set_demand(ScopeDemand(1, 1)) + await session.set_demand(ScopeDemand(0, 0)) + await session.set_demand(ScopeDemand(0, 0)) + assert writer.writes[-2:] == [(0x10, False), (0x11, False)] + assert len(writer.writes) == 4 + assert not session.enabled + + +async def test_deferred_enable_and_tx_have_no_writes(): + writer = Writer() + session = ScopeSession(writer) + await session.set_demand(ScopeDemand(1, 1)) + assert not session.enabled + assert not writer.writes + await session.set_inputs(ready=True, tx=True) + assert not writer.writes + await session.set_inputs(ready=True, tx=False) + await session.set_inputs(ready=True, tx=False) + assert writer.writes == [(0x10, True), (0x11, True)] + + +async def test_enabled_requires_acknowledgement(): + writer = Writer() + writer.fail = True + session = ScopeSession(writer) + await session.set_inputs(ready=True, tx=False) + with pytest.raises(RuntimeError, match="acknowledgement"): + await session.set_demand(ScopeDemand(1, 1)) + assert not session.enabled + + +async def test_pause_only_output_resume_once_and_release_restores(virtual_clock): + writer = Writer() + writer.state = ScopeState(True, False) + session = ScopeSession(writer, clock=virtual_clock) + await session.set_inputs(ready=True, tx=False) + await session.set_demand(ScopeDemand(2, 2)) + await session.set_demand(ScopeDemand(2, 0)) + await session.set_demand(ScopeDemand(1, 0)) + assert writer.writes == [(0x11, True), (0x11, False)] + started = virtual_clock() + await session.set_demand(ScopeDemand(1, 1)) + assert virtual_clock() - started <= 0.1 + assert writer.writes[-1] == (0x11, True) + await session.set_demand(ScopeDemand(0, 0)) + assert writer.state == ScopeState(True, False) + assert writer.writes == [(0x11, True), (0x11, False), (0x11, True), (0x11, False)] + + +async def test_view_and_touched_fixed_slot_restored(): + writer = Writer() + session = ScopeSession(writer) + await session.set_inputs(ready=True, tx=False) + await session.set_demand(ScopeDemand(1, 1)) + fixed = ScopeFixedEdge(7, 1, 14050000, 14150000) + await session.set_fixed(fixed, 0) + await session.set_fixed(fixed, 0) + assert len(writer.views) == 1 + await session.set_demand(ScopeDemand(0, 0)) + assert writer.views[-1] == ScopeView(0, 0, 2, 1) + assert writer.views[-2].fixed_edge == ScopeFixedEdge(7, 1, 14000000, 14350000) + + +async def test_oor_is_arrival_never_hidden(virtual_clock): + session = ScopeSession(Writer(), clock=virtual_clock, silence_seconds=1) + await session.set_inputs(ready=True, tx=False) + await session.set_demand(ScopeDemand(1, 1)) + session.on_frame(ScopeFrame(0, 1, 14000000, 14350000, b"", True)) + assert session.health(civ_flowing=True).hidden is False diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index a4847ef8b..42ba62ca5 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -172,3 +172,24 @@ async def test_native_width_is_observed_from_frames(model, width): ) assert source.capabilities().native_width_for(0) == width + 11 await source.stop() + + +async def test_fixed_edges_are_negotiated_from_profile_data(): + class FixedWriter(Writer): + async def set_fixed(self, fixed_edge, receiver): + self.writes.append((fixed_edge, receiver)) + + from rigplane.core.types import ScopeFixedEdge + + profile = load_rig(Path("rigs/ic7610.toml")).to_profile() + writer = FixedWriter() + source = RadioScopeSource(profile, writer) + request = View(14_050_000, 14_150_000) + grant = await source.set_view(request, 0) + assert "fixed" in source.capabilities().view_modes + assert grant.coverage == Coverage(request.start_hz, request.end_hz) + assert writer.writes[-1] == (ScopeFixedEdge(6, 1, 14_050_000, 14_150_000), 0) + # The 11 MHz range boundary crosses two profile entries. + request = View(10_950_000, 11_050_000) + await source.set_view(request, 0) + assert isinstance(writer.writes[-1][0], float) From b7e869fb2a2e8dbe511168c6a8d2688ceccdaa6b Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:40:05 -0500 Subject: [PATCH 02/62] test(MOR-4274): pin profile fixed-edge ranges Co-Authored-By: Claude Opus 5.5 --- tests/test_rig_loader.py | 28 ++++++++++++++++++++++++ tests/test_scope.py | 46 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/test_rig_loader.py b/tests/test_rig_loader.py index 3f68f6149..839d377b3 100644 --- a/tests/test_rig_loader.py +++ b/tests/test_rig_loader.py @@ -3024,6 +3024,34 @@ def test_shipped_scope_rig_declares_the_eight_icom_presets(self, name): assert rig.to_profile().scope_span_presets_hz == _EXPECTED_SPAN_PRESETS_HZ +class TestScopeFixedEdgeRanges: + def test_shipped_profiles_preserve_typed_fixed_edge_ranges(self): + from rigplane.core.types import ScopeFixedEdgeRange + + rig = load_rig(RIGS_DIR / "ic7610.toml") + assert len(rig.scope_fixed_edge_ranges) == 12 + assert rig.scope_fixed_edge_ranges[0] == ScopeFixedEdgeRange(1, 30_000, 1_600_000) + assert rig.scope_fixed_edge_ranges[5] == ScopeFixedEdgeRange(6, 11_000_000, 15_000_000) + assert rig.scope_fixed_edge_ranges[-1] == ScopeFixedEdgeRange(12, 45_000_000, 60_000_000) + assert rig.to_profile().scope_fixed_edge_ranges == rig.scope_fixed_edge_ranges + center = load_rig(RIGS_DIR / "ic7300.toml") + assert center.scope_fixed_edge_ranges == () + assert center.to_profile().scope_fixed_edge_ranges == () + + @pytest.mark.parametrize("ranges", [ + '[]', '[{range_index = 0, start_hz = 1000, end_hz = 2000}]', + '[{range_index = 1, start_hz = 2000, end_hz = 1000}]', + '[{range_index = true, start_hz = 1000, end_hz = 2000}]', + '[{range_index = 1, start_hz = 1000.0, end_hz = 2000}]', + '[{range_index = 1, start_hz = 1000, end_hz = 2000}, {range_index = 1, start_hz = 2000, end_hz = 3000}]', + '[{range_index = 1, start_hz = 1000, end_hz = 2500}, {range_index = 2, start_hz = 2000, end_hz = 3000}]', + ]) + def test_rejects_invalid_ranges(self, tmp_path, ranges): + text = _MINIMAL_TOML + f'\n[scope]\nfixed_edge_ranges = {ranges}\n' + with pytest.raises(RigLoadError, match="fixed_edge_ranges"): + load_rig(_write_toml(tmp_path, text)) + + class TestAudioPolicy: """Per-profile [audio] codec and sample-rate policy (#1470).""" diff --git a/tests/test_scope.py b/tests/test_scope.py index 298e90e24..09c8dbd06 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -928,7 +928,8 @@ def test_get_scope_fixed_edge_rejects_bad_edge(self, cmd_map) -> None: def test_scope_set_fixed_edge(self, cmd_map) -> None: frame = scope_set_fixed_edge( - edge=4, start_hz=14_000_000, end_hz=14_350_000, cmd_map=cmd_map + edge=4, start_hz=14_000_000, end_hz=14_350_000, cmd_map=cmd_map, + ranges=load_rig(RIG_DIR / "ic7610.toml").scope_fixed_edge_ranges, ) assert frame[4] == 0x27 assert frame[5] == 0x1E @@ -937,6 +938,49 @@ def test_scope_set_fixed_edge(self, cmd_map) -> None: assert frame[8:13] == bcd_encode(14_000_000) assert frame[13:18] == bcd_encode(14_350_000) + @pytest.mark.parametrize("start,end,code", [ + (1_800_000, 1_900_000, 2), (3_500_000, 3_800_000, 3), + (7_100_000, 7_200_000, 4), (14_200_000, 14_350_000, 6), + (50_000_000, 50_500_000, 12), (1_500_000, 1_600_000, 1), + (1_600_000, 1_700_000, 2), (1_900_000, 2_000_000, 2), + (2_000_000, 2_100_000, 3), + ]) + def test_fixed_edge_range_comes_from_profile(self, cmd_map, start, end, code): + rig = load_rig(RIG_DIR / "ic7610.toml") + frame = scope_set_fixed_edge( + edge=1, start_hz=start, end_hz=end, ranges=rig.scope_fixed_edge_ranges, + cmd_map=cmd_map, + ) + assert frame[6] == _bcd_byte(code) + + def test_fixed_edge_without_profile_requires_explicit_selector(self, cmd_map): + with pytest.raises(ValueError, match="profile range"): + scope_set_fixed_edge( + edge=1, start_hz=14_000_000, end_hz=14_350_000, cmd_map=cmd_map, + ) + frame = scope_set_fixed_edge( + edge=1, start_hz=14_000_000, end_hz=14_350_000, range_index=6, + cmd_map=cmd_map, + ) + assert frame[6] == 6 + + @pytest.mark.parametrize("start,end", [(1_500_000, 1_700_000), (29_000, 31_000), (59_900_000, 60_001_000)]) + def test_fixed_edge_rejects_view_outside_one_profile_range(self, cmd_map, start, end): + rig = load_rig(RIG_DIR / "ic7610.toml") + with pytest.raises(ValueError, match="profile range"): + scope_set_fixed_edge( + edge=1, start_hz=start, end_hz=end, ranges=rig.scope_fixed_edge_ranges, + cmd_map=cmd_map, + ) + + def test_center_only_profile_does_not_inherit_fixed_ranges(self, cmd_map): + rig = load_rig(RIG_DIR / "ic7300.toml") + with pytest.raises(ValueError, match="profile range"): + scope_set_fixed_edge( + edge=1, start_hz=14_000_000, end_hz=14_350_000, + ranges=rig.scope_fixed_edge_ranges, cmd_map=cmd_map, + ) + def test_get_scope_rbw(self, cmd_map) -> None: frame = get_scope_rbw(cmd_map=cmd_map) assert frame == b"\xfe\xfe\x98\xe0\x27\x1f\xfd" From facbab3411887f5137ef87aa789d9f7cfd250cfb Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:40:44 -0500 Subject: [PATCH 03/62] test(MOR-4274): specify shared scope session state transitions Co-Authored-By: Claude Opus 5.5 --- tests/test_scope_session.py | 90 +++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/test_scope_session.py diff --git a/tests/test_scope_session.py b/tests/test_scope_session.py new file mode 100644 index 000000000..64ca8ff00 --- /dev/null +++ b/tests/test_scope_session.py @@ -0,0 +1,90 @@ +"""Scope ownership and acknowledged state transitions.""" + +from dataclasses import replace + +import pytest + +from rigplane.runtime.scope_session import ScopeSession, ScopeState + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["serial", "lan"]) +async def test_shared_demand_ack_restore_and_pause(transport): + original = ScopeState(False, False, 0, 2, 1) + writes = [] + + async def read(): + return original + + async def write(name, value, receiver): + writes.append((name, value, receiver)) + + session = ScopeSession(read, write) + await session.set_ready(False) + await session.demand("web", True) + assert not writes and not session.enabled + await session.set_ready(True) + assert writes == [("scope_on", True, 0), ("output_on", True, 0)] + await session.demand("sdk", True) + assert len(writes) == 2 + await session.configure(mode=1, span=4) + await session.demand("web", False) + assert session.enabled + await session.demand("sdk", False) + assert session.observed == original + restored = list(writes) + await session.demand("sdk", False) + assert writes == restored + + +@pytest.mark.asyncio +async def test_no_writes_during_tx_and_enabled_waits_for_ack(): + import asyncio + + ack = asyncio.Event() + writes = [] + + async def read(): + return ScopeState(False, False, 0, 0, 1) + + async def write(name, value, receiver): + writes.append(name) + if name == "output_on" and value: + await ack.wait() + + session = ScopeSession(read, write) + await session.set_tx(True) + await session.demand("web", True) + assert not writes + task = asyncio.create_task(session.set_tx(False)) + from rigplane.testing.timing import await_until + + assert await await_until(lambda: "output_on" in writes) + assert not session.enabled + ack.set() + await task + assert session.enabled + + +@pytest.mark.asyncio +async def test_reconnect_retains_original_capture(): + original = ScopeState(False, False, 0, 1, 1) + current = original + captures = 0 + + async def read(): + nonlocal captures + captures += 1 + return current + + async def write(name, value, receiver): + nonlocal current + current = replace(current, **{name: value}) + + session = ScopeSession(read, write) + await session.demand("web", True) + await session.invalidate() + await session.set_ready(True) + await session.demand("web", False) + assert captures == 1 + assert current == original From 06de09f403cbb2af35076aef09ccdf463200296d Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:41:21 -0500 Subject: [PATCH 04/62] test(MOR-4274): cover wire lifecycle and websocket pause integration Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index d811c9d57..882fccfb2 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -113,3 +113,118 @@ async def test_oor_is_arrival_never_hidden(virtual_clock): await session.set_demand(ScopeDemand(1, 1)) session.on_frame(ScopeFrame(0, 1, 14000000, 14350000, b"", True)) assert session.health(civ_flowing=True).hidden is False + + +@pytest.mark.realtime(reason="local simulator and real server websocket integration") +@pytest.mark.timeout(30) +async def test_server_simulator_pause_resume_release_counts_scope_writes(monkeypatch): + import aiohttp + from rigplane.radio import IcomRadio + from rigplane.commands import parse_civ_frame + from rigplane.scope.models import Subscription, View + from rigplane.testing.timing import await_until + from rigplane.web.protocol import encode_scope_subscription + from rigplane.web.server import WebConfig, WebServer + from rigplane_testkit.icom import MockIcomRadio + from test_web_server import _addr + + sim = MockIcomRadio(scope_stream=True) + await sim.start() + radio = IcomRadio("127.0.0.1", port=sim.control_port, + username="testuser", password="testpass", model="IC-7610") + await radio.connect() + writes = [] + send = radio._send_civ_raw + + async def record(payload, **kwargs): + frame = parse_civ_frame(payload) + if frame.command == 0x27 and frame.sub in (0x10, 0x11) and frame.data: + writes.append((frame.sub, frame.data)) + return await send(payload, **kwargs) + + monkeypatch.setattr(radio, "_send_civ_raw", record) + server = WebServer(radio, WebConfig(host="127.0.0.1", port=0)) + await server.start() + host, port = _addr(server) + try: + async with aiohttp.ClientSession() as client: + async with client.ws_connect(f"http://{host}:{port}/api/v1/scope") as ws: + async def subscribe(fps): + await ws.send_str(encode_scope_subscription( + Subscription("radio", 0, View(14_090_000, 14_110_000), 100, fps) + )) + while True: + message = await ws.receive(timeout=5) + if message.type is aiohttp.WSMsgType.TEXT: + assert '"type":"error"' not in message.data + if '"granted"' in message.data: + return + await subscribe(20) + await await_until(lambda: writes.count((0x11, b"\x01")) == 1) + initial = list(writes) + await subscribe(0) + assert writes == initial + [(0x11, b"\x00")] + await subscribe(0) + assert writes == initial + [(0x11, b"\x00")] + await subscribe(20) + assert writes == initial + [(0x11, b"\x00"), (0x11, b"\x01")] + await ws.send_json({"type": "unsubscribe", "sourceId": "radio", "receiver": 0}) + await await_until(lambda: not server._scope_enabled) + assert writes.count((0x10, b"\x01")) == 1 + assert writes.count((0x10, b"\x00")) == 1 + finally: + await server.stop() + await radio.disconnect() + await sim.stop() + + +@pytest.mark.parametrize("transport", ["lan", "serial"]) +async def test_real_scope_writer_emits_one_enable_per_transport(transport, monkeypatch): + from rigplane.radio import IcomRadio + from rigplane.backends.ic7300 import Ic7300SerialRadio + from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR + + radio = (IcomRadio("127.0.0.1", model="IC-7300") if transport == "lan" + else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200)) + radio._connected = True + writes = [] + + async def send(payload, **kwargs): + frame = parse_civ_frame(payload) + if frame.data: + writes.append((frame.command, frame.sub, frame.data)) + radio._scope_activity_event.set() + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB)) + + async def expect(payload, **kwargs): + frame = parse_civ_frame(payload) + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, + frame.command, sub=frame.sub, data=b"\x00")) + + monkeypatch.setattr(radio, "_send_civ_raw", send) + monkeypatch.setattr(radio, "_check_connected", lambda: None) + monkeypatch.setattr(radio, "_send_civ_expect", expect) + await radio.enable_scope(policy="verify") + await radio.enable_scope(policy="verify") + assert writes == [(0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x01")] + await radio.disable_scope() + await radio.disable_scope() + assert writes[-2:] == [(0x27, 0x10, b"\x00"), (0x27, 0x11, b"\x00")] + + +async def test_release_grace_cancelled_by_resume(virtual_clock): + writer = Writer() + session = ScopeSession(writer, clock=virtual_clock, disable_grace_seconds=0.25) + await session.set_inputs(ready=True, tx=False) + await session.set_demand(ScopeDemand(1, 1)) + await session.set_demand(ScopeDemand(0, 0)) + assert len(writer.writes) == 2 + virtual_clock.advance(0.1) + await session.set_demand(ScopeDemand(1, 1)) + virtual_clock.advance(1) + await session.reconcile() + assert len(writer.writes) == 2 + await session.set_demand(ScopeDemand(0, 0)) + virtual_clock.advance(0.25) + await session.reconcile() + assert writer.writes[-2:] == [(0x10, False), (0x11, False)] From a32f9a3b7f16230e0e04b38e036fcc2658c1ef17 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:42:16 -0500 Subject: [PATCH 05/62] test(MOR-4274): pin fixed edge profile resolution Co-Authored-By: Claude Opus 5.5 --- tests/scope/test_radio_scope_source.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index 42ba62ca5..b5179943a 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -193,3 +193,18 @@ async def set_fixed(self, fixed_edge, receiver): request = View(10_950_000, 11_050_000) await source.set_view(request, 0) assert isinstance(writer.writes[-1][0], float) + + +async def test_fixed_edge_quantization_is_profile_data(): + class FixedWriter(Writer): + async def set_fixed(self, fixed_edge, receiver): + self.writes.append((fixed_edge, receiver)) + + from rigplane.core.types import ScopeFixedEdge + + writer = FixedWriter() + source = RadioScopeSource(load_rig(Path("rigs/ic7610.toml")).to_profile(), writer) + request = View(14_050_123, 14_150_567) + grant = await source.set_view(request, 0) + assert writer.writes[-1] == (ScopeFixedEdge(6, 1, 14_050_000, 14_151_000), 0) + assert grant.coverage == Coverage(14_050_000, 14_151_000) From 849060ad6f411d27481695913e3a8bed816b8fc4 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:42:31 -0500 Subject: [PATCH 06/62] test(MOR-4274): pin profile fixed-edge resolution Co-Authored-By: Claude Opus 5.5 --- tests/test_rig_loader.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_rig_loader.py b/tests/test_rig_loader.py index 839d377b3..73ce8a5f3 100644 --- a/tests/test_rig_loader.py +++ b/tests/test_rig_loader.py @@ -3030,6 +3030,8 @@ def test_shipped_profiles_preserve_typed_fixed_edge_ranges(self): rig = load_rig(RIGS_DIR / "ic7610.toml") assert len(rig.scope_fixed_edge_ranges) == 12 + assert rig.scope_fixed_edge_step_hz == 1000 + assert rig.to_profile().scope_fixed_edge_step_hz == 1000 assert rig.scope_fixed_edge_ranges[0] == ScopeFixedEdgeRange(1, 30_000, 1_600_000) assert rig.scope_fixed_edge_ranges[5] == ScopeFixedEdgeRange(6, 11_000_000, 15_000_000) assert rig.scope_fixed_edge_ranges[-1] == ScopeFixedEdgeRange(12, 45_000_000, 60_000_000) @@ -3037,6 +3039,8 @@ def test_shipped_profiles_preserve_typed_fixed_edge_ranges(self): center = load_rig(RIGS_DIR / "ic7300.toml") assert center.scope_fixed_edge_ranges == () assert center.to_profile().scope_fixed_edge_ranges == () + assert center.scope_fixed_edge_step_hz is None + assert center.to_profile().scope_fixed_edge_step_hz is None @pytest.mark.parametrize("ranges", [ '[]', '[{range_index = 0, start_hz = 1000, end_hz = 2000}]', @@ -3047,10 +3051,20 @@ def test_shipped_profiles_preserve_typed_fixed_edge_ranges(self): '[{range_index = 1, start_hz = 1000, end_hz = 2500}, {range_index = 2, start_hz = 2000, end_hz = 3000}]', ]) def test_rejects_invalid_ranges(self, tmp_path, ranges): - text = _MINIMAL_TOML + f'\n[scope]\nfixed_edge_ranges = {ranges}\n' + text = _MINIMAL_TOML + f'\n[scope]\nfixed_edge_ranges = {ranges}\nfixed_edge_step_hz = 1000\n' with pytest.raises(RigLoadError, match="fixed_edge_ranges"): load_rig(_write_toml(tmp_path, text)) + @pytest.mark.parametrize("declaration", [ + 'fixed_edge_step_hz = 1000', + 'fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]', + 'fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]\nfixed_edge_step_hz = 0', + 'fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]\nfixed_edge_step_hz = true', + ]) + def test_fixed_ranges_and_positive_step_are_declared_together(self, tmp_path, declaration): + with pytest.raises(RigLoadError, match="fixed_edge"): + load_rig(_write_toml(tmp_path, _MINIMAL_TOML + f'\n[scope]\n{declaration}\n')) + class TestAudioPolicy: """Per-profile [audio] codec and sample-rate policy (#1470).""" From 70f51cdb025179f9cc97adb82a38d013c610411d Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:42:58 -0500 Subject: [PATCH 07/62] test(MOR-4274): separate behavioral wire regression evidence Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 97 ---------------------- tests/runtime/test_scope_session_wire.py | 100 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 97 deletions(-) create mode 100644 tests/runtime/test_scope_session_wire.py diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 882fccfb2..e87b345e2 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -115,103 +115,6 @@ async def test_oor_is_arrival_never_hidden(virtual_clock): assert session.health(civ_flowing=True).hidden is False -@pytest.mark.realtime(reason="local simulator and real server websocket integration") -@pytest.mark.timeout(30) -async def test_server_simulator_pause_resume_release_counts_scope_writes(monkeypatch): - import aiohttp - from rigplane.radio import IcomRadio - from rigplane.commands import parse_civ_frame - from rigplane.scope.models import Subscription, View - from rigplane.testing.timing import await_until - from rigplane.web.protocol import encode_scope_subscription - from rigplane.web.server import WebConfig, WebServer - from rigplane_testkit.icom import MockIcomRadio - from test_web_server import _addr - - sim = MockIcomRadio(scope_stream=True) - await sim.start() - radio = IcomRadio("127.0.0.1", port=sim.control_port, - username="testuser", password="testpass", model="IC-7610") - await radio.connect() - writes = [] - send = radio._send_civ_raw - - async def record(payload, **kwargs): - frame = parse_civ_frame(payload) - if frame.command == 0x27 and frame.sub in (0x10, 0x11) and frame.data: - writes.append((frame.sub, frame.data)) - return await send(payload, **kwargs) - - monkeypatch.setattr(radio, "_send_civ_raw", record) - server = WebServer(radio, WebConfig(host="127.0.0.1", port=0)) - await server.start() - host, port = _addr(server) - try: - async with aiohttp.ClientSession() as client: - async with client.ws_connect(f"http://{host}:{port}/api/v1/scope") as ws: - async def subscribe(fps): - await ws.send_str(encode_scope_subscription( - Subscription("radio", 0, View(14_090_000, 14_110_000), 100, fps) - )) - while True: - message = await ws.receive(timeout=5) - if message.type is aiohttp.WSMsgType.TEXT: - assert '"type":"error"' not in message.data - if '"granted"' in message.data: - return - await subscribe(20) - await await_until(lambda: writes.count((0x11, b"\x01")) == 1) - initial = list(writes) - await subscribe(0) - assert writes == initial + [(0x11, b"\x00")] - await subscribe(0) - assert writes == initial + [(0x11, b"\x00")] - await subscribe(20) - assert writes == initial + [(0x11, b"\x00"), (0x11, b"\x01")] - await ws.send_json({"type": "unsubscribe", "sourceId": "radio", "receiver": 0}) - await await_until(lambda: not server._scope_enabled) - assert writes.count((0x10, b"\x01")) == 1 - assert writes.count((0x10, b"\x00")) == 1 - finally: - await server.stop() - await radio.disconnect() - await sim.stop() - - -@pytest.mark.parametrize("transport", ["lan", "serial"]) -async def test_real_scope_writer_emits_one_enable_per_transport(transport, monkeypatch): - from rigplane.radio import IcomRadio - from rigplane.backends.ic7300 import Ic7300SerialRadio - from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR - - radio = (IcomRadio("127.0.0.1", model="IC-7300") if transport == "lan" - else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200)) - radio._connected = True - writes = [] - - async def send(payload, **kwargs): - frame = parse_civ_frame(payload) - if frame.data: - writes.append((frame.command, frame.sub, frame.data)) - radio._scope_activity_event.set() - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB)) - - async def expect(payload, **kwargs): - frame = parse_civ_frame(payload) - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, - frame.command, sub=frame.sub, data=b"\x00")) - - monkeypatch.setattr(radio, "_send_civ_raw", send) - monkeypatch.setattr(radio, "_check_connected", lambda: None) - monkeypatch.setattr(radio, "_send_civ_expect", expect) - await radio.enable_scope(policy="verify") - await radio.enable_scope(policy="verify") - assert writes == [(0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x01")] - await radio.disable_scope() - await radio.disable_scope() - assert writes[-2:] == [(0x27, 0x10, b"\x00"), (0x27, 0x11, b"\x00")] - - async def test_release_grace_cancelled_by_resume(virtual_clock): writer = Writer() session = ScopeSession(writer, clock=virtual_clock, disable_grace_seconds=0.25) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py new file mode 100644 index 000000000..866be51f7 --- /dev/null +++ b/tests/runtime/test_scope_session_wire.py @@ -0,0 +1,100 @@ +import pytest + + +@pytest.mark.realtime(reason="local simulator and real server websocket integration") +@pytest.mark.timeout(30) +async def test_server_simulator_pause_resume_release_counts_scope_writes(monkeypatch): + import aiohttp + from rigplane.radio import IcomRadio + from rigplane.commands import parse_civ_frame + from rigplane.scope.models import Subscription, View + from rigplane.testing.timing import await_until + from rigplane.web.protocol import encode_scope_subscription + from rigplane.web.server import WebConfig, WebServer + from rigplane_testkit.icom import MockIcomRadio + from test_web_server import _addr + + sim = MockIcomRadio(scope_stream=True) + await sim.start() + radio = IcomRadio("127.0.0.1", port=sim.control_port, + username="testuser", password="testpass", model="IC-7610") + await radio.connect() + writes = [] + send = radio._send_civ_raw + + async def record(payload, **kwargs): + frame = parse_civ_frame(payload) + if frame.command == 0x27 and frame.sub in (0x10, 0x11) and frame.data: + writes.append((frame.sub, frame.data)) + return await send(payload, **kwargs) + + monkeypatch.setattr(radio, "_send_civ_raw", record) + server = WebServer(radio, WebConfig(host="127.0.0.1", port=0)) + await server.start() + host, port = _addr(server) + try: + async with aiohttp.ClientSession() as client: + async with client.ws_connect(f"http://{host}:{port}/api/v1/scope") as ws: + async def subscribe(fps): + await ws.send_str(encode_scope_subscription( + Subscription("radio", 0, View(14_090_000, 14_110_000), 100, fps) + )) + while True: + message = await ws.receive(timeout=5) + if message.type is aiohttp.WSMsgType.TEXT: + assert '"type":"error"' not in message.data + if '"granted"' in message.data: + return + await subscribe(20) + await await_until(lambda: writes.count((0x11, b"\x01")) == 1) + initial = list(writes) + await subscribe(0) + assert writes == initial + [(0x11, b"\x00")] + await subscribe(0) + assert writes == initial + [(0x11, b"\x00")] + await subscribe(20) + assert writes == initial + [(0x11, b"\x00"), (0x11, b"\x01")] + await ws.send_json({"type": "unsubscribe", "sourceId": "radio", "receiver": 0}) + await await_until(lambda: not server._scope_enabled) + assert writes.count((0x10, b"\x01")) == 1 + assert writes.count((0x10, b"\x00")) == 1 + finally: + await server.stop() + await radio.disconnect() + await sim.stop() + + +@pytest.mark.parametrize("transport", ["lan", "serial"]) +async def test_real_scope_writer_emits_one_enable_per_transport(transport, monkeypatch): + from rigplane.radio import IcomRadio + from rigplane.backends.ic7300 import Ic7300SerialRadio + from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR + + radio = (IcomRadio("127.0.0.1", model="IC-7300") if transport == "lan" + else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200)) + radio._connected = True + writes = [] + + async def send(payload, **kwargs): + frame = parse_civ_frame(payload) + if frame.data: + writes.append((frame.command, frame.sub, frame.data)) + radio._scope_activity_event.set() + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB)) + + async def expect(payload, **kwargs): + frame = parse_civ_frame(payload) + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, + frame.command, sub=frame.sub, data=b"\x00")) + + monkeypatch.setattr(radio, "_send_civ_raw", send) + monkeypatch.setattr(radio, "_check_connected", lambda: None) + monkeypatch.setattr(radio, "_send_civ_expect", expect) + await radio.enable_scope(policy="verify") + await radio.enable_scope(policy="verify") + assert writes == [(0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x01")] + await radio.disable_scope() + await radio.disable_scope() + assert writes[-2:] == [(0x27, 0x10, b"\x00"), (0x27, 0x11, b"\x00")] + + From 627254103d05c757717c6e210eaa7e7988ba12cf Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:54:15 -0500 Subject: [PATCH 08/62] test(MOR-4274): reconcile scope session demand and lifecycle contract Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 46 ++++++++++++-------------- tests/scope/test_radio_scope_source.py | 6 ++-- tests/test_scope_session.py | 17 +++++----- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index e87b345e2..daf3212c5 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -2,9 +2,10 @@ import pytest from rigplane.core.types import ScopeFixedEdge -from rigplane.runtime.scope_session import ScopeSession, ScopeState, ScopeView +from rigplane.runtime.scope_session import ScopeSession, ScopeState from rigplane.scope.models import ScopeDemand from rigplane.scope import ScopeFrame +from dataclasses import replace class Writer: @@ -14,31 +15,26 @@ def __init__(self): self.writes = [] self.fail = False - async def _scope_read_state(self): + async def read(self): return self.state - async def _scope_write_state(self, previous, desired, *, policy, timeout): - if previous.scope_on != desired.scope_on: - self.writes.append((0x10, desired.scope_on)) - if previous.output_on != desired.output_on: - self.writes.append((0x11, desired.output_on)) + async def write(self, name, value, receiver): + if name in ("scope_on", "output_on"): + self.writes.append((0x10 if name == "scope_on" else 0x11, value)) + else: + self.views.append((name, value, receiver)) if self.fail: raise RuntimeError("no acknowledgement") - self.state = desired + if name != "fixed_edge": + self.state = replace(self.state, **{name: value}) - async def _scope_read_view(self): - return ScopeView(0, 0, 2, 1) - - async def _scope_write_view(self, view): - self.views.append(view) - - async def _scope_read_fixed(self, range_index, edge): + async def read_fixed(self, range_index, edge): return ScopeFixedEdge(range_index, edge, 14000000, 14350000) async def test_one_enable_exact_restore_once(): writer = Writer() - session = ScopeSession(writer) + session = ScopeSession(writer.read, writer.write, writer.read_fixed) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(1, 1)) await session.set_demand(ScopeDemand(2, 2)) @@ -54,7 +50,7 @@ async def test_one_enable_exact_restore_once(): async def test_deferred_enable_and_tx_have_no_writes(): writer = Writer() - session = ScopeSession(writer) + session = ScopeSession(writer.read, writer.write, writer.read_fixed) await session.set_demand(ScopeDemand(1, 1)) assert not session.enabled assert not writer.writes @@ -68,7 +64,7 @@ async def test_deferred_enable_and_tx_have_no_writes(): async def test_enabled_requires_acknowledgement(): writer = Writer() writer.fail = True - session = ScopeSession(writer) + session = ScopeSession(writer.read, writer.write, writer.read_fixed) await session.set_inputs(ready=True, tx=False) with pytest.raises(RuntimeError, match="acknowledgement"): await session.set_demand(ScopeDemand(1, 1)) @@ -78,7 +74,7 @@ async def test_enabled_requires_acknowledgement(): async def test_pause_only_output_resume_once_and_release_restores(virtual_clock): writer = Writer() writer.state = ScopeState(True, False) - session = ScopeSession(writer, clock=virtual_clock) + session = ScopeSession(writer.read, writer.write, writer.read_fixed, clock=virtual_clock) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(2, 2)) await session.set_demand(ScopeDemand(2, 0)) @@ -95,20 +91,20 @@ async def test_pause_only_output_resume_once_and_release_restores(virtual_clock) async def test_view_and_touched_fixed_slot_restored(): writer = Writer() - session = ScopeSession(writer) + session = ScopeSession(writer.read, writer.write, writer.read_fixed) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(1, 1)) fixed = ScopeFixedEdge(7, 1, 14050000, 14150000) await session.set_fixed(fixed, 0) await session.set_fixed(fixed, 0) - assert len(writer.views) == 1 + assert writer.views == [("fixed_edge", fixed, 0), ("edge", 1, 0), ("mode", 1, 0)] await session.set_demand(ScopeDemand(0, 0)) - assert writer.views[-1] == ScopeView(0, 0, 2, 1) - assert writer.views[-2].fixed_edge == ScopeFixedEdge(7, 1, 14000000, 14350000) + assert ("mode", 0, 0) in writer.views[3:] + assert ("fixed_edge", ScopeFixedEdge(7, 1, 14000000, 14350000), 0) in writer.views[3:] async def test_oor_is_arrival_never_hidden(virtual_clock): - session = ScopeSession(Writer(), clock=virtual_clock, silence_seconds=1) + session = ScopeSession(Writer().read, Writer().write, clock=virtual_clock, silence_seconds=1) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(1, 1)) session.on_frame(ScopeFrame(0, 1, 14000000, 14350000, b"", True)) @@ -117,7 +113,7 @@ async def test_oor_is_arrival_never_hidden(virtual_clock): async def test_release_grace_cancelled_by_resume(virtual_clock): writer = Writer() - session = ScopeSession(writer, clock=virtual_clock, disable_grace_seconds=0.25) + session = ScopeSession(writer.read, writer.write, writer.read_fixed, clock=virtual_clock, disable_grace_seconds=0.25) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(1, 1)) await session.set_demand(ScopeDemand(0, 0)) diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index b5179943a..c6401024f 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -16,7 +16,7 @@ def __init__(self): def tuned_frequency(self, receiver): return self.frequency - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): self.writes.append(enabled) async def set_center(self, center_hz, span_hz, receiver): @@ -116,7 +116,7 @@ def encode(frame): async def test_failed_output_off_can_be_retried(): class FlakyWriter(Writer): - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): self.writes.append(enabled) if not enabled and self.writes.count(False) == 1: raise RuntimeError("output-off failed") @@ -136,7 +136,7 @@ def __init__(self): super().__init__() self.output = False - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): self.writes.append(enabled) if enabled: self.output = True diff --git a/tests/test_scope_session.py b/tests/test_scope_session.py index 64ca8ff00..3648e4fcf 100644 --- a/tests/test_scope_session.py +++ b/tests/test_scope_session.py @@ -5,6 +5,7 @@ import pytest from rigplane.runtime.scope_session import ScopeSession, ScopeState +from rigplane.scope.models import ScopeDemand @pytest.mark.asyncio @@ -21,19 +22,19 @@ async def write(name, value, receiver): session = ScopeSession(read, write) await session.set_ready(False) - await session.demand("web", True) + await session.set_demand(ScopeDemand(1, 1)) assert not writes and not session.enabled await session.set_ready(True) assert writes == [("scope_on", True, 0), ("output_on", True, 0)] - await session.demand("sdk", True) + await session.set_demand(ScopeDemand(2, 2)) assert len(writes) == 2 await session.configure(mode=1, span=4) - await session.demand("web", False) + await session.set_demand(ScopeDemand(1, 1)) assert session.enabled - await session.demand("sdk", False) + await session.set_demand(ScopeDemand(0, 0)) assert session.observed == original restored = list(writes) - await session.demand("sdk", False) + await session.set_demand(ScopeDemand(0, 0)) assert writes == restored @@ -54,7 +55,7 @@ async def write(name, value, receiver): session = ScopeSession(read, write) await session.set_tx(True) - await session.demand("web", True) + await session.set_demand(ScopeDemand(1, 1)) assert not writes task = asyncio.create_task(session.set_tx(False)) from rigplane.testing.timing import await_until @@ -82,9 +83,9 @@ async def write(name, value, receiver): current = replace(current, **{name: value}) session = ScopeSession(read, write) - await session.demand("web", True) + await session.set_demand(ScopeDemand(1, 1)) await session.invalidate() await session.set_ready(True) - await session.demand("web", False) + await session.set_demand(ScopeDemand(0, 0)) assert captures == 1 assert current == original From d65335a584119405fb15c9444f9b297922f4952c Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:55:47 -0500 Subject: [PATCH 09/62] test(MOR-4274): consolidate reconciled session tests in one module Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 87 +++++++++++++++++++++++++++ tests/test_scope_session.py | 91 ----------------------------- 2 files changed, 87 insertions(+), 91 deletions(-) delete mode 100644 tests/test_scope_session.py diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index daf3212c5..d561517f1 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -1,5 +1,6 @@ import pytest +import asyncio from rigplane.core.types import ScopeFixedEdge from rigplane.runtime.scope_session import ScopeSession, ScopeState @@ -127,3 +128,89 @@ async def test_release_grace_cancelled_by_resume(virtual_clock): virtual_clock.advance(0.25) await session.reconcile() assert writer.writes[-2:] == [(0x10, False), (0x11, False)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["serial", "lan"]) +async def test_shared_demand_ack_restore_and_pause(transport): + original = ScopeState(False, False, 0, 2, 1) + writes = [] + + async def read(): + return original + + async def write(name, value, receiver): + writes.append((name, value, receiver)) + + session = ScopeSession(read, write) + await session.set_ready(True) + await session.set_ready(False) + await session.set_demand(ScopeDemand(1, 1)) + assert not writes and not session.enabled + await session.set_ready(True) + assert writes == [("scope_on", True, 0), ("output_on", True, 0)] + await session.set_demand(ScopeDemand(2, 2)) + assert len(writes) == 2 + await session.configure(mode=1, span=4) + await session.set_demand(ScopeDemand(1, 1)) + assert session.enabled + await session.set_demand(ScopeDemand(0, 0)) + assert session.observed == original + restored = list(writes) + await session.set_demand(ScopeDemand(0, 0)) + assert writes == restored + + +@pytest.mark.asyncio +async def test_no_writes_during_tx_and_enabled_waits_for_ack(): + import asyncio + + ack = asyncio.Event() + writes = [] + + async def read(): + return ScopeState(False, False, 0, 0, 1) + + async def write(name, value, receiver): + writes.append(name) + if name == "output_on" and value: + await ack.wait() + + session = ScopeSession(read, write) + await session.set_ready(True) + await session.set_tx(True) + await session.set_demand(ScopeDemand(1, 1)) + assert not writes + task = asyncio.create_task(session.set_tx(False)) + from rigplane.testing.timing import await_until + + assert await await_until(lambda: "output_on" in writes) + assert not session.enabled + ack.set() + await task + assert session.enabled + + +@pytest.mark.asyncio +async def test_reconnect_retains_original_capture(): + original = ScopeState(False, False, 0, 1, 1) + current = original + captures = 0 + + async def read(): + nonlocal captures + captures += 1 + return current + + async def write(name, value, receiver): + nonlocal current + current = replace(current, **{name: value}) + + session = ScopeSession(read, write) + await session.set_ready(True) + await session.set_demand(ScopeDemand(1, 1)) + await session.invalidate() + await session.set_ready(True) + await session.set_demand(ScopeDemand(0, 0)) + assert captures == 1 + assert current == original diff --git a/tests/test_scope_session.py b/tests/test_scope_session.py deleted file mode 100644 index 3648e4fcf..000000000 --- a/tests/test_scope_session.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Scope ownership and acknowledged state transitions.""" - -from dataclasses import replace - -import pytest - -from rigplane.runtime.scope_session import ScopeSession, ScopeState -from rigplane.scope.models import ScopeDemand - - -@pytest.mark.asyncio -@pytest.mark.parametrize("transport", ["serial", "lan"]) -async def test_shared_demand_ack_restore_and_pause(transport): - original = ScopeState(False, False, 0, 2, 1) - writes = [] - - async def read(): - return original - - async def write(name, value, receiver): - writes.append((name, value, receiver)) - - session = ScopeSession(read, write) - await session.set_ready(False) - await session.set_demand(ScopeDemand(1, 1)) - assert not writes and not session.enabled - await session.set_ready(True) - assert writes == [("scope_on", True, 0), ("output_on", True, 0)] - await session.set_demand(ScopeDemand(2, 2)) - assert len(writes) == 2 - await session.configure(mode=1, span=4) - await session.set_demand(ScopeDemand(1, 1)) - assert session.enabled - await session.set_demand(ScopeDemand(0, 0)) - assert session.observed == original - restored = list(writes) - await session.set_demand(ScopeDemand(0, 0)) - assert writes == restored - - -@pytest.mark.asyncio -async def test_no_writes_during_tx_and_enabled_waits_for_ack(): - import asyncio - - ack = asyncio.Event() - writes = [] - - async def read(): - return ScopeState(False, False, 0, 0, 1) - - async def write(name, value, receiver): - writes.append(name) - if name == "output_on" and value: - await ack.wait() - - session = ScopeSession(read, write) - await session.set_tx(True) - await session.set_demand(ScopeDemand(1, 1)) - assert not writes - task = asyncio.create_task(session.set_tx(False)) - from rigplane.testing.timing import await_until - - assert await await_until(lambda: "output_on" in writes) - assert not session.enabled - ack.set() - await task - assert session.enabled - - -@pytest.mark.asyncio -async def test_reconnect_retains_original_capture(): - original = ScopeState(False, False, 0, 1, 1) - current = original - captures = 0 - - async def read(): - nonlocal captures - captures += 1 - return current - - async def write(name, value, receiver): - nonlocal current - current = replace(current, **{name: value}) - - session = ScopeSession(read, write) - await session.set_demand(ScopeDemand(1, 1)) - await session.invalidate() - await session.set_ready(True) - await session.set_demand(ScopeDemand(0, 0)) - assert captures == 1 - assert current == original From 8f1fabe16b69c6fd4b1f96e950aa2b7a96889508 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 18:57:07 -0500 Subject: [PATCH 10/62] test(MOR-4274): exercise acknowledged scope controls through simulator Co-Authored-By: Claude Opus 5.5 --- tests/_scope_simulator.py | 51 ++++++++++++++++++++++++ tests/runtime/test_scope_session_wire.py | 6 +-- tests/scope/test_radio_scope_source.py | 3 ++ 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/_scope_simulator.py diff --git a/tests/_scope_simulator.py b/tests/_scope_simulator.py new file mode 100644 index 000000000..615b69332 --- /dev/null +++ b/tests/_scope_simulator.py @@ -0,0 +1,51 @@ +"""Scope-control simulator using Core's CI-V response parsers.""" +from rigplane.commands import ( + bcd_encode, parse_civ_frame, parse_scope_mode_response, + parse_scope_span_response, parse_scope_edge_response, + parse_scope_fixed_edge_response, +) +from rigplane.core.types import ScopeFixedEdge +from rigplane.profiles.rig_loader import load_rig +from rigplane_testkit.icom import MockIcomRadio +from pathlib import Path + + +class ScopeControlSimulator(MockIcomRadio): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._scope_10 = 0 + self.profile = load_rig(Path("rigs/ic7610.toml")).to_profile() + self.views = {r: (0, 3, 1) for r in (0, 1)} + self.fixed = {} + + def _dispatch_scope(self, payload, from_addr): + sub, rest = payload[0], payload[1:] + if sub == 0x10 and rest == b"\x00": + self._scope_10 = 0 + return self._civ_ack(from_addr, self._radio_addr) + if sub in (0x14, 0x15, 0x16) and rest: + receiver = rest[0] + mode, span, edge = self.views[receiver] + if len(rest) == 1: + value = (bytes([mode]) if sub == 0x14 else + bcd_encode(self.profile.scope_span_presets_hz[span] // 2) if sub == 0x15 else bytes([edge])) + return self._civ_frame(from_addr, self._radio_addr, 0x27, data=bytes([sub, receiver])+value) + frame = parse_civ_frame(self._civ_frame(from_addr, self._radio_addr, 0x27, data=payload)) + if sub == 0x14: + _, mode = parse_scope_mode_response(frame) + elif sub == 0x15: + _, span = parse_scope_span_response(frame, self.profile.scope_span_presets_hz) + else: + _, edge = parse_scope_edge_response(frame) + self.views[receiver] = mode, span, edge + return self._civ_ack(from_addr, self._radio_addr) + if sub == 0x1E and len(rest) >= 2: + key = rest[:2] + if len(rest) == 2: + value = self.fixed.get(key, ScopeFixedEdge(int(f"{key[0]:02x}"), key[1], 14000000, 14350000)) + data = key + bcd_encode(value.start_hz) + bcd_encode(value.end_hz) + return self._civ_frame(from_addr, self._radio_addr, 0x27, data=b"\x1e"+data) + frame = parse_civ_frame(self._civ_frame(from_addr, self._radio_addr, 0x27, data=payload)) + self.fixed[key] = parse_scope_fixed_edge_response(frame) + return self._civ_ack(from_addr, self._radio_addr) + return super()._dispatch_scope(payload, from_addr) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 866be51f7..a3fbe7312 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -11,10 +11,10 @@ async def test_server_simulator_pause_resume_release_counts_scope_writes(monkeyp from rigplane.testing.timing import await_until from rigplane.web.protocol import encode_scope_subscription from rigplane.web.server import WebConfig, WebServer - from rigplane_testkit.icom import MockIcomRadio + from _scope_simulator import ScopeControlSimulator from test_web_server import _addr - sim = MockIcomRadio(scope_stream=True) + sim = ScopeControlSimulator(scope_stream=True) await sim.start() radio = IcomRadio("127.0.0.1", port=sim.control_port, username="testuser", password="testpass", model="IC-7610") @@ -55,7 +55,7 @@ async def subscribe(fps): await subscribe(20) assert writes == initial + [(0x11, b"\x00"), (0x11, b"\x01")] await ws.send_json({"type": "unsubscribe", "sourceId": "radio", "receiver": 0}) - await await_until(lambda: not server._scope_enabled) + assert await await_until(lambda: not radio.scope_session().active) assert writes.count((0x10, b"\x01")) == 1 assert writes.count((0x10, b"\x00")) == 1 finally: diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index c6401024f..004af80a2 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -13,6 +13,9 @@ def __init__(self): self.writes = [] self.frequency = 14_100_000 + def on_frame(self, frame): + pass + def tuned_frequency(self, receiver): return self.frequency From 19123f852aec806e784c840635ca50c085b1f873 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:18:54 -0500 Subject: [PATCH 11/62] test(MOR-4274): migrate lifecycle coverage to the single scope session Co-Authored-By: Claude Opus 5.5 --- tests/_scope_session_fake.py | 52 +++++++ tests/_scope_simulator.py | 45 ++++-- tests/runtime/test_scope_session.py | 65 +++++++- tests/runtime/test_scope_session_wire.py | 49 ++++-- tests/scope/test_hub.py | 12 +- tests/test_icom7610_serial_radio.py | 73 +++++++-- tests/test_profile_command_binding.py | 4 + tests/test_radio.py | 3 + tests/test_radio_coverage.py | 25 ++- tests/test_radio_poller_coverage.py | 85 ++++++---- tests/test_response_shape_from_profile.py | 9 +- tests/test_rig_loader.py | 60 ++++--- tests/test_scope.py | 62 ++++++-- tests/test_scope_health_watchdog_mor3690.py | 72 ++++----- tests/test_supports_command.py | 8 +- tests/test_web_server.py | 16 +- tests/test_web_server_coverage.py | 71 +++++---- tests/web/test_scope_hub_channel.py | 163 +++++++++++--------- tests/web/test_scope_server_lifecycle.py | 6 +- 19 files changed, 610 insertions(+), 270 deletions(-) create mode 100644 tests/_scope_session_fake.py diff --git a/tests/_scope_session_fake.py b/tests/_scope_session_fake.py new file mode 100644 index 000000000..eb67e76fa --- /dev/null +++ b/tests/_scope_session_fake.py @@ -0,0 +1,52 @@ +"""Typed session fixture for web and poller radio fakes.""" + +from dataclasses import replace +from unittest.mock import Mock + +from rigplane.core.types import ScopeFixedEdge +from rigplane.runtime.scope_session import ScopeSession, ScopeState + + +def attach_scope_session(radio, **kwargs): + current = ScopeState(False, False, 0, 0, 1) + writes = [] + + async def read(): + panel, output = await radio.get_scope_session_state() + return replace(current, scope_on=panel, output_on=output) + + async def write(name, value, receiver): + nonlocal current + writes.append((name, value, receiver)) + if name == "output_on": + if value: + await radio.enable_scope(policy="fast") + else: + await radio.disable_scope(policy="fast") + elif name in ("mode", "span", "edge"): + await getattr(radio, f"set_scope_{name}")(value) + elif name == "fixed_edge": + await radio.set_scope_fixed_edge( + edge=value.edge, + start_hz=value.start_hz, + end_hz=value.end_hz, + range_index=value.range_index, + ) + if name != "fixed_edge": + current = replace(current, **{name: value}) + + async def read_fixed(range_index, edge): + return ScopeFixedEdge(range_index, edge, 14000000, 14350000) + + session = ScopeSession( + read, + write, + read_fixed, + ready=True, + frequency=lambda r: 14_100_000, + spans=radio.profile.scope_span_presets_hz, + **kwargs, + ) + radio.scope_session = Mock(return_value=session) + radio.scope_writes = writes + return session diff --git a/tests/_scope_simulator.py b/tests/_scope_simulator.py index 615b69332..cf49e764f 100644 --- a/tests/_scope_simulator.py +++ b/tests/_scope_simulator.py @@ -1,9 +1,13 @@ """Scope-control simulator using Core's CI-V response parsers.""" + from rigplane.commands import ( - bcd_encode, parse_civ_frame, parse_scope_mode_response, - parse_scope_span_response, parse_scope_edge_response, + parse_civ_frame, + parse_scope_mode_response, + parse_scope_span_response, + parse_scope_edge_response, parse_scope_fixed_edge_response, ) +from rigplane.core.types import bcd_encode from rigplane.core.types import ScopeFixedEdge from rigplane.profiles.rig_loader import load_rig from rigplane_testkit.icom import MockIcomRadio @@ -27,14 +31,28 @@ def _dispatch_scope(self, payload, from_addr): receiver = rest[0] mode, span, edge = self.views[receiver] if len(rest) == 1: - value = (bytes([mode]) if sub == 0x14 else - bcd_encode(self.profile.scope_span_presets_hz[span] // 2) if sub == 0x15 else bytes([edge])) - return self._civ_frame(from_addr, self._radio_addr, 0x27, data=bytes([sub, receiver])+value) - frame = parse_civ_frame(self._civ_frame(from_addr, self._radio_addr, 0x27, data=payload)) + value = ( + bytes([mode]) + if sub == 0x14 + else bcd_encode(self.profile.scope_span_presets_hz[span]) + if sub == 0x15 + else bytes([edge]) + ) + return self._civ_frame( + from_addr, + self._radio_addr, + 0x27, + data=bytes([sub, receiver]) + value, + ) + frame = parse_civ_frame( + self._civ_frame(from_addr, self._radio_addr, 0x27, data=payload) + ) if sub == 0x14: _, mode = parse_scope_mode_response(frame) elif sub == 0x15: - _, span = parse_scope_span_response(frame, self.profile.scope_span_presets_hz) + _, span = parse_scope_span_response( + frame, self.profile.scope_span_presets_hz + ) else: _, edge = parse_scope_edge_response(frame) self.views[receiver] = mode, span, edge @@ -42,10 +60,17 @@ def _dispatch_scope(self, payload, from_addr): if sub == 0x1E and len(rest) >= 2: key = rest[:2] if len(rest) == 2: - value = self.fixed.get(key, ScopeFixedEdge(int(f"{key[0]:02x}"), key[1], 14000000, 14350000)) + value = self.fixed.get( + key, + ScopeFixedEdge(int(f"{key[0]:02x}"), key[1], 14000000, 14350000), + ) data = key + bcd_encode(value.start_hz) + bcd_encode(value.end_hz) - return self._civ_frame(from_addr, self._radio_addr, 0x27, data=b"\x1e"+data) - frame = parse_civ_frame(self._civ_frame(from_addr, self._radio_addr, 0x27, data=payload)) + return self._civ_frame( + from_addr, self._radio_addr, 0x27, data=b"\x1e" + data + ) + frame = parse_civ_frame( + self._civ_frame(from_addr, self._radio_addr, 0x27, data=payload) + ) self.fixed[key] = parse_scope_fixed_edge_response(frame) return self._civ_ack(from_addr, self._radio_addr) return super()._dispatch_scope(payload, from_addr) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index d561517f1..85d509a7a 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -1,4 +1,3 @@ - import pytest import asyncio @@ -75,7 +74,9 @@ async def test_enabled_requires_acknowledgement(): async def test_pause_only_output_resume_once_and_release_restores(virtual_clock): writer = Writer() writer.state = ScopeState(True, False) - session = ScopeSession(writer.read, writer.write, writer.read_fixed, clock=virtual_clock) + session = ScopeSession( + writer.read, writer.write, writer.read_fixed, clock=virtual_clock + ) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(2, 2)) await session.set_demand(ScopeDemand(2, 0)) @@ -98,14 +99,18 @@ async def test_view_and_touched_fixed_slot_restored(): fixed = ScopeFixedEdge(7, 1, 14050000, 14150000) await session.set_fixed(fixed, 0) await session.set_fixed(fixed, 0) - assert writer.views == [("fixed_edge", fixed, 0), ("edge", 1, 0), ("mode", 1, 0)] + assert writer.views == [("fixed_edge", fixed, 0), ("mode", 1, 0)] await session.set_demand(ScopeDemand(0, 0)) - assert ("mode", 0, 0) in writer.views[3:] - assert ("fixed_edge", ScopeFixedEdge(7, 1, 14000000, 14350000), 0) in writer.views[3:] + assert ("mode", 0, 0) in writer.views[2:] + assert ("fixed_edge", ScopeFixedEdge(7, 1, 14000000, 14350000), 0) in writer.views[ + 2: + ] async def test_oor_is_arrival_never_hidden(virtual_clock): - session = ScopeSession(Writer().read, Writer().write, clock=virtual_clock, silence_seconds=1) + session = ScopeSession( + Writer().read, Writer().write, clock=virtual_clock, silence_seconds=1 + ) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(1, 1)) session.on_frame(ScopeFrame(0, 1, 14000000, 14350000, b"", True)) @@ -114,7 +119,13 @@ async def test_oor_is_arrival_never_hidden(virtual_clock): async def test_release_grace_cancelled_by_resume(virtual_clock): writer = Writer() - session = ScopeSession(writer.read, writer.write, writer.read_fixed, clock=virtual_clock, disable_grace_seconds=0.25) + session = ScopeSession( + writer.read, + writer.write, + writer.read_fixed, + clock=virtual_clock, + disable_grace_seconds=0.25, + ) await session.set_inputs(ready=True, tx=False) await session.set_demand(ScopeDemand(1, 1)) await session.set_demand(ScopeDemand(0, 0)) @@ -163,7 +174,6 @@ async def write(name, value, receiver): @pytest.mark.asyncio async def test_no_writes_during_tx_and_enabled_waits_for_ack(): - import asyncio ack = asyncio.Event() writes = [] @@ -214,3 +224,42 @@ async def write(name, value, receiver): await session.set_demand(ScopeDemand(0, 0)) assert captures == 1 assert current == original + + +async def test_sdk_demand_survives_web_pause_and_last_owner_restores(): + writer = Writer() + session = ScopeSession(writer.read, writer.write, ready=True) + await session.set_demand(ScopeDemand(1, 1), owner="sdk") + await session.set_demand(ScopeDemand(3, 0)) + assert session.enabled + assert writer.writes == [(0x10, True), (0x11, True)] + await session.set_demand(ScopeDemand(0, 0), owner="sdk") + assert writer.writes[-1] == (0x11, False) + assert session.active + await session.set_demand(ScopeDemand(0, 0)) + assert writer.state == ScopeState(False, False) + assert not session.active + + +async def test_lost_output_ack_still_restores_the_uncertain_write(): + original = ScopeState(False, False) + current = original + writes = [] + + async def read(): + return original + + async def write(name, value, receiver): + nonlocal current + writes.append((name, value)) + current = replace(current, **{name: value}) + if name == "output_on" and value: + raise RuntimeError("lost ack") + + session = ScopeSession(read, write, ready=True) + with pytest.raises(RuntimeError, match="lost ack"): + await session.set_demand(ScopeDemand(1, 1)) + assert not session.enabled + await session.set_demand(ScopeDemand(0, 0)) + assert current == original + assert writes[-1] == ("output_on", False) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index a3fbe7312..e258da8ae 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -16,8 +16,13 @@ async def test_server_simulator_pause_resume_release_counts_scope_writes(monkeyp sim = ScopeControlSimulator(scope_stream=True) await sim.start() - radio = IcomRadio("127.0.0.1", port=sim.control_port, - username="testuser", password="testpass", model="IC-7610") + radio = IcomRadio( + "127.0.0.1", + port=sim.control_port, + username="testuser", + password="testpass", + model="IC-7610", + ) await radio.connect() writes = [] send = radio._send_civ_raw @@ -35,16 +40,22 @@ async def record(payload, **kwargs): try: async with aiohttp.ClientSession() as client: async with client.ws_connect(f"http://{host}:{port}/api/v1/scope") as ws: + async def subscribe(fps): - await ws.send_str(encode_scope_subscription( - Subscription("radio", 0, View(14_090_000, 14_110_000), 100, fps) - )) + await ws.send_str( + encode_scope_subscription( + Subscription( + "radio", 0, View(14_090_000, 14_110_000), 100, fps + ) + ) + ) while True: message = await ws.receive(timeout=5) if message.type is aiohttp.WSMsgType.TEXT: assert '"type":"error"' not in message.data if '"granted"' in message.data: return + await subscribe(20) await await_until(lambda: writes.count((0x11, b"\x01")) == 1) initial = list(writes) @@ -54,7 +65,9 @@ async def subscribe(fps): assert writes == initial + [(0x11, b"\x00")] await subscribe(20) assert writes == initial + [(0x11, b"\x00"), (0x11, b"\x01")] - await ws.send_json({"type": "unsubscribe", "sourceId": "radio", "receiver": 0}) + await ws.send_json( + {"type": "unsubscribe", "sourceId": "radio", "receiver": 0} + ) assert await await_until(lambda: not radio.scope_session().active) assert writes.count((0x10, b"\x01")) == 1 assert writes.count((0x10, b"\x00")) == 1 @@ -70,8 +83,11 @@ async def test_real_scope_writer_emits_one_enable_per_transport(transport, monke from rigplane.backends.ic7300 import Ic7300SerialRadio from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR - radio = (IcomRadio("127.0.0.1", model="IC-7300") if transport == "lan" - else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200)) + radio = ( + IcomRadio("127.0.0.1", model="IC-7300") + if transport == "lan" + else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200) + ) radio._connected = True writes = [] @@ -80,12 +96,21 @@ async def send(payload, **kwargs): if frame.data: writes.append((frame.command, frame.sub, frame.data)) radio._scope_activity_event.set() - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB)) + return parse_civ_frame( + build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB) + ) async def expect(payload, **kwargs): frame = parse_civ_frame(payload) - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, - frame.command, sub=frame.sub, data=b"\x00")) + return parse_civ_frame( + build_civ_frame( + CONTROLLER_ADDR, + radio._radio_addr, + frame.command, + sub=frame.sub, + data=b"\x00", + ) + ) monkeypatch.setattr(radio, "_send_civ_raw", send) monkeypatch.setattr(radio, "_check_connected", lambda: None) @@ -96,5 +121,3 @@ async def expect(payload, **kwargs): await radio.disable_scope() await radio.disable_scope() assert writes[-2:] == [(0x27, 0x10, b"\x00"), (0x27, 0x11, b"\x00")] - - diff --git a/tests/scope/test_hub.py b/tests/scope/test_hub.py index 44ac600aa..780188b40 100644 --- a/tests/scope/test_hub.py +++ b/tests/scope/test_hub.py @@ -73,7 +73,7 @@ async def start(self): if self.fail_start: raise RuntimeError("start failed") - async def stop(self): + async def stop(self, *, release=True): self.stops += 1 if self.stop_gate is not None: await self.stop_gate.wait() @@ -406,7 +406,7 @@ def tuned_frequency(self, receiver): async def set_center(self, center_hz, span_hz, receiver): pass - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): probe.start() if enabled else probe.stop() source = RadioScopeSource( @@ -502,12 +502,18 @@ async def test_stop_retry_serializes_with_new_demand_and_repeated_cancellation( class NativeWriter: + def on_frame(self, frame): + pass + + async def set_fixed(self, edges, receiver): + pass + """Minimal RadioScopeWriter for the width-cap tests.""" def tuned_frequency(self, receiver): return 10_000_000 - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): pass async def set_center(self, center_hz, span_hz, receiver): diff --git a/tests/test_icom7610_serial_radio.py b/tests/test_icom7610_serial_radio.py index 1b92ae273..a46ae48fe 100644 --- a/tests/test_icom7610_serial_radio.py +++ b/tests/test_icom7610_serial_radio.py @@ -193,6 +193,7 @@ def __init__( self.model_id = model_id self.identity_queries = 0 self.selection_reads: list[bytes] = [] + self.scope_state = {0x10: False, 0x11: False} def set_device(self, device: str) -> None: self.device_history.append(device) @@ -234,8 +235,33 @@ async def send(self, frame: bytes) -> None: return self.sent_frames.append(payload) send_no = len(self.sent_frames) - for response in self._responses_by_send.pop(send_no, []): + scripted = self._responses_by_send.pop(send_no, []) + for response in scripted: self._responses.put_nowait(response) + civ = parse_civ_frame(payload) + if civ.command == 0x27 and civ.sub in (0x10, 0x11): + if not civ.data: + state_replies = [ + parse_civ_frame(item) + for item in scripted + if parse_civ_frame(item).command == 0x27 + and parse_civ_frame(item).sub == civ.sub + ] + if state_replies: + self.scope_state[civ.sub] = bool(state_replies[0].data[0]) + else: + self._responses.put_nowait( + _scope_state_response(civ.sub, self.scope_state[civ.sub]) + ) + else: + self.scope_state[civ.sub] = bool(civ.data[0]) + if not any( + parse_civ_frame(item).command in (0xFA, 0xFB) for item in scripted + ): + self._responses.put_nowait( + build_civ_frame(CONTROLLER_ADDR, IC_7610_ADDR, 0xFB) + ) + return # MOR-3071: the connect-time identity read. The reply is a real # 19 00 answer from the frame's own radio address, carrying the # IC-7610 model-ID byte (0x98 — opaque, never compared @@ -2132,10 +2158,14 @@ async def test_serial_scope_enable_disable_full_lifecycle_commands() -> None: # MOR-3071: connect sends the identity read first (its answer carries # the 0x98 payload, not the request). assert signatures[0] == (0x19, 0x00, b"") - assert signatures[1] == (0x27, 0x10, b"\x01") - assert signatures[2] == (0x27, 0x11, b"\x01") - assert signatures[3] == (0x27, 0x11, b"\x00") - assert signatures[4] == (0x27, 0x10, b"\x00") + assert signatures[1:] == [ + (0x27, 0x10, b""), + (0x27, 0x11, b""), + (0x27, 0x10, b"\x01"), + (0x27, 0x11, b"\x01"), + (0x27, 0x10, b"\x00"), + (0x27, 0x11, b"\x00"), + ] @pytest.mark.asyncio @@ -2215,12 +2245,12 @@ async def test_scope_session_restore_preserves_panel_and_output_state_exactly() ] assert initial == (True, False) assert signatures == [ - (0x19, 0x00, b""), # MOR-3071 identity read on connect + (0x19, 0x00, b""), + (0x27, 0x10, b""), + (0x27, 0x11, b""), (0x27, 0x10, b""), (0x27, 0x11, b""), - (0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x01"), - (0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x00"), ] @@ -2569,8 +2599,33 @@ async def send(self, frame: bytes) -> None: self.lifecycle_events.append(("send", payload)) self.sent_frames.append(payload) send_no = len(self.sent_frames) - for response in self._responses_by_send.pop(send_no, []): + scripted = self._responses_by_send.pop(send_no, []) + for response in scripted: self._responses.put_nowait(response) + civ = parse_civ_frame(payload) + if civ.command == 0x27 and civ.sub in (0x10, 0x11): + if not civ.data: + state_replies = [ + parse_civ_frame(item) + for item in scripted + if parse_civ_frame(item).command == 0x27 + and parse_civ_frame(item).sub == civ.sub + ] + if state_replies: + self.scope_state[civ.sub] = bool(state_replies[0].data[0]) + else: + self._responses.put_nowait( + _scope_state_response(civ.sub, self.scope_state[civ.sub]) + ) + else: + self.scope_state[civ.sub] = bool(civ.data[0]) + if not any( + parse_civ_frame(item).command in (0xFA, 0xFB) for item in scripted + ): + self._responses.put_nowait( + build_civ_frame(CONTROLLER_ADDR, IC_7610_ADDR, 0xFB) + ) + return if payload[4:-1] == b"\x19\x00": self.identity_queries += 1 if self.policy_answer and ( diff --git a/tests/test_profile_command_binding.py b/tests/test_profile_command_binding.py index 8efdc7882..bcc1c76cd 100644 --- a/tests/test_profile_command_binding.py +++ b/tests/test_profile_command_binding.py @@ -476,6 +476,8 @@ def _synthesize_case(builder: Any, cmd_map: CommandMap) -> dict[str, Any]: real ``__globals__``. """ target = inspect.unwrap(builder) + if target.__name__ == "scope_set_fixed_edge": + return {"edge": 1, "range_index": 1, "start_hz": 1000000, "end_hz": 2000000} required, _optional = _split_params(target) extra = _EXTRA_PROBE_KWARGS.get(getattr(target, "__name__", "")) candidates = itertools.chain( @@ -541,6 +543,8 @@ def _fake_build_from_map( builders_module = sys.modules["rigplane.commands._builders"] monkeypatch.setattr(builders_module, "_build_from_map", _fake_build_from_map) target = inspect.unwrap(builder) + if target.__name__ == "scope_set_fixed_edge": + return {"edge": 1, "range_index": 1, "start_hz": 1000000, "end_hz": 2000000} probe_map = cmd_map if "ctcss_tones_centihz" in inspect.signature(target).parameters: # Tone builders validate both their profile domain and their named diff --git a/tests/test_radio.py b/tests/test_radio.py index 8a67337d4..2395df693 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -2589,6 +2589,7 @@ async def test_get_scope_fixed_edge( async def test_set_scope_fixed_edge_updates_radio_state( self, radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response(_ack_response()) await radio.set_scope_fixed_edge(edge=4, start_hz=14_000_000, end_hz=14_350_000) fixed_edge = radio.radio_state.scope_controls.fixed_edge assert fixed_edge.range_index == 6 @@ -2662,6 +2663,8 @@ async def test_set_scope_variants_update_radio_state( field_name: str, expected: object, ) -> None: + if method_name in ("set_scope_mode", "set_scope_span", "set_scope_edge"): + mock_transport.queue_response(_ack_response()) await getattr(radio, method_name)(**kwargs) assert getattr(radio.radio_state.scope_controls, field_name) == expected assert len(mock_transport.sent_packets) > 0 diff --git a/tests/test_radio_coverage.py b/tests/test_radio_coverage.py index e7bee05dd..7eeb84d3d 100644 --- a/tests/test_radio_coverage.py +++ b/tests/test_radio_coverage.py @@ -136,6 +136,7 @@ def radio(mock_transport: MockTransport): r._ctrl_transport = mock_transport r._connected = True observe_selected_receiver(r, 0) + r.get_scope_session_state = AsyncMock(return_value=(False, False)) yield r r._connected = False # reset _conn_state so __del__ stays quiet @@ -1263,7 +1264,8 @@ async def test_scope_stream_exits_when_disconnected(radio: IcomRadio) -> None: async def test_enable_scope_fast_policy_no_wait( radio: IcomRadio, mock_transport: MockTransport ) -> None: - """FAST policy sends commands without waiting for ACK or verification.""" + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) from rigplane.types import ScopeCompletionPolicy await radio.enable_scope(policy=ScopeCompletionPolicy.FAST) @@ -1273,6 +1275,8 @@ async def test_enable_scope_fast_policy_no_wait( async def test_enable_scope_fast_no_output( radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) """enable_scope(output=False) should only send the on command.""" from rigplane.types import ScopeCompletionPolicy @@ -1314,7 +1318,7 @@ async def test_enable_scope_strict_nak_raises( from rigplane.types import ScopeCompletionPolicy mock_transport.queue_response(_nak_response()) - with pytest.raises(CommandError, match="scope enable"): + with pytest.raises(CommandError, match="scope scope_on"): await radio.enable_scope(policy=ScopeCompletionPolicy.STRICT) @@ -1326,6 +1330,8 @@ async def test_enable_scope_strict_nak_raises( async def test_enable_scope_verify_times_out( radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) """VERIFY policy raises TimeoutError if no scope data arrives.""" from rigplane.types import ScopeCompletionPolicy @@ -1337,6 +1343,8 @@ async def test_enable_scope_verify_times_out( async def test_enable_scope_verify_succeeds_when_event_fires( radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) """VERIFY policy succeeds when scope activity event fires.""" from rigplane.types import ScopeCompletionPolicy @@ -1366,7 +1374,7 @@ async def test_disable_scope_fast_sends_command( from rigplane.types import ScopeCompletionPolicy await radio.disable_scope(policy=ScopeCompletionPolicy.FAST) - assert len(mock_transport.sent_packets) >= 1 + assert len(mock_transport.sent_packets) == 0 async def test_disable_scope_strict_nak_raises( @@ -1374,8 +1382,11 @@ async def test_disable_scope_strict_nak_raises( ) -> None: from rigplane.types import ScopeCompletionPolicy + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) + await radio.enable_scope(policy=ScopeCompletionPolicy.STRICT) mock_transport.queue_response(_nak_response()) - with pytest.raises(CommandError, match="scope data output disable"): + with pytest.raises(CommandError, match="scope scope_on"): await radio.disable_scope(policy=ScopeCompletionPolicy.STRICT) @@ -1396,6 +1407,8 @@ async def test_disable_scope_strict_ack_succeeds( async def test_capture_scope_frame_returns_first_frame( radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) """capture_scope_frame() should return the first frame received.""" frame = ScopeFrame( receiver=0, @@ -1429,6 +1442,8 @@ async def _push_frame() -> None: async def test_capture_scope_frames_timeout_raises( radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) """capture_scope_frames() raises TimeoutError if not enough frames arrive.""" with pytest.raises(TimeoutError, match="timed out"): await radio.capture_scope_frames(count=5, timeout=0.05) @@ -1437,6 +1452,8 @@ async def test_capture_scope_frames_timeout_raises( async def test_capture_scope_frames_multiple( radio: IcomRadio, mock_transport: MockTransport ) -> None: + mock_transport.queue_response_on_send(1, _ack_response()) + mock_transport.queue_response_on_send(2, _ack_response()) """capture_scope_frames(count=3) collects exactly 3 frames.""" frames = [ ScopeFrame( diff --git a/tests/test_radio_poller_coverage.py b/tests/test_radio_poller_coverage.py index d6a868f06..024ed5030 100644 --- a/tests/test_radio_poller_coverage.py +++ b/tests/test_radio_poller_coverage.py @@ -482,6 +482,9 @@ async def _select_receiver(which: object) -> None: for _attr in _proto_attrs: if _attr not in vars(radio): setattr(radio, _attr, AsyncMock()) + from _scope_session_fake import attach_scope_session + + attach_scope_session(radio) return radio @@ -2563,7 +2566,8 @@ async def test_execute_event_emitting_commands_and_vfo_paths() -> None: await poller._execute(DisableScope()) # noqa: SLF001 await poller._execute(SwitchScopeReceiver(1)) # noqa: SLF001 radio.enable_scope.assert_awaited_once_with(policy="fast") - radio.restore_scope_session_state.assert_awaited_once_with((False, False)) + assert radio.scope_session().observed.scope_on is False + assert radio.scope_session().observed.output_on is False with pytest.raises(CommandError, match="receiver must be 0 or 1, got 2"): await poller._execute(SwitchScopeReceiver(2)) # noqa: SLF001 @@ -3965,9 +3969,9 @@ async def test_enable_scope_deferred_during_initial_fetch() -> None: radio.enable_scope.assert_not_awaited() # Command should be re-queued - assert queue.has_commands is True - cmds = queue.drain() - assert any(isinstance(c, EnableScope) for c in cmds) + assert not queue.has_commands + await radio.scope_session().set_ready(True) + radio.enable_scope.assert_awaited_once() @pytest.mark.asyncio @@ -3996,7 +4000,7 @@ async def test_stale_deferred_enable_cannot_requeue_after_newer_disable() -> Non queue.put(EnableScope(policy="fast", generation=1)) (old_enable,) = queue.drain() await poller._execute(old_enable) # noqa: SLF001 - assert queue.has_commands is True + assert queue.has_commands is False queue.put(DisableScope(generation=2)) for command in queue.drain(): @@ -4034,7 +4038,7 @@ async def test_current_scope_generation_preserves_recovery_enable() -> None: await poller._execute(EnableScope(generation=5)) # noqa: SLF001 await poller._execute(EnableScope(generation=5)) # noqa: SLF001 - assert radio.enable_scope.await_count == 2 + assert radio.enable_scope.await_count == 1 radio.get_scope_session_state.assert_awaited_once() @@ -4052,8 +4056,11 @@ async def test_scope_session_restores_exact_initial_state( await poller._execute(DisableScope(generation=2)) # noqa: SLF001 radio.get_scope_session_state.assert_awaited_once() - assert radio.enable_scope.await_count == 2 - radio.restore_scope_session_state.assert_awaited_once_with(initial) + assert radio.enable_scope.await_count == 1 + assert ( + radio.scope_session().observed.scope_on, + radio.scope_session().observed.output_on, + ) == initial @pytest.mark.asyncio @@ -4066,7 +4073,8 @@ async def test_failed_scope_enable_rolls_back_captured_state() -> None: await poller._execute(EnableScope(generation=1)) # noqa: SLF001 await poller._execute(DisableScope(generation=2)) # noqa: SLF001 - radio.restore_scope_session_state.assert_awaited_once_with((False, False)) + assert radio.scope_session().observed.scope_on is False + assert radio.scope_session().observed.output_on is False @pytest.mark.asyncio @@ -4740,7 +4748,8 @@ async def test_multiple_commands_execute_in_order_after_fetch() -> None: # Now simulate fetch completing poller._initial_fetch_done.set() # noqa: SLF001 - # Drain the re-queued EnableScope + await radio.scope_session().set_ready(True) + # No retry command remains in the queue. cmds = queue.drain() for cmd in cmds: await poller._execute(cmd) # noqa: SLF001 @@ -5840,29 +5849,46 @@ def _shutdown_server( _client_tasks=client_tasks, _server=None, _server_was_running=True, + _radio_scope_session=poller._radio.scope_session() + if poller is not None + else None, _scope_enabled=scope_enabled, _scope_enable_failed=scope_enable_failed, ) -def _scope_shutdown_poller( - *, external_cat_session_active: bool = False -) -> tuple[RadioPoller, _Radio, CommandQueue]: - """Stopped-poller restore harness over the same executor as the TX drain.""" +async def _scope_shutdown_poller(*, external_cat_session_active=False): + from rigplane.runtime.scope_session import ScopeSession, ScopeState + radio = _Radio() radio.capabilities.add(CAP_SCOPE) + radio.external_cat_session_active = False + radio.restore_scope_session_state = AsyncMock() + + async def read(): + return ScopeState(True, False) + + async def write(name, value, receiver): + if radio.external_cat_session_active: + raise CommandError( + "scope restore deferred while an external CAT session owns the wire" + ) + if name == "output_on" and not value: + await radio.restore_scope_session_state((True, False)) + + session = ScopeSession(read, write, ready=True) + radio.scope_session = lambda: session + await session.set_output(True) radio.external_cat_session_active = external_cat_session_active queue = CommandQueue() - poller = RadioPoller(radio, queue) # type: ignore[arg-type] - poller._scope_session_state = (True, False) # noqa: SLF001 - poller._scope_session_active = True # noqa: SLF001 + poller = RadioPoller(radio, queue) return poller, radio, queue @pytest.mark.asyncio async def test_shutdown_restores_scope_after_final_unkey_on_same_executor() -> None: """Scope restoration stays on the sole poller executor and follows PTT OFF.""" - poller, radio, queue = _scope_shutdown_poller() + poller, radio, queue = await _scope_shutdown_poller() async def _restore_scope(state: tuple[bool, bool]) -> None: radio.calls.append(f"restore_scope{state}") @@ -5878,17 +5904,14 @@ async def _restore_scope(state: tuple[bool, bool]) -> None: # no parallel queue consumer or second radio-control lane is created. assert [type(call.args[0]) for call in execute.await_args_list] == [ PttOff, - DisableScope, ] assert radio.calls == [ "set_ptt(False)", *_TEARDOWN, "restore_scope(True, False)", ] - assert not server._scope_enabled - assert not server._scope_enable_failed - assert poller._scope_session_state is None # noqa: SLF001 - assert poller._scope_session_active is False # noqa: SLF001 + assert not server._radio_scope_session.active + assert server._radio_scope_session.observed.output_on is False assert queue.has_commands is False @@ -5896,7 +5919,9 @@ async def _restore_scope(state: tuple[bool, bool]) -> None: async def test_shutdown_external_cat_defers_scope_but_still_delivers_unkey( caplog: pytest.LogCaptureFixture, ) -> None: - poller, radio, queue = _scope_shutdown_poller(external_cat_session_active=True) + poller, radio, queue = await _scope_shutdown_poller( + external_cat_session_active=True + ) restore = AsyncMock() radio.restore_scope_session_state = restore # type: ignore[attr-defined] queue.put(PttOff(), command_id="shutdown-off", source="websocket") @@ -5907,9 +5932,8 @@ async def test_shutdown_external_cat_defers_scope_but_still_delivers_unkey( assert radio.calls == ["set_ptt(False)", *_TEARDOWN] restore.assert_not_awaited() - assert server._scope_enabled - assert poller._scope_session_state == (True, False) # noqa: SLF001 - assert poller._scope_session_active is True # noqa: SLF001 + assert server._radio_scope_session.active + assert server._radio_scope_session.restoring assert "external CAT session owns the wire" in caplog.text assert "state remains pending" in caplog.text @@ -5920,7 +5944,7 @@ async def test_shutdown_scope_restore_failure_never_delays_final_unkey( failure: str, caplog: pytest.LogCaptureFixture, ) -> None: - poller, radio, queue = _scope_shutdown_poller() + poller, radio, queue = await _scope_shutdown_poller() restore_started = asyncio.Event() async def _restore_scope(_state: tuple[bool, bool]) -> None: @@ -5944,9 +5968,8 @@ async def _restore_scope(_state: tuple[bool, bool]) -> None: assert restore_started.is_set() assert elapsed < 1.0 assert radio.calls == ["set_ptt(False)", *_TEARDOWN] - assert server._scope_enabled - assert poller._scope_session_state == (True, False) # noqa: SLF001 - assert poller._scope_session_active is True # noqa: SLF001 + assert server._radio_scope_session.active + assert server._radio_scope_session.restoring expected = "timed out" if failure == "timeout" else "failed" assert expected in caplog.text assert "state remains pending" in caplog.text diff --git a/tests/test_response_shape_from_profile.py b/tests/test_response_shape_from_profile.py index 8685fac08..43a6b0010 100644 --- a/tests/test_response_shape_from_profile.py +++ b/tests/test_response_shape_from_profile.py @@ -663,17 +663,20 @@ async def test_set_scope_fixed_edge_reply_shape_comes_from_the_map( monkeypatch.setattr(CoreRadio, "_check_connected", lambda self: None) radio = CoreRadio("198.51.100.1", profile=mutated_profile) - async def _fake_send_civ_raw(civ: bytes, **kwargs: Any) -> None: - return None + async def _fake_send_civ_raw(civ: bytes, **kwargs: Any) -> CivFrame: + return CivFrame(to_addr=0xE0, from_addr=0x98, command=0xFB) monkeypatch.setattr(radio, "_send_civ_raw", _fake_send_civ_raw) + await radio.scope_session().set_ready(True) # The request builder reads this SAME mutated map, so the frame it # builds already carries sub=_SCOPE_DIVERGENT_SUB. A parser still # checking the module's hardcoded _SUB_SCOPE_FIXED_EDGE would reject # its own request's echo; deriving the shape from the same map instead # (this call site's retrofit) must not raise here. - await radio.set_scope_fixed_edge(edge=1, start_hz=1_000_000, end_hz=2_000_000) + await radio.set_scope_fixed_edge( + edge=1, range_index=1, start_hz=1_000_000, end_hz=2_000_000 + ) # MOR-2106: `commands/scope.py: scope_main_sub` (the WRITE builder) used to diff --git a/tests/test_rig_loader.py b/tests/test_rig_loader.py index 73ce8a5f3..76deeb47a 100644 --- a/tests/test_rig_loader.py +++ b/tests/test_rig_loader.py @@ -3032,9 +3032,15 @@ def test_shipped_profiles_preserve_typed_fixed_edge_ranges(self): assert len(rig.scope_fixed_edge_ranges) == 12 assert rig.scope_fixed_edge_step_hz == 1000 assert rig.to_profile().scope_fixed_edge_step_hz == 1000 - assert rig.scope_fixed_edge_ranges[0] == ScopeFixedEdgeRange(1, 30_000, 1_600_000) - assert rig.scope_fixed_edge_ranges[5] == ScopeFixedEdgeRange(6, 11_000_000, 15_000_000) - assert rig.scope_fixed_edge_ranges[-1] == ScopeFixedEdgeRange(12, 45_000_000, 60_000_000) + assert rig.scope_fixed_edge_ranges[0] == ScopeFixedEdgeRange( + 1, 30_000, 1_600_000 + ) + assert rig.scope_fixed_edge_ranges[5] == ScopeFixedEdgeRange( + 6, 11_000_000, 15_000_000 + ) + assert rig.scope_fixed_edge_ranges[-1] == ScopeFixedEdgeRange( + 12, 45_000_000, 60_000_000 + ) assert rig.to_profile().scope_fixed_edge_ranges == rig.scope_fixed_edge_ranges center = load_rig(RIGS_DIR / "ic7300.toml") assert center.scope_fixed_edge_ranges == () @@ -3042,28 +3048,42 @@ def test_shipped_profiles_preserve_typed_fixed_edge_ranges(self): assert center.scope_fixed_edge_step_hz is None assert center.to_profile().scope_fixed_edge_step_hz is None - @pytest.mark.parametrize("ranges", [ - '[]', '[{range_index = 0, start_hz = 1000, end_hz = 2000}]', - '[{range_index = 1, start_hz = 2000, end_hz = 1000}]', - '[{range_index = true, start_hz = 1000, end_hz = 2000}]', - '[{range_index = 1, start_hz = 1000.0, end_hz = 2000}]', - '[{range_index = 1, start_hz = 1000, end_hz = 2000}, {range_index = 1, start_hz = 2000, end_hz = 3000}]', - '[{range_index = 1, start_hz = 1000, end_hz = 2500}, {range_index = 2, start_hz = 2000, end_hz = 3000}]', - ]) + @pytest.mark.parametrize( + "ranges", + [ + "[]", + "[{range_index = 0, start_hz = 1000, end_hz = 2000}]", + "[{range_index = 1, start_hz = 2000, end_hz = 1000}]", + "[{range_index = true, start_hz = 1000, end_hz = 2000}]", + "[{range_index = 1, start_hz = 1000.0, end_hz = 2000}]", + "[{range_index = 1, start_hz = 1000, end_hz = 2000}, {range_index = 1, start_hz = 2000, end_hz = 3000}]", + "[{range_index = 1, start_hz = 1000, end_hz = 2500}, {range_index = 2, start_hz = 2000, end_hz = 3000}]", + ], + ) def test_rejects_invalid_ranges(self, tmp_path, ranges): - text = _MINIMAL_TOML + f'\n[scope]\nfixed_edge_ranges = {ranges}\nfixed_edge_step_hz = 1000\n' + text = ( + _MINIMAL_TOML + + f"\n[scope]\nfixed_edge_ranges = {ranges}\nfixed_edge_step_hz = 1000\n" + ) with pytest.raises(RigLoadError, match="fixed_edge_ranges"): load_rig(_write_toml(tmp_path, text)) - @pytest.mark.parametrize("declaration", [ - 'fixed_edge_step_hz = 1000', - 'fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]', - 'fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]\nfixed_edge_step_hz = 0', - 'fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]\nfixed_edge_step_hz = true', - ]) - def test_fixed_ranges_and_positive_step_are_declared_together(self, tmp_path, declaration): + @pytest.mark.parametrize( + "declaration", + [ + "fixed_edge_step_hz = 1000", + "fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]", + "fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]\nfixed_edge_step_hz = 0", + "fixed_edge_ranges = [{range_index = 1, start_hz = 1000, end_hz = 2000}]\nfixed_edge_step_hz = true", + ], + ) + def test_fixed_ranges_and_positive_step_are_declared_together( + self, tmp_path, declaration + ): with pytest.raises(RigLoadError, match="fixed_edge"): - load_rig(_write_toml(tmp_path, _MINIMAL_TOML + f'\n[scope]\n{declaration}\n')) + load_rig( + _write_toml(tmp_path, _MINIMAL_TOML + f"\n[scope]\n{declaration}\n") + ) class TestAudioPolicy: diff --git a/tests/test_scope.py b/tests/test_scope.py index 09c8dbd06..58d2d20c2 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -928,7 +928,10 @@ def test_get_scope_fixed_edge_rejects_bad_edge(self, cmd_map) -> None: def test_scope_set_fixed_edge(self, cmd_map) -> None: frame = scope_set_fixed_edge( - edge=4, start_hz=14_000_000, end_hz=14_350_000, cmd_map=cmd_map, + edge=4, + start_hz=14_000_000, + end_hz=14_350_000, + cmd_map=cmd_map, ranges=load_rig(RIG_DIR / "ic7610.toml").scope_fixed_edge_ranges, ) assert frame[4] == 0x27 @@ -938,17 +941,27 @@ def test_scope_set_fixed_edge(self, cmd_map) -> None: assert frame[8:13] == bcd_encode(14_000_000) assert frame[13:18] == bcd_encode(14_350_000) - @pytest.mark.parametrize("start,end,code", [ - (1_800_000, 1_900_000, 2), (3_500_000, 3_800_000, 3), - (7_100_000, 7_200_000, 4), (14_200_000, 14_350_000, 6), - (50_000_000, 50_500_000, 12), (1_500_000, 1_600_000, 1), - (1_600_000, 1_700_000, 2), (1_900_000, 2_000_000, 2), - (2_000_000, 2_100_000, 3), - ]) + @pytest.mark.parametrize( + "start,end,code", + [ + (1_800_000, 1_900_000, 2), + (3_500_000, 3_800_000, 3), + (7_100_000, 7_200_000, 4), + (14_200_000, 14_350_000, 6), + (50_000_000, 50_500_000, 12), + (1_500_000, 1_600_000, 1), + (1_600_000, 1_700_000, 2), + (1_900_000, 2_000_000, 2), + (2_000_000, 2_100_000, 3), + ], + ) def test_fixed_edge_range_comes_from_profile(self, cmd_map, start, end, code): rig = load_rig(RIG_DIR / "ic7610.toml") frame = scope_set_fixed_edge( - edge=1, start_hz=start, end_hz=end, ranges=rig.scope_fixed_edge_ranges, + edge=1, + start_hz=start, + end_hz=end, + ranges=rig.scope_fixed_edge_ranges, cmd_map=cmd_map, ) assert frame[6] == _bcd_byte(code) @@ -956,20 +969,34 @@ def test_fixed_edge_range_comes_from_profile(self, cmd_map, start, end, code): def test_fixed_edge_without_profile_requires_explicit_selector(self, cmd_map): with pytest.raises(ValueError, match="profile range"): scope_set_fixed_edge( - edge=1, start_hz=14_000_000, end_hz=14_350_000, cmd_map=cmd_map, + edge=1, + start_hz=14_000_000, + end_hz=14_350_000, + cmd_map=cmd_map, ) frame = scope_set_fixed_edge( - edge=1, start_hz=14_000_000, end_hz=14_350_000, range_index=6, + edge=1, + start_hz=14_000_000, + end_hz=14_350_000, + range_index=6, cmd_map=cmd_map, ) assert frame[6] == 6 - @pytest.mark.parametrize("start,end", [(1_500_000, 1_700_000), (29_000, 31_000), (59_900_000, 60_001_000)]) - def test_fixed_edge_rejects_view_outside_one_profile_range(self, cmd_map, start, end): + @pytest.mark.parametrize( + "start,end", + [(1_500_000, 1_700_000), (29_000, 31_000), (59_900_000, 60_001_000)], + ) + def test_fixed_edge_rejects_view_outside_one_profile_range( + self, cmd_map, start, end + ): rig = load_rig(RIG_DIR / "ic7610.toml") with pytest.raises(ValueError, match="profile range"): scope_set_fixed_edge( - edge=1, start_hz=start, end_hz=end, ranges=rig.scope_fixed_edge_ranges, + edge=1, + start_hz=start, + end_hz=end, + ranges=rig.scope_fixed_edge_ranges, cmd_map=cmd_map, ) @@ -977,8 +1004,11 @@ def test_center_only_profile_does_not_inherit_fixed_ranges(self, cmd_map): rig = load_rig(RIG_DIR / "ic7300.toml") with pytest.raises(ValueError, match="profile range"): scope_set_fixed_edge( - edge=1, start_hz=14_000_000, end_hz=14_350_000, - ranges=rig.scope_fixed_edge_ranges, cmd_map=cmd_map, + edge=1, + start_hz=14_000_000, + end_hz=14_350_000, + ranges=rig.scope_fixed_edge_ranges, + cmd_map=cmd_map, ) def test_get_scope_rbw(self, cmd_map) -> None: diff --git a/tests/test_scope_health_watchdog_mor3690.py b/tests/test_scope_health_watchdog_mor3690.py index 74a118aff..0b8f32d7f 100644 --- a/tests/test_scope_health_watchdog_mor3690.py +++ b/tests/test_scope_health_watchdog_mor3690.py @@ -53,7 +53,6 @@ from rigplane.testing.timing import VirtualClock, await_until from rigplane.web.protocol import encode_scope_subscription, decode_scope_frame_v2 from rigplane.scope.models import Subscription, View -from rigplane.web.radio_poller import EnableScope from rigplane.web.server import WebConfig, WebServer from rigplane.ws import WS_OP_BINARY from test_web_server import ( @@ -96,7 +95,7 @@ async def test_all_zero_frames_flowing_writes_nothing( monkeypatch: pytest.MonkeyPatch, ) -> None: """Arriving-but-zero frames = menu on the radio, never a write (PR #4444).""" - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) async def scenario() -> None: await _elapse_scope_windows( @@ -132,7 +131,9 @@ async def test_captured_out_of_range_is_present_not_hidden( ) radio = _scope_radio() radio.model = model - srv, _, queued = _virtual_scope_server(monkeypatch, virtual_clock, radio=radio) + srv, _, queued = await _virtual_scope_server( + monkeypatch, virtual_clock, radio=radio + ) caplog.set_level("INFO", logger="rigplane.web.server") published: list[bool] = [] publish = srv._publish_scope_hidden # noqa: SLF001 @@ -152,7 +153,7 @@ async def scenario() -> None: assert True not in published assert _scope_hidden_verdict(srv) is False assert "all-zero" not in caplog.text - assert not srv._scope_menu_guard_active(now=virtual_clock()) # noqa: SLF001 + assert not srv._radio_scope_session.menu_guard_active() # noqa: SLF001 _feed_scope_health(srv, _live_scope_frame()) # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 assert len(queued) == 1 @@ -171,7 +172,7 @@ async def test_menu_silence_two_minutes_never_enables( menu; CI-V polls keep answering. Two minutes of fake time must pass without a single EnableScope. """ - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) async def scenario() -> None: await _elapse_scope_windows( @@ -211,7 +212,7 @@ async def test_menu_silence_verdict_reads_the_public_liveness_stamp( ``_last_civ_data_received`` field still gets the no-frame menu verdict — the monitor reads the Radio contract's public ``last_valid_frame_monotonic`` stamp, nothing private.""" - srv, radio, queued = _virtual_scope_server( + srv, radio, queued = await _virtual_scope_server( monkeypatch, virtual_clock, radio=_scope_radio(cls=_PublicLivenessOnlyRadio), @@ -246,9 +247,10 @@ async def test_continuing_menu_evidence_stays_fresh_past_the_grace_window( menu on a perfectly healthy link. """ caplog.set_level("INFO", logger="rigplane.web.server") - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) windows = ( - int(srv._SCOPE_HIDDEN_RECONNECT_GRACE / srv._scope_health_interval) + 5 # noqa: SLF001 + int(srv._radio_scope_session.hidden_grace_seconds / srv._scope_health_interval) + + 5 # noqa: SLF001 ) async def scenario() -> None: @@ -264,7 +266,7 @@ async def scenario() -> None: await srv._refetch_and_reenable() # noqa: SLF001 assert queued == [] - assert "radio is in its own menu" in caplog.text + assert "hidden on the radio (menu)" in caplog.text @pytest.mark.timeout(30) @@ -275,13 +277,15 @@ async def test_reconnect_while_in_menu_sends_no_enable( ) -> None: """A soft-reconnect while the radio is known to be in its menu is quiet.""" caplog.set_level("INFO", logger="rigplane.web.server") - srv, _radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, _radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) + virtual_clock.advance(srv._scope_health_interval + 1) + assert srv._radio_scope_session.health(civ_flowing=True).hidden is True srv._publish_scope_hidden(True, now=virtual_clock()) # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 assert queued == [] - assert "radio is in its own menu" in caplog.text + assert "hidden on the radio (menu)" in caplog.text @pytest.mark.timeout(30) @@ -295,9 +299,11 @@ async def test_stale_menu_verdict_does_not_block_reconnect_enable( This is the genuinely-unobserved case: no monitor window ran to re-confirm the verdict, so its evidence clock rightly ages out. """ - srv, _radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, _radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) + virtual_clock.advance(srv._scope_health_interval + 1) + assert srv._radio_scope_session.health(civ_flowing=True).hidden is True srv._publish_scope_hidden(True, now=virtual_clock()) # noqa: SLF001 - virtual_clock.advance(srv._SCOPE_HIDDEN_RECONNECT_GRACE + 1.0) # noqa: SLF001 + virtual_clock.advance(srv._radio_scope_session.hidden_grace_seconds + 1.0) # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 @@ -313,12 +319,12 @@ async def test_link_fault_with_scope_visible_reenables_once( """Real link fault with the scope screen on: the reconnect re-enables — at most once per viewing session, not on every reconnect cycle.""" caplog.set_level("INFO", logger="rigplane.web.server") - srv, _radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, _radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) srv._publish_scope_hidden(False, now=virtual_clock()) # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 assert len(queued) == 1 - assert "re-enable confirmed after reconnect" in caplog.text + assert srv._radio_scope_session.enabled await srv._refetch_and_reenable() # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 @@ -338,7 +344,7 @@ async def test_new_connection_epoch_rearms_reconnect_enable( so a second real outage (or a radio power cycle) while the viewer stays connected still gets its scope output restored. """ - srv, _radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, _radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) srv._publish_scope_hidden(False, now=virtual_clock()) # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 @@ -367,19 +373,16 @@ async def test_overlapping_reconnect_refetches_send_one_enable( radio._fetch_initial_state = AsyncMock() # noqa: SLF001 srv = WebServer(radio) srv._scope_handlers.add(MagicMock()) # noqa: SLF001 - srv._radio_scope_demand = True + srv.scope_hub + await srv._radio_scope_session.set_output(True) held: list[asyncio.Future[None]] = [] - put_ordered = srv.command_queue.put_ordered - - def observe_and_hold(command: object, **kwargs: object) -> object: - entry = put_ordered(command, **kwargs) - future = kwargs.get("future") - if isinstance(command, EnableScope) and isinstance(future, asyncio.Future): - held.append(future) - return entry + async def observe_and_hold(**kwargs): + future = asyncio.get_running_loop().create_future() + held.append(future) + await future - monkeypatch.setattr(srv.command_queue, "put_ordered", observe_and_hold) + radio.enable_scope.side_effect = observe_and_hold first = asyncio.create_task(srv._refetch_and_reenable()) # noqa: SLF001 second = asyncio.create_task(srv._refetch_and_reenable()) # noqa: SLF001 @@ -409,8 +412,7 @@ def observe_and_hold(command: object, **kwargs: object) -> object: task.cancel() await asyncio.gather(first, second, return_exceptions=True) - enables = [c for c in srv.command_queue.drain() if isinstance(c, EnableScope)] - assert len(enables) == 1 + assert len(held) == 1 @pytest.mark.timeout(30) @@ -428,7 +430,7 @@ def stamp_future_frame() -> None: # newer than the window's `now` — the old log printed "for -1s". srv._scope_started_at = virtual_clock() + 5.0 # noqa: SLF001 - srv, radio, queued = _virtual_scope_server( + srv, radio, queued = await _virtual_scope_server( monkeypatch, virtual_clock, on_enable=stamp_future_frame ) @@ -460,7 +462,7 @@ async def test_live_frames_clear_hidden_then_reconnect_enables_once( ) -> None: """The operator leaves the menu, frames resume, the verdict clears; the next reconnect re-enables (the guard no longer applies), still bounded.""" - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) async def scenario() -> None: await _elapse_scope_windows( @@ -491,7 +493,7 @@ async def test_new_viewing_session_allows_one_reconnect_enable( ) -> None: """The once-per-session bound is per viewing session: a fresh viewer generation earns one more reconnect re-enable, not unlimited ones.""" - srv, _radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, _radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) await srv._refetch_and_reenable() # noqa: SLF001 await srv._refetch_and_reenable() # noqa: SLF001 @@ -499,8 +501,8 @@ async def test_new_viewing_session_allows_one_reconnect_enable( for handler in list(srv._scope_handlers): srv.unregister_scope_handler(handler) - await srv._set_radio_scope_output(False) - await srv._set_radio_scope_output(True) + await srv._radio_scope_session.set_output(False) + await srv._radio_scope_session.set_output(True) user_enable = len(queued) # the user-demand enable, if any, is not counted await srv._refetch_and_reenable() # noqa: SLF001 @@ -720,7 +722,7 @@ async def test_scope_ws_menu_preserved_through_reconnect() -> None: await asyncio.wait_for(refetch_done.wait(), timeout=5.0) assert radio.enable_scope.await_count == 1 # no ejecting write - assert srv._scope_enabled is True # noqa: SLF001 + assert srv._radio_scope_session.enabled is True # noqa: SLF001 assert _scope_hidden_verdict(srv) is True finally: await _close_ws(writer) @@ -763,7 +765,7 @@ async def test_scope_ws_deliberate_user_enable_during_menu() -> None: # The first viewer leaves; the pre-session radio state is restored. await _close_ws(writer) await await_until( - lambda: not srv._scope_handlers and not srv._scope_enabled, # noqa: SLF001 + lambda: not srv._scope_handlers and not srv._radio_scope_session.enabled, # noqa: SLF001 timeout=5.0, description="scope torn down after the last viewer", ) diff --git a/tests/test_supports_command.py b/tests/test_supports_command.py index 385b65aef..c04a297e7 100644 --- a/tests/test_supports_command.py +++ b/tests/test_supports_command.py @@ -78,7 +78,6 @@ def ic7300_radio(ic7300_profile): "X6200": (15, 19), } _ALIASES = { - "disable_scope": ScopeRuntimeMixin, "get_scope_dual": ScopeRuntimeMixin, "get_scope_receiver": ScopeRuntimeMixin, "set_scope_receiver": ScopeRuntimeMixin, @@ -155,8 +154,11 @@ def _assert_parity(relations, sources=None) -> None: builder.__name__ for builder in by_name[name].builders } scope = by_name["enable_scope"].builders - assert {b.__name__ for b in scope} == _command_calls( - ScopeRuntimeMixin, "enable_scope", sources + assert {b.__name__ for b in scope} == {"scope_on", "scope_data_output"} + assert "scope_session" in _self_calls(ScopeRuntimeMixin, "enable_scope", sources) + assert "scope_session" in _self_calls(ScopeRuntimeMixin, "disable_scope", sources) + assert "scope_data_output" in _command_calls( + ScopeRuntimeMixin, "_write_scope_setting", sources ) assert by_name["capture_scope_frame"].builders == scope assert by_name["capture_scope_frames"].builders == scope diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 641189b8e..ad2b58f93 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -365,6 +365,9 @@ def _add_scope_capable_attrs(radio: MagicMock) -> MagicMock: radio.get_scope_hold = AsyncMock(return_value=False) radio.set_scope_hold = AsyncMock() + from _scope_session_fake import attach_scope_session + + attach_scope_session(radio) return radio @@ -2696,7 +2699,8 @@ async def test_shutdown_with_pending_scope_and_no_poller_is_fail_open( """A dead executor cannot block the later client-teardown phase.""" radio = _add_scope_capable_attrs(MagicMock()) server = WebServer(radio) - server._scope_enabled = True + server.scope_hub + await server._radio_scope_session.set_output(True) client_teardown = asyncio.Event() async def _client() -> None: @@ -2714,7 +2718,7 @@ async def _client() -> None: assert client_teardown.is_set() assert server._radio_poller is None - assert server._scope_enabled # retained as pending restoration evidence + assert server._radio_scope_session.active # pending restoration assert not server._command_queue.has_commands assert "radio poller is unavailable" in caplog.text @@ -2860,6 +2864,10 @@ def _make_radio(self) -> MagicMock: radio.equalize_main_sub = AsyncMock() radio.send_civ = AsyncMock() # RadioPoller now calls send_civ directly radio.state_cache = StateCache() + from _scope_session_fake import attach_scope_session + + radio.get_scope_session_state = AsyncMock(return_value=(False, False)) + attach_scope_session(radio) return radio async def test_poller_starts_and_stops(self) -> None: @@ -3033,6 +3041,10 @@ async def _select_receiver(which: object) -> None: radio.select_receiver = AsyncMock(side_effect=_select_receiver) radio.set_vfo_slot = AsyncMock() + from _scope_session_fake import attach_scope_session + + radio.get_scope_session_state = AsyncMock(return_value=(False, False)) + attach_scope_session(radio) return radio async def test_switch_scope_receiver_main_sends_civ(self) -> None: diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index 69064b65e..03c7878d2 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -278,7 +278,9 @@ def _scope_radio( radio.get_scope_hold = AsyncMock(return_value=False) radio.set_scope_hold = AsyncMock() - return radio + from test_web_server import _add_scope_capable_attrs + + return _add_scope_capable_attrs(radio) class _StateNotifyRadio(MagicMock): @@ -452,6 +454,9 @@ def __init__(self, *args: object, **kwargs: object) -> None: # attributes do not satisfy. self.capabilities = {"scope"} self.on_scope_data = MagicMock() + from test_web_server import _add_scope_capable_attrs + + _add_scope_capable_attrs(self) ScopeCapable.register(_ManagedTxStateNotifyRadio) @@ -500,15 +505,15 @@ async def test_managed_tx_start_still_registers_reconnect_recovery_pass() -> Non # Simulate the radio's post-soft_reconnect edge: the registered # callback must refetch state and queue the scope re-enable. srv._scope_handlers.add(MagicMock()) # noqa: SLF001 - srv._radio_scope_demand = True - scope_callbacks_before = radio.on_scope_data.call_count + srv.scope_hub + await srv._radio_scope_session.set_output(True) + radio.enable_scope.reset_mock() radio._reconnect_callback() - await _await_condition( - lambda: radio.on_scope_data.call_count > scope_callbacks_before - ) + await _await_condition(lambda: radio._fetch_initial_state.await_count == 1) radio._fetch_initial_state.assert_awaited_once() - assert any(isinstance(c, EnableScope) for c in srv.command_queue.drain()) + radio.enable_scope.assert_awaited_once() + assert srv._radio_scope_session.enabled await srv.stop() fake_poller.stop.assert_called_once() @@ -3552,15 +3557,14 @@ async def test_on_radio_reconnect_enables_scope_without_waiting_for_broadcast() radio._fetch_initial_state = AsyncMock() srv = WebServer(radio) srv._scope_handlers.add(MagicMock()) - srv._radio_scope_demand = True - - scope_callbacks_before = radio.on_scope_data.call_count + srv.scope_hub + await srv._radio_scope_session.set_output(True) + radio.enable_scope.reset_mock() srv._on_radio_reconnect() # noqa: SLF001 - await _await_condition( - lambda: radio.on_scope_data.call_count > scope_callbacks_before - ) + await _await_condition(lambda: radio._fetch_initial_state.await_count == 1) - assert any(isinstance(c, EnableScope) for c in srv.command_queue.drain()) + radio.enable_scope.assert_awaited_once() + assert srv._radio_scope_session.enabled assert radio.radio_ready is False # gate was bypassed, not flipped @@ -3635,7 +3639,7 @@ async def test_scope_health_monitor_link_down_then_menu_silence( ) -> None: """MOR-3690: link down resets the frame clock; menu silence (no frames, CI-V healthy) publishes hidden and never enqueues an enable.""" - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) radio.connected = False radio.radio_ready = False srv._scope_started_at = 0.0 # noqa: SLF001 @@ -3644,7 +3648,7 @@ async def test_scope_health_monitor_link_down_then_menu_silence( try: await _elapse_scope_windows(virtual_clock, 2) # Link down: the frame clock is reset inside the monitor window. - assert 0.0 <= virtual_clock() - srv._scope_started_at <= 10.0 # noqa: SLF001 + assert srv._radio_scope_session.health(civ_flowing=True).hidden is None # noqa: SLF001 radio.connected = True radio.radio_ready = True await _elapse_scope_windows( @@ -3742,6 +3746,8 @@ def test_scope_frame_fixtures_are_producer_typed() -> None: def _feed_scope_health(srv: WebServer, frame: ScopeFrame) -> None: now = asyncio.get_running_loop().time() + if srv._radio_scope_session is not None: + srv._radio_scope_session.on_frame(frame) hub = srv.scope_hub hub._sources["radio"].health[frame.receiver] = ScopeHealth( now, @@ -3759,7 +3765,7 @@ def _scope_hidden_verdict(srv: WebServer) -> object: return None -def _virtual_scope_server( +async def _virtual_scope_server( monkeypatch: pytest.MonkeyPatch, clock: VirtualClock, *, @@ -3780,27 +3786,24 @@ def _virtual_scope_server( if radio is None: radio = _scope_radio() radio._fetch_initial_state = AsyncMock() # noqa: SLF001 + from _scope_session_fake import attach_scope_session + from rigplane.scope.models import ScopeDemand + + radio.get_scope_session_state.return_value = (True, True) + session = attach_scope_session(radio, clock=clock, silence_seconds=interval) srv = WebServer(radio, WebConfig(radio_model="IC-7610")) srv.scope_hub - srv._scope_handlers.add(MagicMock()) # noqa: SLF001 - srv._radio_scope_demand = True - srv._scope_enabled = True # noqa: SLF001 + srv._scope_handlers.add(MagicMock()) + await session.set_demand(ScopeDemand(1, 1)) srv._scope_health_interval = interval # noqa: SLF001 queued: list[EnableScope] = [] - put_ordered = srv.command_queue.put_ordered # noqa: SLF001 - def observe_and_complete(command: object, **kwargs: object) -> object: - entry = put_ordered(command, **kwargs) - if isinstance(command, EnableScope): - queued.append(command) - if on_enable is not None: - on_enable() - future = kwargs.get("future") - if isinstance(future, asyncio.Future) and not future.done(): - future.set_result(None) - return entry + async def record_enable(**kwargs): + queued.append(EnableScope()) + if on_enable is not None: + on_enable() - monkeypatch.setattr(srv.command_queue, "put_ordered", observe_and_complete) + radio.enable_scope.side_effect = record_enable clock.scope_to(monkeypatch, server_module) return srv, radio, queued @@ -3931,7 +3934,7 @@ async def test_scope_health_monitor_verdicts_around_tx( ) -> None: """MOR-2859/MOR-3690: TX pauses judgement; live frames publish visible; a later menu silence publishes hidden — all without a single write.""" - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) srv._managed_tx_keyed = True # noqa: SLF001 monitor = asyncio.create_task(srv._scope_health_monitor()) # noqa: SLF001 @@ -3968,7 +3971,7 @@ async def test_scope_health_monitor_stale_ptt_writes_nothing( ) -> None: """MOR-2859/MOR-3690: stale observed PTT is not TX — the monitor judges the stall normally (publishes hidden) and still never writes.""" - srv, radio, queued = _virtual_scope_server(monkeypatch, virtual_clock) + srv, radio, queued = await _virtual_scope_server(monkeypatch, virtual_clock) # The ON observation is already stale: stamped 100 s behind the clock. srv.command_state_store.apply_current( _store_observation( diff --git a/tests/web/test_scope_hub_channel.py b/tests/web/test_scope_hub_channel.py index 6bc09504c..baf40e4dd 100644 --- a/tests/web/test_scope_hub_channel.py +++ b/tests/web/test_scope_hub_channel.py @@ -20,10 +20,16 @@ class Writer: def __init__(self): self.output = [] + async def set_fixed(self, edges, receiver): + pass + + def on_frame(self, frame): + pass + def tuned_frequency(self, receiver): return 14_100_000 - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): self.output.append(enabled) async def set_center(self, center_hz, span_hz, receiver): @@ -111,7 +117,7 @@ async def test_two_clients_resample_and_pace_simulator_frames( ): from rigplane.scope import ScopeAssembler from rigplane.web.protocol import decode_scope_frame_v2, encode_scope_frame_v2 - from rigplane_testkit.icom import MockIcomRadio + from _scope_simulator import ScopeControlSimulator as MockIcomRadio server = WebServer(None) writer = Writer() @@ -173,7 +179,7 @@ async def test_real_websockets_negotiate_independent_views_and_audio_selection() from rigplane.scope import ScopeAssembler from rigplane.web.protocol import decode_scope_frame_v2 from rigplane.web.server import WebConfig - from rigplane_testkit.icom import MockIcomRadio + from _scope_simulator import ScopeControlSimulator as MockIcomRadio from test_web_server import _addr server = WebServer(None, WebConfig(host="127.0.0.1", port=0)) @@ -192,7 +198,10 @@ async def test_real_websockets_negotiate_independent_views_and_audio_selection() a = await client.ws_connect(f"http://{host}:{port}/api/v1/scope") b = await client.ws_connect(f"http://{host}:{port}/api/v1/scope") try: - assert server._radio_scope_demand is False + assert ( + server._radio_scope_session is None + or server._radio_scope_session.active is False + ) for ws, width, fps in ((a, 1024, 10), (b, 2800, 20)): await ws.send_str( encode_scope_subscription( @@ -293,7 +302,9 @@ async def test_real_server_two_clients_simulator_pacing_and_last_viewer_output_o monkeypatch.setattr( testkit, "_SCOPE_GAP_S", {key: 1 / 30 for key in testkit._SCOPE_GAP_S} ) - sim = testkit.MockIcomRadio(scope_stream=True) + from _scope_simulator import ScopeControlSimulator + + sim = ScopeControlSimulator(scope_stream=True) assembler = ScopeAssembler() native = None for raw in sim._scope_templates[0]: @@ -331,6 +342,7 @@ def count(frame): feed(frame) monkeypatch.setattr(source, "feed_frame", count) + server._set_scope_data_callback(count) host, port = _addr(server) received = [[], []] async with aiohttp.ClientSession() as client: @@ -340,7 +352,7 @@ def count(frame): ] readers = [] try: - assert not server._scope_enabled + assert not server._radio_scope_session.enabled for ws, width, fps in zip(sockets, (1024, 2800), (10, 20), strict=True): await ws.send_str( encode_scope_subscription( @@ -408,7 +420,9 @@ async def receive(index, ws): for ws in sockets: await ws.close() await asyncio.gather(*readers, return_exceptions=True) - await await_until(lambda: not server._scope_enabled, timeout=5) + await await_until( + lambda: not server._radio_scope_session.enabled, timeout=5 + ) assert sim._scope_11 == 0 finally: if server is not None: @@ -427,14 +441,19 @@ async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( from types import SimpleNamespace from rigplane.web.protocol import decode_scope_frame_v2 - source = RadioScopeSource(resolve_radio_profile(model="ic705"), Writer()) + from test_web_server_coverage import _scope_radio + from _scope_session_fake import attach_scope_session + + radio = _scope_radio() + radio.profile = resolve_radio_profile(model="ic705") + session = attach_scope_session(radio, clock=virtual_clock) + source = RadioScopeSource(radio.profile, session) server = WebServer(None) - server.scope_hub.register("radio", source) + server.scope_hub.register("radio", source, on_demand=session.set_demand) server._radio_scope_source = source + server._radio_scope_session = session server._radio = SimpleNamespace(last_valid_frame_monotonic=virtual_clock()) server._hardware_scope_available = True - server._radio_scope_demand = True - server._scope_enabled = True server._scope_health_interval = 0.1 monkeypatch.setattr(server, "_radio_ready", lambda: True) monkeypatch.setattr(server, "_scope_tx_known", lambda: False) @@ -464,36 +483,33 @@ async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( async def test_writer_maps_profile_span_index_and_never_retunes_off_vfo_view( model, monkeypatch ): - from rigplane.web.radio_poller import ( - SetScopeMode, - SetScopeSpan, - SwitchScopeReceiver, - ) - from rigplane.radio_state import RadioState - from rigplane.web.server import WebConfig - from rigplane.web.scope_sources import WebRadioScopeWriter - - server = WebServer(None, WebConfig(radio_model=model)) - server._radio_state = RadioState() - server._radio_state.main.freq = 14_100_000 - writer = WebRadioScopeWriter(server) - source = RadioScopeSource(resolve_radio_profile(model=model), writer) - commands = [] + from _scope_session_fake import attach_scope_session + from test_web_server_coverage import _scope_radio - def execute(command, *, future): - commands.append(command) - future.set_result(None) + radio = _scope_radio() + radio.profile = resolve_radio_profile(model=model) + writer = attach_scope_session(radio) + source = RadioScopeSource(radio.profile, writer) + from rigplane.core.types import ScopeFixedEdge - monkeypatch.setattr(server.command_queue, "put_ordered", execute) requested = View(14_180_000, 14_200_000) grant = await source.set_view(requested, 0) assert grant.view == requested assert grant.coverage is not None - assert (grant.coverage.start_hz + grant.coverage.end_hz) / 2 == 14_100_000 - span = grant.coverage.end_hz - grant.coverage.start_hz - index = server._get_profile().scope_span_presets_hz.index(span) - assert commands == [SwitchScopeReceiver(0), SetScopeMode(0), SetScopeSpan(index)] - assert server._radio_state.main.freq == 14_100_000 + if radio.profile.scope_fixed_edge_ranges: + assert grant.coverage.start_hz == requested.start_hz + assert grant.coverage.end_hz == requested.end_hz + assert radio.scope_writes == [ + ("fixed_edge", ScopeFixedEdge(6, 1, 14180000, 14200000), 0), + ("edge", 1, 0), + ("mode", 1, 0), + ] + else: + assert (grant.coverage.start_hz + grant.coverage.end_hz) / 2 == 14_100_000 + span = grant.coverage.end_hz - grant.coverage.start_hz + index = radio.profile.scope_span_presets_hz.index(span) + assert radio.scope_writes == [("mode", 0, 0), ("span", index, 0)] + assert writer.tuned_frequency(0) == 14_100_000 @pytest.mark.asyncio @@ -680,7 +696,7 @@ async def test_shutdown_cleanup_failure_keeps_other_sources_releasing( class FailingWriter(Writer): fail = True - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): await super().set_output(enabled) if not enabled and self.fail: raise RuntimeError("output restoration failed") @@ -721,46 +737,43 @@ async def stop_executor(received): async def test_reconnect_waiting_behind_last_close_cannot_revive_output(monkeypatch): from unittest.mock import AsyncMock from test_web_server_coverage import _scope_radio - from rigplane.web.radio_poller import DisableScope, EnableScope from rigplane.web.server import WebConfig radio = _scope_radio() radio._fetch_initial_state = AsyncMock() server = WebServer(radio, WebConfig(radio_model="IC-7610")) - server._radio_scope_demand = True - server._scope_enabled = True server.scope_hub - commands = [] - held = [] - put = server.command_queue.put_ordered - - def complete(command, **kwargs): - entry = put(command, **kwargs) - commands.append(command) - future = kwargs.get("future") - if isinstance(command, EnableScope) and not held: - held.append(future) - elif future is not None: - future.set_result(None) - return entry - - monkeypatch.setattr(server.command_queue, "put_ordered", complete) + session = server._radio_scope_session + await session.set_output(True) + changes = [] + held = asyncio.Event() + release = asyncio.Event() + + async def enable(**kwargs): + changes.append(True) + held.set() + await release.wait() + + async def disable(**kwargs): + changes.append(False) + + radio.enable_scope.side_effect = enable + radio.disable_scope.side_effect = disable first = asyncio.create_task(server._refetch_and_reenable()) close = second = None try: - await await_until(lambda: bool(held)) - close = asyncio.create_task(server._set_radio_scope_output(False)) - await await_until(lambda: len(server._scope_enable_lock._waiters or ()) == 1) + assert await await_until(held.is_set) + close = asyncio.create_task(session.set_output(False)) + assert await await_until(lambda: len(session._lock._waiters or ()) == 1) second = asyncio.create_task(server._refetch_and_reenable()) - await await_until(lambda: len(server._scope_enable_lock._waiters or ()) == 2) - held[0].set_result(None) + assert await await_until(lambda: len(session._lock._waiters or ()) == 2) + release.set() await asyncio.gather(first, close, second) - assert [type(command) for command in commands] == [EnableScope, DisableScope] - assert server._radio_scope_demand is False - assert server._scope_enabled is False + assert changes == [True, False] + assert session.active is False + assert session.enabled is False finally: - if held and not held[0].done(): - held[0].set_result(None) + release.set() tasks = [task for task in (first, close, second) if task is not None] for task in tasks: if not task.done(): @@ -778,11 +791,11 @@ async def test_previous_session_health_cannot_override_new_session_grace( _live_scope_frame, ) - server, _, _ = _virtual_scope_server(monkeypatch, virtual_clock) + server, _, _ = await _virtual_scope_server(monkeypatch, virtual_clock) _feed_scope_health(server, _live_scope_frame()) await virtual_clock.elapse(100) - await server._set_radio_scope_output(False) - await server._set_radio_scope_output(True) + await server._radio_scope_session.set_output(False) + await server._radio_scope_session.set_output(True) monkeypatch.setattr(server, "_civ_link_flowing", lambda now: True) import rigplane.web.server as server_module @@ -803,17 +816,15 @@ async def test_scope_enable_success_starts_new_health_grace( ): from test_web_server_coverage import _virtual_scope_server - server, _, _ = _virtual_scope_server(monkeypatch, virtual_clock) + server, _, _ = await _virtual_scope_server(monkeypatch, virtual_clock) await virtual_clock.elapse(100) - server._scope_enabled = False + await server._radio_scope_session.invalidate() if enable_path == "deferred": - await server._wait_and_enable_scope( - reason="readiness", generation=server._scope_demand_generation - ) + await server._radio_scope_session.set_ready(True) else: await server._refetch_and_reenable() - assert server._scope_enabled is True - assert server._scope_started_at == virtual_clock() + assert server._radio_scope_session.enabled is True + assert server._radio_scope_session.health(civ_flowing=True).hidden is False @pytest.mark.parametrize( @@ -868,7 +879,7 @@ async def test_one_real_scope_socket_multiplexes_radio_and_audio(): from rigplane.web.protocol import decode_scope_frame_v2 from rigplane.web.scope_sources import WebScopePcmTap from rigplane.web.server import WebConfig - from rigplane_testkit.icom import MockIcomRadio + from _scope_simulator import ScopeControlSimulator as MockIcomRadio from test_handlers_coverage import _RelayRadio from test_audio_scope_dispatch import _seed_audio_facts from test_web_server import _addr @@ -993,7 +1004,7 @@ async def test_multiplex_cleanup_attempts_all_streams_after_one_stop_fails(): class FailingWriter(Writer): fail = True - async def set_output(self, enabled): + async def set_output(self, enabled, *, release=True): await super().set_output(enabled) if not enabled and self.fail: raise RuntimeError("restore failed") diff --git a/tests/web/test_scope_server_lifecycle.py b/tests/web/test_scope_server_lifecycle.py index 39f02f5d3..0479d8dd6 100644 --- a/tests/web/test_scope_server_lifecycle.py +++ b/tests/web/test_scope_server_lifecycle.py @@ -158,7 +158,7 @@ async def test_server_shutdown_retires_real_radio_subscription_after_poller_stop cancelled_close, monkeypatch ): from rigplane.radio import IcomRadio - from rigplane_testkit.icom import MockIcomRadio + from _scope_simulator import ScopeControlSimulator as MockIcomRadio sim = MockIcomRadio(scope_stream=True) await sim.start() @@ -230,7 +230,7 @@ def record_command(command, **kwargs): assert not server._scope_command_futures assert not server._radio_scope_source._active restore.assert_awaited_once() - assert not server._scope_enabled + assert not server._radio_scope_session.enabled finally: # Recover the old implementation's blocked queue wait after the RED assertion. for future in futures: @@ -349,7 +349,7 @@ async def client(): assert not handler._streams assert not source._active assert not source._callbacks - assert not server._scope_enabled + assert not server._radio_scope_session.enabled assert not server._scope_enable_failed assert server._server is None listener.close.assert_called_once() From 165e35af916f410c8ebadb8cc68cf81883950981 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:23:24 -0500 Subject: [PATCH 12/62] test(MOR-4274): pin paused startup and demand failure cleanup Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 35 +++++++++++++++++++++++++++++ tests/scope/test_hub.py | 16 +++++++++++++ 2 files changed, 51 insertions(+) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 85d509a7a..45e444dc5 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -263,3 +263,38 @@ async def write(name, value, receiver): await session.set_demand(ScopeDemand(0, 0)) assert current == original assert writes[-1] == ("output_on", False) + + +async def test_initial_paused_demand_never_enables_panel_and_keeps_hub_count(): + writer = Writer() + session = ScopeSession(writer.read, writer.write, ready=True) + await session.set_demand(ScopeDemand(3, 0)) + assert writer.writes == [] + generation = session.generation + await session.set_output(False, release=False) + assert session.generation == generation + await session.set_demand(ScopeDemand(3, 1)) + assert writer.writes == [(0x10, True), (0x11, True)] + await session.set_demand(ScopeDemand(3, 0)) + assert writer.writes[-1] == (0x11, False) + assert writer.writes.count((0x10, False)) == 0 + await session.set_demand(ScopeDemand(0, 0)) + assert writer.state == ScopeState(False, False) + + +async def test_invalid_view_write_does_not_poison_the_next_request(): + writes = [] + + async def read(): + return ScopeState(False, False) + + async def write(name, value, receiver): + writes.append((name, value)) + if name == "mode" and value == 99: + raise ValueError("invalid mode") + + session = ScopeSession(read, write, ready=True) + with pytest.raises(ValueError, match="invalid mode"): + await session.configure(mode=99) + await session.configure(span=4) + assert writes == [("mode", 99), ("span", 4)] diff --git a/tests/scope/test_hub.py b/tests/scope/test_hub.py index 780188b40..bc6c95e1d 100644 --- a/tests/scope/test_hub.py +++ b/tests/scope/test_hub.py @@ -645,3 +645,19 @@ async def test_failed_stop_retries_with_retained_paused_subscription(): assert source.stops == 2 await paused.close() assert source.stops == 2 + + +async def test_failed_demand_callback_cleans_up_subscriber_and_frame_hook(): + source = FakeSource() + hub = ScopeHub(encoder=Encoder()) + + async def demand(value): + if value.subscribers: + raise RuntimeError("demand failed") + + hub.register("radio", source, on_demand=demand) + with pytest.raises(RuntimeError, match="demand failed"): + await hub.subscribe(request()) + assert not source.callbacks + assert not hub._sources["radio"].clients + assert source.stops == 1 From 6493c5dd4e90e63a61f75af94487ddad4e102f3a Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:24:15 -0500 Subject: [PATCH 13/62] test(MOR-4274): require minimal viewport restoration writes Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 10 ++++++++++ tests/scope/test_hub.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 45e444dc5..0b68f87b7 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -298,3 +298,13 @@ async def write(name, value, receiver): await session.configure(mode=99) await session.configure(span=4) assert writes == [("mode", 99), ("span", 4)] + + +async def test_restore_only_writes_view_settings_the_session_changed(): + writer = Writer() + session = ScopeSession(writer.read, writer.write, ready=True, spans=(2500, 5000, 10000, 25000)) + await session.set_output(True) + await session.set_center(14100000, 25000, 0) + assert writer.views == [("span", 3, 0)] + await session.set_output(False) + assert writer.views == [("span", 3, 0), ("span", 2, 0)] diff --git a/tests/scope/test_hub.py b/tests/scope/test_hub.py index bc6c95e1d..547d80a22 100644 --- a/tests/scope/test_hub.py +++ b/tests/scope/test_hub.py @@ -657,7 +657,7 @@ async def demand(value): hub.register("radio", source, on_demand=demand) with pytest.raises(RuntimeError, match="demand failed"): - await hub.subscribe(request()) + await hub.subscribe(request(source="radio")) assert not source.callbacks assert not hub._sources["radio"].clients assert source.stops == 1 From 7cf7cb6bb987153b389fd3c7b5c55efcd635e9d4 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:26:34 -0500 Subject: [PATCH 14/62] test(MOR-4274): pin the shared fixed-slot observation across receivers Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 0b68f87b7..fee0642a9 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -308,3 +308,15 @@ async def test_restore_only_writes_view_settings_the_session_changed(): assert writer.views == [("span", 3, 0)] await session.set_output(False) assert writer.views == [("span", 3, 0), ("span", 2, 0)] + + +async def test_shared_fixed_slot_is_actuated_again_after_other_receiver_changes_it(): + writer = Writer() + session = ScopeSession(writer.read, writer.write, writer.read_fixed, ready=True) + await session.set_output(True) + first = ScopeFixedEdge(6, 1, 14000000, 14100000) + second = ScopeFixedEdge(6, 1, 14100000, 14200000) + await session.set_fixed(first, 0) + await session.set_fixed(second, 1) + await session.set_fixed(first, 0) + assert [value for name, value, receiver in writer.views if name == "fixed_edge"] == [first, second, first] From 505eacbfc19421d1fb5bdb2ef8e7d532be314dcc Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:28:39 -0500 Subject: [PATCH 15/62] test(MOR-4274): keep receiver fixed-edge slots independent Co-Authored-By: Claude Opus 5.5 --- tests/scope/test_radio_scope_source.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index 004af80a2..9e76d855a 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -211,3 +211,15 @@ async def set_fixed(self, fixed_edge, receiver): grant = await source.set_view(request, 0) assert writer.writes[-1] == (ScopeFixedEdge(6, 1, 14_050_000, 14_151_000), 0) assert grant.coverage == Coverage(14_050_000, 14_151_000) + + +async def test_fixed_views_on_two_receivers_use_distinct_profile_slots(): + class FixedWriter(Writer): + async def set_fixed(self, edges, receiver): + self.writes.append((edges, receiver)) + + writer = FixedWriter() + source = RadioScopeSource(load_rig(Path("rigs/ic7610.toml")).to_profile(), writer) + await source.set_view(View(14_050_000, 14_150_000), 0) + await source.set_view(View(14_150_000, 14_250_000), 1) + assert [(value.edge, receiver) for value, receiver in writer.writes] == [(1, 0), (2, 1)] From 08a5a13f1be2c4b3a50257d15bd3220138992809 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:30:37 -0500 Subject: [PATCH 16/62] test(MOR-4274): cancel pending views on pre-readiness release Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index fee0642a9..135428d01 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -320,3 +320,14 @@ async def test_shared_fixed_slot_is_actuated_again_after_other_receiver_changes_ await session.set_fixed(second, 1) await session.set_fixed(first, 0) assert [value for name, value, receiver in writer.views if name == "fixed_edge"] == [first, second, first] + + +async def test_last_release_before_readiness_discards_the_pending_view(): + writer = Writer() + session = ScopeSession(writer.read, writer.write) + await session.set_demand(ScopeDemand(1, 1)) + await session.configure(mode=1, span=3) + await session.set_demand(ScopeDemand(0, 0)) + await session.set_ready(True) + assert writer.writes == [] and writer.views == [] + assert not session.active From cff1b921d76de373f195de48795c963ccf2e9d54 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:31:52 -0500 Subject: [PATCH 17/62] test(MOR-4274): isolate wire restore and transmit deferral regressions Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_wire.py | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index e258da8ae..701c9ad77 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -121,3 +121,50 @@ async def expect(payload, **kwargs): await radio.disable_scope() await radio.disable_scope() assert writes[-2:] == [(0x27, 0x10, b"\x00"), (0x27, 0x11, b"\x00")] + + +@pytest.mark.parametrize("transport", ["lan", "serial"]) +@pytest.mark.parametrize("initial", [(False, False), (True, False), (True, True)]) +async def test_last_release_restores_wire_snapshot_once(transport, initial, monkeypatch): + from rigplane.radio import IcomRadio + from rigplane.backends.ic7300 import Ic7300SerialRadio + from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR + + radio = (IcomRadio("127.0.0.1", model="IC-7300") if transport == "lan" + else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200)) + radio._connected = True + observed = {0x10: initial[0], 0x11: initial[1]} + writes = [] + + async def send(payload, **kwargs): + frame = parse_civ_frame(payload) + observed[frame.sub] = bool(frame.data[0]) + writes.append((frame.sub, frame.data)) + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB)) + + async def expect(payload, **kwargs): + frame = parse_civ_frame(payload) + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, frame.command, sub=frame.sub, data=bytes([observed[frame.sub]]))) + + monkeypatch.setattr(radio, "_send_civ_raw", send) + monkeypatch.setattr(radio, "_send_civ_expect", expect) + monkeypatch.setattr(radio, "_check_connected", lambda: None) + await radio.enable_scope(policy="fast") + await radio.disable_scope() + assert (observed[0x10], observed[0x11]) == initial + restored = list(writes) + await radio.disable_scope() + assert writes == restored + + +async def test_known_tx_defers_sdk_scope_wire_writes(monkeypatch): + from rigplane.radio import IcomRadio + from unittest.mock import AsyncMock + + radio = IcomRadio("127.0.0.1", model="IC-7300") + radio._connected = True + radio._radio_state.ptt = True + send = AsyncMock() + monkeypatch.setattr(radio, "_send_civ_raw", send) + await radio.enable_scope(policy="fast") + send.assert_not_awaited() From ffa10b5730379b05c2b78083f159da04f85e3108 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:42:16 -0500 Subject: [PATCH 18/62] test(MOR-4274): exercise session cancellation through real lifecycle inputs Co-Authored-By: Claude Opus 5.5 --- .../icom/scope_control.py | 0 tests/_scope_session_fake.py | 4 +- tests/runtime/test_scope_session.py | 11 +++ tests/runtime/test_scope_session_wire.py | 3 +- tests/scope/test_audio_scope_source.py | 2 + tests/test_icom7610_serial_radio.py | 8 +- tests/test_radio_poller_coverage.py | 23 +++--- tests/test_web_managed_ptt_rejection.py | 2 + tests/web/test_scope_hub_channel.py | 8 +- tests/web/test_scope_server_lifecycle.py | 77 ++++++++++--------- 10 files changed, 81 insertions(+), 57 deletions(-) rename tests/_scope_simulator.py => rigplane_testkit/icom/scope_control.py (100%) diff --git a/tests/_scope_simulator.py b/rigplane_testkit/icom/scope_control.py similarity index 100% rename from tests/_scope_simulator.py rename to rigplane_testkit/icom/scope_control.py diff --git a/tests/_scope_session_fake.py b/tests/_scope_session_fake.py index eb67e76fa..9643af8fb 100644 --- a/tests/_scope_session_fake.py +++ b/tests/_scope_session_fake.py @@ -7,8 +7,8 @@ from rigplane.runtime.scope_session import ScopeSession, ScopeState -def attach_scope_session(radio, **kwargs): - current = ScopeState(False, False, 0, 0, 1) +def attach_scope_session(radio, *, initial=None, **kwargs): + current = initial or ScopeState(False, False, 0, 0, 1) writes = [] async def read(): diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 135428d01..f79dd9824 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -331,3 +331,14 @@ async def test_last_release_before_readiness_discards_the_pending_view(): await session.set_ready(True) assert writer.writes == [] and writer.views == [] assert not session.active + + +async def test_restored_output_without_subscribers_has_no_menu_verdict(virtual_clock): + writer = Writer() + writer.state = ScopeState(True, True) + session = ScopeSession(writer.read, writer.write, ready=True, clock=virtual_clock) + await session.set_output(True) + await session.set_output(False) + virtual_clock.advance(60) + assert session.observed.output_on is True + assert session.health(civ_flowing=True).hidden is None diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 701c9ad77..38d091a29 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -11,7 +11,7 @@ async def test_server_simulator_pause_resume_release_counts_scope_writes(monkeyp from rigplane.testing.timing import await_until from rigplane.web.protocol import encode_scope_subscription from rigplane.web.server import WebConfig, WebServer - from _scope_simulator import ScopeControlSimulator + from rigplane_testkit.icom.scope_control import ScopeControlSimulator from test_web_server import _addr sim = ScopeControlSimulator(scope_stream=True) @@ -164,6 +164,7 @@ async def test_known_tx_defers_sdk_scope_wire_writes(monkeypatch): radio = IcomRadio("127.0.0.1", model="IC-7300") radio._connected = True radio._radio_state.ptt = True + monkeypatch.setattr(radio, "_check_connected", lambda: None) send = AsyncMock() monkeypatch.setattr(radio, "_send_civ_raw", send) await radio.enable_scope(policy="fast") diff --git a/tests/scope/test_audio_scope_source.py b/tests/scope/test_audio_scope_source.py index fc2e555a5..4cf7c3bea 100644 --- a/tests/scope/test_audio_scope_source.py +++ b/tests/scope/test_audio_scope_source.py @@ -273,7 +273,9 @@ async def test_unavailable_facts_drop_pcm_and_recovery_cannot_mix_history( ): from types import SimpleNamespace from unittest.mock import Mock + import logging import rigplane.audio.fft_scope as fft_module + caplog.set_level(logging.WARNING) monkeypatch.setattr(fft_module, "time", SimpleNamespace(monotonic=virtual_clock)) valid = AudioPassband(14_100_000, 300, 2700, "upper") diff --git a/tests/test_icom7610_serial_radio.py b/tests/test_icom7610_serial_radio.py index a46ae48fe..a0ea33784 100644 --- a/tests/test_icom7610_serial_radio.py +++ b/tests/test_icom7610_serial_radio.py @@ -2275,8 +2275,8 @@ async def test_rejected_low_baud_scope_enable_never_emits_scope_off() -> None: poller = RadioPoller(radio, queue, radio_state=radio.radio_state) with pytest.raises(CommandError, match="baudrate"): - await poller._execute(EnableScope(generation=1)) - await poller._execute(DisableScope(generation=2)) + await poller._execute(EnableScope()) + await poller._execute(DisableScope()) await radio.disconnect() signatures = [ @@ -2319,9 +2319,9 @@ async def enable_scope_with_short_verify(*, policy: str) -> None: poller = RadioPoller(radio, CommandQueue(), radio_state=radio.radio_state) with pytest.raises(RigplaneTimeoutError, match="verification timed out"): - await poller._execute(EnableScope(policy="verify", generation=1)) + await poller._execute(EnableScope(policy="verify")) sent_before_disable = len(link.sent_frames) - await poller._execute(DisableScope(generation=2)) + await poller._execute(DisableScope()) assert len(link.sent_frames) == sent_before_disable await radio.disconnect() diff --git a/tests/test_radio_poller_coverage.py b/tests/test_radio_poller_coverage.py index 024ed5030..e434ff6de 100644 --- a/tests/test_radio_poller_coverage.py +++ b/tests/test_radio_poller_coverage.py @@ -3997,12 +3997,12 @@ async def test_stale_deferred_enable_cannot_requeue_after_newer_disable() -> Non poller = RadioPoller(radio, StateCache(), queue, radio_state=RadioState()) poller._initial_fetch_done.clear() # noqa: SLF001 - queue.put(EnableScope(policy="fast", generation=1)) + queue.put(EnableScope(policy="fast")) (old_enable,) = queue.drain() await poller._execute(old_enable) # noqa: SLF001 assert queue.has_commands is False - queue.put(DisableScope(generation=2)) + queue.put(DisableScope()) for command in queue.drain(): await poller._execute(command) # noqa: SLF001 @@ -4017,9 +4017,9 @@ async def test_stale_disable_is_inert_after_newer_enable_demand() -> None: queue = CommandQueue() poller = RadioPoller(radio, StateCache(), queue, radio_state=RadioState()) - queue.put(DisableScope(generation=3)) + queue.put(DisableScope()) (old_disable,) = queue.drain() - queue.put(EnableScope(policy="fast", generation=4)) + queue.put(EnableScope(policy="fast")) await poller._execute(old_disable) # noqa: SLF001 for command in queue.drain(): @@ -4035,8 +4035,8 @@ async def test_current_scope_generation_preserves_recovery_enable() -> None: queue = CommandQueue() poller = RadioPoller(radio, StateCache(), queue, radio_state=RadioState()) - await poller._execute(EnableScope(generation=5)) # noqa: SLF001 - await poller._execute(EnableScope(generation=5)) # noqa: SLF001 + await poller._execute(EnableScope()) # noqa: SLF001 + await poller._execute(EnableScope()) # noqa: SLF001 assert radio.enable_scope.await_count == 1 radio.get_scope_session_state.assert_awaited_once() @@ -4051,9 +4051,9 @@ async def test_scope_session_restores_exact_initial_state( radio.get_scope_session_state.return_value = initial poller = RadioPoller(radio, StateCache(), CommandQueue(), radio_state=RadioState()) - await poller._execute(EnableScope(generation=1)) # noqa: SLF001 - await poller._execute(EnableScope(generation=1)) # noqa: SLF001 - await poller._execute(DisableScope(generation=2)) # noqa: SLF001 + await poller._execute(EnableScope()) # noqa: SLF001 + await poller._execute(EnableScope()) # noqa: SLF001 + await poller._execute(DisableScope()) # noqa: SLF001 radio.get_scope_session_state.assert_awaited_once() assert radio.enable_scope.await_count == 1 @@ -4070,8 +4070,8 @@ async def test_failed_scope_enable_rolls_back_captured_state() -> None: poller = RadioPoller(radio, StateCache(), CommandQueue(), radio_state=RadioState()) with pytest.raises(ConnectionError, match="low baud"): - await poller._execute(EnableScope(generation=1)) # noqa: SLF001 - await poller._execute(DisableScope(generation=2)) # noqa: SLF001 + await poller._execute(EnableScope()) # noqa: SLF001 + await poller._execute(DisableScope()) # noqa: SLF001 assert radio.scope_session().observed.scope_on is False assert radio.scope_session().observed.output_on is False @@ -5844,6 +5844,7 @@ def _shutdown_server( _dx_client_task=None, _zombie_reaper_task=None, _scope_health_task=None, + _scope_handlers=set(), _scope_reenable_task=None, _bg_tasks=[], _client_tasks=client_tasks, diff --git a/tests/test_web_managed_ptt_rejection.py b/tests/test_web_managed_ptt_rejection.py index bbdb43f37..1f00d89ca 100644 --- a/tests/test_web_managed_ptt_rejection.py +++ b/tests/test_web_managed_ptt_rejection.py @@ -66,6 +66,8 @@ def _radio() -> MagicMock: radio.radio_ready = True radio.set_ptt = AsyncMock() radio._civ_epoch = 1 + from _scope_session_fake import attach_scope_session + attach_scope_session(radio) return radio diff --git a/tests/web/test_scope_hub_channel.py b/tests/web/test_scope_hub_channel.py index baf40e4dd..fa87d6e16 100644 --- a/tests/web/test_scope_hub_channel.py +++ b/tests/web/test_scope_hub_channel.py @@ -117,7 +117,7 @@ async def test_two_clients_resample_and_pace_simulator_frames( ): from rigplane.scope import ScopeAssembler from rigplane.web.protocol import decode_scope_frame_v2, encode_scope_frame_v2 - from _scope_simulator import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio server = WebServer(None) writer = Writer() @@ -179,7 +179,7 @@ async def test_real_websockets_negotiate_independent_views_and_audio_selection() from rigplane.scope import ScopeAssembler from rigplane.web.protocol import decode_scope_frame_v2 from rigplane.web.server import WebConfig - from _scope_simulator import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio from test_web_server import _addr server = WebServer(None, WebConfig(host="127.0.0.1", port=0)) @@ -302,7 +302,7 @@ async def test_real_server_two_clients_simulator_pacing_and_last_viewer_output_o monkeypatch.setattr( testkit, "_SCOPE_GAP_S", {key: 1 / 30 for key in testkit._SCOPE_GAP_S} ) - from _scope_simulator import ScopeControlSimulator + from rigplane_testkit.icom.scope_control import ScopeControlSimulator sim = ScopeControlSimulator(scope_stream=True) assembler = ScopeAssembler() @@ -879,7 +879,7 @@ async def test_one_real_scope_socket_multiplexes_radio_and_audio(): from rigplane.web.protocol import decode_scope_frame_v2 from rigplane.web.scope_sources import WebScopePcmTap from rigplane.web.server import WebConfig - from _scope_simulator import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio from test_handlers_coverage import _RelayRadio from test_audio_scope_dispatch import _seed_audio_facts from test_web_server import _addr diff --git a/tests/web/test_scope_server_lifecycle.py b/tests/web/test_scope_server_lifecycle.py index 0479d8dd6..b3b7d712e 100644 --- a/tests/web/test_scope_server_lifecycle.py +++ b/tests/web/test_scope_server_lifecycle.py @@ -158,7 +158,7 @@ async def test_server_shutdown_retires_real_radio_subscription_after_poller_stop cancelled_close, monkeypatch ): from rigplane.radio import IcomRadio - from _scope_simulator import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio sim = MockIcomRadio(scope_stream=True) await sim.start() @@ -206,9 +206,12 @@ def record_command(command, **kwargs): poller.stop() await asyncio.gather(poll_task, return_exceptions=True) if cancelled_close: + source = server.scope_hub._sources["radio"] + await source.lock.acquire() closing = asyncio.create_task(handler._unsubscribe()) - await await_until(lambda: server.command_queue.has_commands) + await await_until(lambda: not handler._streams) closing.cancel() + source.lock.release() restore = AsyncMock(wraps=poller.restore_scope_after_shutdown) monkeypatch.setattr(poller, "restore_scope_after_shutdown", restore) stopping = asyncio.create_task(server.stop()) @@ -218,6 +221,7 @@ def record_command(command, **kwargs): ) await stopping if closing is not None: + await await_until(closing.done, timeout=1) assert closing.done(), ( "cancelled unsubscribe retained its shielded queue wait" ) @@ -227,9 +231,8 @@ def record_command(command, **kwargs): assert server._server is None assert not server._bg_tasks assert not server.command_queue.has_commands - assert not server._scope_command_futures assert not server._radio_scope_source._active - restore.assert_awaited_once() + assert not radio.scope_session().active assert not server._radio_scope_session.enabled finally: # Recover the old implementation's blocked queue wait after the RED assertion. @@ -253,18 +256,17 @@ async def test_shutdown_cancels_pending_replacement_view_at_each_command_stage( ): from unittest.mock import MagicMock - from rigplane.web.radio_poller import ( - PttOff, - SetScopeMode, - SetScopeSpan, - SwitchScopeReceiver, - ) + from rigplane.web.radio_poller import PttOff + from rigplane.runtime.scope_session import ScopeState + from rigplane.scope.models import FollowView + from rigplane.profiles import resolve_radio_profile + from _scope_session_fake import attach_scope_session - server = WebServer(_scope_radio(), WebConfig(radio_model="IC-7610")) - server._radio_state.main.freq = 14_100_000 - assert server.scope_hub is not None - source = server._radio_scope_source - assert source is not None + radio = _scope_radio() + radio.profile = resolve_radio_profile(model="IC-7610") + session = attach_scope_session(radio, initial=ScopeState(False, False, 0, 0, 2)) + server = WebServer(radio, WebConfig(radio_model="IC-7610")) + source = server.scope_hub._sources["radio"].plugin listener = MagicMock() listener.wait_closed = AsyncMock() server._server = listener @@ -272,20 +274,26 @@ async def test_shutdown_cancels_pending_replacement_view_at_each_command_stage( futures = [] replacement_commands = [] replacing = False - put_ordered = server.command_queue.put_ordered + restoring = False - def enqueue(command, **kwargs): - put_ordered(command, **kwargs) - future = kwargs.get("future") - if future is None: - return - futures.append(future) - if replacing: - replacement_commands.append(command) - if not replacing or len(replacement_commands) <= pending_stage: - future.set_result(None) + async def step(name): + if replacing and not restoring: + replacement_commands.append(name) + future = asyncio.get_running_loop().create_future() + futures.append(future) + if len(replacement_commands) <= pending_stage: + future.set_result(None) + await future - monkeypatch.setattr(server.command_queue, "put_ordered", enqueue) + async def fixed(**kwargs): + await step("fixed_edge") + async def edge(value): + await step("edge") + async def mode(value): + await step("mode") + radio.set_scope_fixed_edge.side_effect = fixed + radio.set_scope_edge.side_effect = edge + radio.set_scope_mode.side_effect = mode poller = MagicMock() poller.stop.side_effect = lambda: events.append("poller-stopped") @@ -296,7 +304,11 @@ async def drain(*, timeout): events.append("tx-drain") async def restore(): + nonlocal restoring assert events[-1] == "tx-drain" + restoring = True + await session.set_inputs(ready=True, tx=False) + assert not session.restoring events.append("scope-restored") poller.drain_tx_safety_commands = AsyncMock(side_effect=drain) @@ -305,13 +317,14 @@ async def restore(): handler = ScopeHandler(Socket(), server) server.register_scope_handler(handler) request = encode_scope_subscription( - Subscription("radio", 0, View(14_090_000, 14_110_000), 100, 10) + Subscription("radio", 0, FollowView("vfo", 25000), 100, 10) ) await handler._handle_control(request) assert handler._streams, handler._ws.text server.command_queue.drain_entries() poller.stop() replacing = True + request = encode_scope_subscription(Subscription("radio", 0, View(14_200_000, 14_300_000), 100, 10)) replacement = asyncio.create_task(handler._handle_control(request)) async def client(): @@ -328,11 +341,7 @@ async def client(): try: await virtual_clock.elapse(0) assert not replacement.done() - assert [type(command) for command in replacement_commands] == [ - SwitchScopeReceiver, - SetScopeMode, - SetScopeSpan, - ][: pending_stage + 1] + assert replacement_commands == ["fixed_edge", "edge", "mode"][: pending_stage + 1] pending = futures[-1] assert not pending.done() stopping = asyncio.create_task(server.stop()) @@ -344,13 +353,11 @@ async def client(): assert client_task.done() assert len(replacement_commands) == pending_stage + 1 assert all(future.done() for future in futures) - assert not server._scope_command_futures assert not server.command_queue.has_commands assert not handler._streams assert not source._active assert not source._callbacks assert not server._radio_scope_session.enabled - assert not server._scope_enable_failed assert server._server is None listener.close.assert_called_once() listener.wait_closed.assert_awaited_once() From 2d7aa63c25c35e93a0039b8ad104805215a7cde3 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 19:45:27 -0500 Subject: [PATCH 19/62] test(MOR-4274): distinguish stale out-of-range state from fresh arrival Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index f79dd9824..3b45ecf88 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -302,7 +302,9 @@ async def write(name, value, receiver): async def test_restore_only_writes_view_settings_the_session_changed(): writer = Writer() - session = ScopeSession(writer.read, writer.write, ready=True, spans=(2500, 5000, 10000, 25000)) + session = ScopeSession( + writer.read, writer.write, ready=True, spans=(2500, 5000, 10000, 25000) + ) await session.set_output(True) await session.set_center(14100000, 25000, 0) assert writer.views == [("span", 3, 0)] @@ -319,7 +321,9 @@ async def test_shared_fixed_slot_is_actuated_again_after_other_receiver_changes_ await session.set_fixed(first, 0) await session.set_fixed(second, 1) await session.set_fixed(first, 0) - assert [value for name, value, receiver in writer.views if name == "fixed_edge"] == [first, second, first] + assert [ + value for name, value, receiver in writer.views if name == "fixed_edge" + ] == [first, second, first] async def test_last_release_before_readiness_discards_the_pending_view(): @@ -342,3 +346,13 @@ async def test_restored_output_without_subscribers_has_no_menu_verdict(virtual_c virtual_clock.advance(60) assert session.observed.output_on is True assert session.health(civ_flowing=True).hidden is None + + +async def test_missing_frames_after_oor_use_the_arrival_deadline(virtual_clock): + writer = Writer() + session = ScopeSession(writer.read, writer.write, ready=True, clock=virtual_clock, silence_seconds=1) + await session.set_output(True) + session.on_frame(ScopeFrame(0, 1, 14000000, 14350000, b"", True)) + assert session.health(civ_flowing=True).hidden is False + virtual_clock.advance(2) + assert session.health(civ_flowing=True).hidden is True From fba69ad58897fc712777788bbde6413564b8ce55 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:02:35 -0500 Subject: [PATCH 20/62] test(MOR-4274): prevent health snapshots delaying first resume Co-Authored-By: Claude Opus 5.5 --- tests/test_web_server_coverage.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index 03c7878d2..ac3f6edd3 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -5488,3 +5488,26 @@ async def test_state_200_when_etag_differs(self) -> None: assert "200" in status_line, f"Expected 200, got: {status_line}" body_start = text.index("\r\n\r\n") + 4 assert len(text[body_start:]) > 0, "200 response must have a body" + + +@pytest.mark.parametrize("signal", ["tx", "readiness"]) +async def test_health_snapshot_cannot_delay_fresh_scope_resume(signal, virtual_clock, monkeypatch): + srv, radio, _ = await _virtual_scope_server(monkeypatch, virtual_clock) + session = srv._radio_scope_session + transmitting = signal == "tx" + radio.radio_ready = signal != "readiness" + monkeypatch.setattr(srv, "_scope_tx_known", lambda: transmitting) + session.tx_probe = lambda: transmitting + await session.set_output(False, release=False) + monitor = asyncio.create_task(srv._scope_health_monitor()) + try: + await _elapse_scope_windows(virtual_clock, 1) + finally: + monitor.cancel() + await asyncio.gather(monitor, return_exceptions=True) + transmitting = False + radio.radio_ready = True + started = virtual_clock() + await session.set_output(True) + assert session.enabled + assert virtual_clock() - started <= 0.1 From 1af93a7e9f4316ccf7ec3296b5c9878653dffbc3 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:15:06 -0500 Subject: [PATCH 21/62] test(MOR-4274): preserve actual view observations after restoration Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 23 +++++++++++++++++++++- tests/runtime/test_scope_session_wire.py | 25 +++++++++++++++++++----- tests/scope/test_audio_scope_source.py | 1 + tests/scope/test_radio_scope_source.py | 5 ++++- tests/test_web_managed_ptt_rejection.py | 1 + tests/test_web_server_coverage.py | 5 +++-- tests/web/test_scope_hub_channel.py | 18 ++++++++++++++--- tests/web/test_scope_server_lifecycle.py | 15 +++++++++++--- 8 files changed, 78 insertions(+), 15 deletions(-) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 3b45ecf88..9606d07f7 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -350,9 +350,30 @@ async def test_restored_output_without_subscribers_has_no_menu_verdict(virtual_c async def test_missing_frames_after_oor_use_the_arrival_deadline(virtual_clock): writer = Writer() - session = ScopeSession(writer.read, writer.write, ready=True, clock=virtual_clock, silence_seconds=1) + session = ScopeSession( + writer.read, writer.write, ready=True, clock=virtual_clock, silence_seconds=1 + ) await session.set_output(True) session.on_frame(ScopeFrame(0, 1, 14000000, 14350000, b"", True)) assert session.health(civ_flowing=True).hidden is False virtual_clock.advance(2) assert session.health(civ_flowing=True).hidden is True + + +async def test_restore_keeps_the_actual_view_observation_instead_of_cached_defaults(): + writer = Writer() + writer.state = ScopeState(False, False, 3, 2, 1) + + async def read_flags(): + return ScopeState(False, False, 0, 2, 1) + + async def read_receiver(receiver): + return writer.state + + session = ScopeSession(read_flags, writer.write, read_receiver=read_receiver, ready=True) + await session.set_output(True) + await session.configure(mode=1) + await session.set_output(False) + assert session.observed.mode == writer.state.mode == 3 + await session.configure(mode=0) + assert writer.state.mode == 0 diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 38d091a29..6dd1959d5 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -125,13 +125,18 @@ async def expect(payload, **kwargs): @pytest.mark.parametrize("transport", ["lan", "serial"]) @pytest.mark.parametrize("initial", [(False, False), (True, False), (True, True)]) -async def test_last_release_restores_wire_snapshot_once(transport, initial, monkeypatch): +async def test_last_release_restores_wire_snapshot_once( + transport, initial, monkeypatch +): from rigplane.radio import IcomRadio from rigplane.backends.ic7300 import Ic7300SerialRadio from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR - radio = (IcomRadio("127.0.0.1", model="IC-7300") if transport == "lan" - else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200)) + radio = ( + IcomRadio("127.0.0.1", model="IC-7300") + if transport == "lan" + else Ic7300SerialRadio(device="/dev/fake-scope", baudrate=115200) + ) radio._connected = True observed = {0x10: initial[0], 0x11: initial[1]} writes = [] @@ -140,11 +145,21 @@ async def send(payload, **kwargs): frame = parse_civ_frame(payload) observed[frame.sub] = bool(frame.data[0]) writes.append((frame.sub, frame.data)) - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB)) + return parse_civ_frame( + build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0xFB) + ) async def expect(payload, **kwargs): frame = parse_civ_frame(payload) - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, frame.command, sub=frame.sub, data=bytes([observed[frame.sub]]))) + return parse_civ_frame( + build_civ_frame( + CONTROLLER_ADDR, + radio._radio_addr, + frame.command, + sub=frame.sub, + data=bytes([observed[frame.sub]]), + ) + ) monkeypatch.setattr(radio, "_send_civ_raw", send) monkeypatch.setattr(radio, "_send_civ_expect", expect) diff --git a/tests/scope/test_audio_scope_source.py b/tests/scope/test_audio_scope_source.py index 4cf7c3bea..d71e8f1e9 100644 --- a/tests/scope/test_audio_scope_source.py +++ b/tests/scope/test_audio_scope_source.py @@ -275,6 +275,7 @@ async def test_unavailable_facts_drop_pcm_and_recovery_cannot_mix_history( from unittest.mock import Mock import logging import rigplane.audio.fft_scope as fft_module + caplog.set_level(logging.WARNING) monkeypatch.setattr(fft_module, "time", SimpleNamespace(monotonic=virtual_clock)) diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index 9e76d855a..14c3621e8 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -222,4 +222,7 @@ async def set_fixed(self, edges, receiver): source = RadioScopeSource(load_rig(Path("rigs/ic7610.toml")).to_profile(), writer) await source.set_view(View(14_050_000, 14_150_000), 0) await source.set_view(View(14_150_000, 14_250_000), 1) - assert [(value.edge, receiver) for value, receiver in writer.writes] == [(1, 0), (2, 1)] + assert [(value.edge, receiver) for value, receiver in writer.writes] == [ + (1, 0), + (2, 1), + ] diff --git a/tests/test_web_managed_ptt_rejection.py b/tests/test_web_managed_ptt_rejection.py index 1f00d89ca..48e1917ed 100644 --- a/tests/test_web_managed_ptt_rejection.py +++ b/tests/test_web_managed_ptt_rejection.py @@ -67,6 +67,7 @@ def _radio() -> MagicMock: radio.set_ptt = AsyncMock() radio._civ_epoch = 1 from _scope_session_fake import attach_scope_session + attach_scope_session(radio) return radio diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index ac3f6edd3..8fe2e9ce2 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -3648,7 +3648,7 @@ async def test_scope_health_monitor_link_down_then_menu_silence( try: await _elapse_scope_windows(virtual_clock, 2) # Link down: the frame clock is reset inside the monitor window. - assert srv._radio_scope_session.health(civ_flowing=True).hidden is None # noqa: SLF001 + assert srv._radio_scope_session.health(civ_flowing=True, link_ready=radio.radio_ready).hidden is None # noqa: SLF001 radio.connected = True radio.radio_ready = True await _elapse_scope_windows( @@ -5494,11 +5494,12 @@ async def test_state_200_when_etag_differs(self) -> None: async def test_health_snapshot_cannot_delay_fresh_scope_resume(signal, virtual_clock, monkeypatch): srv, radio, _ = await _virtual_scope_server(monkeypatch, virtual_clock) session = srv._radio_scope_session - transmitting = signal == "tx" + transmitting = False radio.radio_ready = signal != "readiness" monkeypatch.setattr(srv, "_scope_tx_known", lambda: transmitting) session.tx_probe = lambda: transmitting await session.set_output(False, release=False) + transmitting = signal == "tx" monitor = asyncio.create_task(srv._scope_health_monitor()) try: await _elapse_scope_windows(virtual_clock, 1) diff --git a/tests/web/test_scope_hub_channel.py b/tests/web/test_scope_hub_channel.py index fa87d6e16..6d8e3fc9d 100644 --- a/tests/web/test_scope_hub_channel.py +++ b/tests/web/test_scope_hub_channel.py @@ -117,7 +117,9 @@ async def test_two_clients_resample_and_pace_simulator_frames( ): from rigplane.scope import ScopeAssembler from rigplane.web.protocol import decode_scope_frame_v2, encode_scope_frame_v2 - from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ( + ScopeControlSimulator as MockIcomRadio, + ) server = WebServer(None) writer = Writer() @@ -179,7 +181,9 @@ async def test_real_websockets_negotiate_independent_views_and_audio_selection() from rigplane.scope import ScopeAssembler from rigplane.web.protocol import decode_scope_frame_v2 from rigplane.web.server import WebConfig - from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ( + ScopeControlSimulator as MockIcomRadio, + ) from test_web_server import _addr server = WebServer(None, WebConfig(host="127.0.0.1", port=0)) @@ -586,8 +590,11 @@ async def test_server_drains_scope_before_stopping_command_executor(monkeypatch) ) ) + real_stop = web_startup.stop_web_server + async def stop_executor(received): assert received is server + await real_stop(received) assert writer.output == [True, False] assert not handler._streams @@ -719,8 +726,11 @@ async def set_output(self, enabled, *, release=True): handlers.append(handler) failed_subscription = next(iter(handlers[0]._streams.values())).subscription + real_stop = web_startup.stop_web_server + async def stop_executor(received): assert received is server + await real_stop(received) assert healthy.output == [True, False] assert all(not handler._streams for handler in handlers) @@ -879,7 +889,9 @@ async def test_one_real_scope_socket_multiplexes_radio_and_audio(): from rigplane.web.protocol import decode_scope_frame_v2 from rigplane.web.scope_sources import WebScopePcmTap from rigplane.web.server import WebConfig - from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ( + ScopeControlSimulator as MockIcomRadio, + ) from test_handlers_coverage import _RelayRadio from test_audio_scope_dispatch import _seed_audio_facts from test_web_server import _addr diff --git a/tests/web/test_scope_server_lifecycle.py b/tests/web/test_scope_server_lifecycle.py index b3b7d712e..cbe67533b 100644 --- a/tests/web/test_scope_server_lifecycle.py +++ b/tests/web/test_scope_server_lifecycle.py @@ -158,7 +158,9 @@ async def test_server_shutdown_retires_real_radio_subscription_after_poller_stop cancelled_close, monkeypatch ): from rigplane.radio import IcomRadio - from rigplane_testkit.icom.scope_control import ScopeControlSimulator as MockIcomRadio + from rigplane_testkit.icom.scope_control import ( + ScopeControlSimulator as MockIcomRadio, + ) sim = MockIcomRadio(scope_stream=True) await sim.start() @@ -287,10 +289,13 @@ async def step(name): async def fixed(**kwargs): await step("fixed_edge") + async def edge(value): await step("edge") + async def mode(value): await step("mode") + radio.set_scope_fixed_edge.side_effect = fixed radio.set_scope_edge.side_effect = edge radio.set_scope_mode.side_effect = mode @@ -324,7 +329,9 @@ async def restore(): server.command_queue.drain_entries() poller.stop() replacing = True - request = encode_scope_subscription(Subscription("radio", 0, View(14_200_000, 14_300_000), 100, 10)) + request = encode_scope_subscription( + Subscription("radio", 0, View(14_200_000, 14_300_000), 100, 10) + ) replacement = asyncio.create_task(handler._handle_control(request)) async def client(): @@ -341,7 +348,9 @@ async def client(): try: await virtual_clock.elapse(0) assert not replacement.done() - assert replacement_commands == ["fixed_edge", "edge", "mode"][: pending_stage + 1] + assert ( + replacement_commands == ["fixed_edge", "edge", "mode"][: pending_stage + 1] + ) pending = futures[-1] assert not pending.done() stopping = asyncio.create_task(server.stop()) From 85ab765ffa20567b8f4d9e3e9c211a46647b4b38 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:18:14 -0500 Subject: [PATCH 22/62] test(MOR-4274): skip unchanged fixed-slot programming and restore Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index 9606d07f7..fa6b7127e 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -377,3 +377,14 @@ async def read_receiver(receiver): assert session.observed.mode == writer.state.mode == 3 await session.configure(mode=0) assert writer.state.mode == 0 + + +async def test_matching_fixed_slot_is_not_programmed_or_restored_again(): + writer = Writer() + writer.state = ScopeState(False, False, 1, 2, 1) + session = ScopeSession(writer.read, writer.write, writer.read_fixed, ready=True) + await session.set_output(True) + await session.set_fixed(ScopeFixedEdge(6, 1, 14000000, 14350000), 0) + assert writer.views == [] + await session.set_output(False) + assert writer.views == [] From fb990d87ac909227c5ff1df52976186184c3e5ea Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:22:33 -0500 Subject: [PATCH 23/62] test(MOR-4274): keep receiver snapshots from retargeting shared controls Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_wire.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 6dd1959d5..6bdd72447 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -184,3 +184,25 @@ async def test_known_tx_defers_sdk_scope_wire_writes(monkeypatch): monkeypatch.setattr(radio, "_send_civ_raw", send) await radio.enable_scope(policy="fast") send.assert_not_awaited() + + +async def test_view_capture_does_not_retarget_the_shared_scope_receiver(monkeypatch): + from rigplane.radio import IcomRadio + from rigplane.commands import parse_civ_frame, build_civ_frame, CONTROLLER_ADDR + + radio = IcomRadio("127.0.0.1", model="IC-7610") + controls = radio._scope_controls() + controls.receiver, controls.mode, controls.span, controls.edge = 0, 0, 2, 1 + monkeypatch.setattr(radio, "_check_connected", lambda: None) + + async def expect(payload, **kwargs): + assert controls.receiver == 0 + request = parse_civ_frame(payload) + assert request.data == b"\x01" + value = 1 if request.sub == 0x16 else 0 + return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, request.command, sub=request.sub, data=bytes([1, value]))) + + monkeypatch.setattr(radio, "_send_civ_expect", expect) + captured = await radio._read_scope_receiver(1) + assert (captured.mode, captured.span, captured.edge) == (0, 0, 1) + assert (controls.receiver, controls.mode, controls.span, controls.edge) == (0, 0, 2, 1) From 60610be5bcd3e003589aac2343dc7fbc4eaede11 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:28:49 -0500 Subject: [PATCH 24/62] test(MOR-4274): format reconciled regression coverage Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 4 +++- tests/runtime/test_scope_session_wire.py | 17 +++++++++++++++-- tests/test_web_server_coverage.py | 11 +++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index fa6b7127e..b1564899b 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -370,7 +370,9 @@ async def read_flags(): async def read_receiver(receiver): return writer.state - session = ScopeSession(read_flags, writer.write, read_receiver=read_receiver, ready=True) + session = ScopeSession( + read_flags, writer.write, read_receiver=read_receiver, ready=True + ) await session.set_output(True) await session.configure(mode=1) await session.set_output(False) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 6bdd72447..56389479c 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -200,9 +200,22 @@ async def expect(payload, **kwargs): request = parse_civ_frame(payload) assert request.data == b"\x01" value = 1 if request.sub == 0x16 else 0 - return parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, request.command, sub=request.sub, data=bytes([1, value]))) + return parse_civ_frame( + build_civ_frame( + CONTROLLER_ADDR, + radio._radio_addr, + request.command, + sub=request.sub, + data=bytes([1, value]), + ) + ) monkeypatch.setattr(radio, "_send_civ_expect", expect) captured = await radio._read_scope_receiver(1) assert (captured.mode, captured.span, captured.edge) == (0, 0, 1) - assert (controls.receiver, controls.mode, controls.span, controls.edge) == (0, 0, 2, 1) + assert (controls.receiver, controls.mode, controls.span, controls.edge) == ( + 0, + 0, + 2, + 1, + ) diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index 8fe2e9ce2..704521c9c 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -3648,7 +3648,12 @@ async def test_scope_health_monitor_link_down_then_menu_silence( try: await _elapse_scope_windows(virtual_clock, 2) # Link down: the frame clock is reset inside the monitor window. - assert srv._radio_scope_session.health(civ_flowing=True, link_ready=radio.radio_ready).hidden is None # noqa: SLF001 + assert ( + srv._radio_scope_session.health( + civ_flowing=True, link_ready=radio.radio_ready + ).hidden + is None + ) # noqa: SLF001 radio.connected = True radio.radio_ready = True await _elapse_scope_windows( @@ -5491,7 +5496,9 @@ async def test_state_200_when_etag_differs(self) -> None: @pytest.mark.parametrize("signal", ["tx", "readiness"]) -async def test_health_snapshot_cannot_delay_fresh_scope_resume(signal, virtual_clock, monkeypatch): +async def test_health_snapshot_cannot_delay_fresh_scope_resume( + signal, virtual_clock, monkeypatch +): srv, radio, _ = await _virtual_scope_server(monkeypatch, virtual_clock) session = srv._radio_scope_session transmitting = False From 746f03e9847af71a20090b16860a428a419ddd57 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:38:04 -0500 Subject: [PATCH 25/62] feat(MOR-4274): unify acknowledged scope demand and view control Replace SDK and web lifecycle writers with one ScopeSession. Negotiate fixed edges from profile data, pause output without releasing views, and restore the exact pre-session state after the final owner leaves. Co-Authored-By: Claude Opus 5.5 --- docs/api/scope.md | 22 ++ qa/bench/dogfood/scope_fps.py | 4 +- rigplane_testkit/icom/scope_control.py | 22 +- rigs/ic7610.toml | 18 + src/rigplane/audio/scope_source.py | 2 +- src/rigplane/backends/_icom_serial_base.py | 14 - src/rigplane/commands/scope.py | 51 +-- src/rigplane/core/types.py | 14 +- src/rigplane/profiles/__init__.py | 5 + src/rigplane/profiles/rig_loader.py | 48 +++ src/rigplane/runtime/_civ_rx.py | 2 +- src/rigplane/runtime/_poller_types.py | 17 +- src/rigplane/runtime/_scope_runtime.py | 306 ++++++++------- src/rigplane/runtime/callable_support.py | 2 +- src/rigplane/runtime/radio_scope_source.py | 57 ++- src/rigplane/runtime/scope_session.py | 427 +++++++++++++++++++++ src/rigplane/scope/hub.py | 48 ++- src/rigplane/scope/models.py | 12 +- src/rigplane/sdr/scope_source.py | 2 +- src/rigplane/web/radio_poller.py | 133 ++----- src/rigplane/web/scope_sources.py | 24 -- src/rigplane/web/server.py | 390 ++----------------- src/rigplane/web/web_startup.py | 24 +- 23 files changed, 907 insertions(+), 737 deletions(-) create mode 100644 src/rigplane/runtime/scope_session.py diff --git a/docs/api/scope.md b/docs/api/scope.md index 22da891ef..923728ed1 100644 --- a/docs/api/scope.md +++ b/docs/api/scope.md @@ -280,3 +280,25 @@ Combined image: spectrum on top, waterfall below. **`grayscale`** — black (noise) → white (max) Custom themes can be added to `scope_render.THEMES` dict with anchor points for linear interpolation. + +## SDK lifecycle + +`src/rigplane/runtime/_scope_runtime.py: ScopeRuntimeMixin.enable_scope` and the +web radio source share `src/rigplane/runtime/scope_session.py: ScopeSession`. +Scope writes now await a CI-V acknowledgement for every completion policy: +`fast` and `strict` do not wait for frames; `verify` additionally waits for +scope activity. This changes the former fire-and-forget timing of `fast`. +`disable_scope` releases SDK demand; the last SDK or web release restores the +captured settings. The wire lifecycle is covered by +`tests/runtime/test_scope_session_wire.py: test_last_release_restores_wire_snapshot_once`. + +Implicit FIXED range selection in +`src/rigplane/commands/scope.py: scope_set_fixed_edge` requires profile `ranges`; +callers can instead provide an explicit `range_index`. Negotiated FIXED views +use only the profiles that declare ranges and their frequency step. The shipped +IC-7610 data cites its CI-V reference in `rigs/ic7610.toml`; profiles without +that data negotiate CENTER views. + +Hardware scope providers used by the web server must expose +`src/rigplane/runtime/scope_session.py: ScopeSessionProvider.scope_session`; +this replaces the web-owned writer adapter. diff --git a/qa/bench/dogfood/scope_fps.py b/qa/bench/dogfood/scope_fps.py index 13874167d..09b0c1175 100644 --- a/qa/bench/dogfood/scope_fps.py +++ b/qa/bench/dogfood/scope_fps.py @@ -78,7 +78,9 @@ async def simulator() -> AsyncIterator[tuple[str, float]]: previous_gaps = testkit._SCOPE_GAP_S testkit._SCOPE_GAP_S = {speed: 1 / 30 for speed in previous_gaps} - fake = testkit.MockIcomRadio(scope_stream=True, enable_audio=True) + from rigplane_testkit.icom.scope_control import ScopeControlSimulator + + fake = ScopeControlSimulator(scope_stream=True, enable_audio=True) receiver, templates = next(iter(fake._scope_templates.items())) wave = parse_civ_frame(templates[0]) native = ScopeAssembler().feed(wave.data[1:], receiver) diff --git a/rigplane_testkit/icom/scope_control.py b/rigplane_testkit/icom/scope_control.py index cf49e764f..3c77641cd 100644 --- a/rigplane_testkit/icom/scope_control.py +++ b/rigplane_testkit/icom/scope_control.py @@ -9,20 +9,20 @@ ) from rigplane.core.types import bcd_encode from rigplane.core.types import ScopeFixedEdge -from rigplane.profiles.rig_loader import load_rig +from rigplane.profiles import resolve_radio_profile from rigplane_testkit.icom import MockIcomRadio -from pathlib import Path +from typing import Any class ScopeControlSimulator(MockIcomRadio): - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._scope_10 = 0 - self.profile = load_rig(Path("rigs/ic7610.toml")).to_profile() - self.views = {r: (0, 3, 1) for r in (0, 1)} - self.fixed = {} + self.profile = resolve_radio_profile(model="IC-7610") + self.views: dict[int, tuple[int, int, int]] = {r: (0, 3, 1) for r in (0, 1)} + self.fixed: dict[bytes, ScopeFixedEdge] = {} - def _dispatch_scope(self, payload, from_addr): + def _dispatch_scope(self, payload: bytes, from_addr: int) -> bytes | None: sub, rest = payload[0], payload[1:] if sub == 0x10 and rest == b"\x00": self._scope_10 = 0 @@ -60,11 +60,15 @@ def _dispatch_scope(self, payload, from_addr): if sub == 0x1E and len(rest) >= 2: key = rest[:2] if len(rest) == 2: - value = self.fixed.get( + fixed_value = self.fixed.get( key, ScopeFixedEdge(int(f"{key[0]:02x}"), key[1], 14000000, 14350000), ) - data = key + bcd_encode(value.start_hz) + bcd_encode(value.end_hz) + data = ( + key + + bcd_encode(fixed_value.start_hz) + + bcd_encode(fixed_value.end_hz) + ) return self._civ_frame( from_addr, self._radio_addr, 0x27, data=b"\x1e" + data ) diff --git a/rigs/ic7610.toml b/rigs/ic7610.toml index 2c0af059b..6a55856c4 100644 --- a/rigs/ic7610.toml +++ b/rigs/ic7610.toml @@ -51,6 +51,24 @@ ref_min_db = -30.0 ref_max_db = 10.0 ref_step_db = 0.5 span_presets_hz = [2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000] +# IC-7610 CI-V Reference Guide, printed p15, 27 1E fixed-edge table. +# https://www.icomjapan.com/uploads/support/manual/IC-7610_ENG_CI-V_4.pdf +# The radio ignores digits below 1 kHz. +fixed_edge_step_hz = 1000 +fixed_edge_ranges = [ + { range_index = 1, start_hz = 30000, end_hz = 1600000 }, + { range_index = 2, start_hz = 1600000, end_hz = 2000000 }, + { range_index = 3, start_hz = 2000000, end_hz = 6000000 }, + { range_index = 4, start_hz = 6000000, end_hz = 8000000 }, + { range_index = 5, start_hz = 8000000, end_hz = 11000000 }, + { range_index = 6, start_hz = 11000000, end_hz = 15000000 }, + { range_index = 7, start_hz = 15000000, end_hz = 20000000 }, + { range_index = 8, start_hz = 20000000, end_hz = 22000000 }, + { range_index = 9, start_hz = 22000000, end_hz = 26000000 }, + { range_index = 10, start_hz = 26000000, end_hz = 30000000 }, + { range_index = 11, start_hz = 30000000, end_hz = 45000000 }, + { range_index = 12, start_hz = 45000000, end_hz = 60000000 }, +] # ── Capabilities ───────────────────────────────────────────────── # Feature tags determine which UI panels and controls are rendered. diff --git a/src/rigplane/audio/scope_source.py b/src/rigplane/audio/scope_source.py index 9fda8965a..691c9ccde 100644 --- a/src/rigplane/audio/scope_source.py +++ b/src/rigplane/audio/scope_source.py @@ -130,7 +130,7 @@ async def start(self) -> None: await self.stop() raise - async def stop(self) -> None: + async def stop(self, *, release: bool = True) -> None: if not self._active: return self._active = False diff --git a/src/rigplane/backends/_icom_serial_base.py b/src/rigplane/backends/_icom_serial_base.py index 25143d54b..94b0f4e9e 100644 --- a/src/rigplane/backends/_icom_serial_base.py +++ b/src/rigplane/backends/_icom_serial_base.py @@ -20,7 +20,6 @@ from .._connection_state import RadioConnectionState from ..audio import AudioPacket from ..audio.lan_stream import SYNTHETIC_RX_IDENT -from ..commands import parse_ack_nak from ..core.radio_protocol import RadioIdentity, RadioIdentityStatus from ..core.serial_open import serial_open_diagnostic from ..exceptions import ( @@ -1632,19 +1631,6 @@ async def enable_scope( self._ensure_scope_baud_guardrail() await super().enable_scope(output=output, policy=policy, timeout=timeout) - async def disable_scope( - self, *, policy: ScopeCompletionPolicy | str = ScopeCompletionPolicy.FAST - ) -> None: - await super().disable_scope(policy=policy) - pol = ScopeCompletionPolicy(policy) - wait_resp = pol == ScopeCompletionPolicy.STRICT - resp = await self._send_civ_raw( - self._commands.scope_off(to_addr=self._radio_addr), - wait_response=wait_resp, - ) - if wait_resp and resp is not None and parse_ack_nak(resp) is False: - raise CommandError("Radio rejected scope disable") - # ------------------------------------------------------------------ # Neutral AudioTransport surface (MOR-532 epic, MOR-540) # ------------------------------------------------------------------ diff --git a/src/rigplane/commands/scope.py b/src/rigplane/commands/scope.py index 326076f61..e95a6002b 100644 --- a/src/rigplane/commands/scope.py +++ b/src/rigplane/commands/scope.py @@ -46,6 +46,8 @@ from __future__ import annotations +from rigplane.core.types import ScopeFixedEdgeRange + from typing import TYPE_CHECKING from ..types import ScopeFixedEdge, bcd_decode, bcd_encode @@ -78,24 +80,6 @@ from ..types import CivFrame -_SCOPE_FIXED_EDGE_RANGE_STARTS_HZ: tuple[int, ...] = ( - 50_000_000, - 28_000_000, - 24_890_000, - 21_000_000, - 18_068_000, - 14_000_000, - 10_100_000, - 7_000_000, - 5_250_000, - 3_500_000, - 1_800_000, - 472_000, - 135_000, - 10_000, -) - - # Scope sub-commands whose READ query carries a one-byte Main/Sub scope # selector between the sub-command byte and the frame terminator # (0x00 = MAIN, 0x01 = SUB). Without it the IC-7610 silently ignores the @@ -271,17 +255,6 @@ def _decode_scope_bcd_value( return receiver, value -def _resolve_scope_fixed_edge_range(start_hz: int) -> int: - if start_hz < 0: - raise ValueError(f"scope fixed edge start_hz must be >= 0, got {start_hz}") - for index, band_start in enumerate(_SCOPE_FIXED_EDGE_RANGE_STARTS_HZ, start=1): - if start_hz >= band_start: - return index - raise ValueError( - f"scope fixed edge start_hz {start_hz} is outside known IC-7610 bands" - ) - - def _scope_ref_encode(ref: float, bounds: tuple[float, float] | None = None) -> bytes: """Encode scope reference level as 3-byte Icom BCD format. @@ -836,6 +809,7 @@ def scope_set_fixed_edge( start_hz: int, end_hz: int, to_addr: int, + ranges: tuple[ScopeFixedEdgeRange, ...] = (), range_index: int | None = None, from_addr: int = CONTROLLER_ADDR, cmd_map: CommandMap, @@ -847,11 +821,20 @@ def scope_set_fixed_edge( raise ValueError( f"scope fixed edge end_hz must be greater than start_hz, got {start_hz}..{end_hz}" ) - resolved_range = ( - _resolve_scope_fixed_edge_range(start_hz) - if range_index is None - else _validate_scope_range("scope fixed edge range", range_index, 1, 99) - ) + if range_index is None: + range_index = next( + ( + item.range_index + for item in ranges + if item.start_hz <= start_hz < end_hz <= item.end_hz + ), + None, + ) + if range_index is None: + raise ValueError( + "scope fixed edge requires profile ranges covering both edges" + ) + resolved_range = _validate_scope_range("scope fixed edge range", range_index, 1, 99) payload = ( bcd_encode_value(resolved_range, byte_count=1) + bcd_encode_value(edge, byte_count=1) diff --git a/src/rigplane/core/types.py b/src/rigplane/core/types.py index 477f251a2..4cc5a0021 100644 --- a/src/rigplane/core/types.py +++ b/src/rigplane/core/types.py @@ -22,6 +22,7 @@ "get_audio_capabilities", "ScopeCompletionPolicy", "ScopeFixedEdge", + "ScopeFixedEdgeRange", "PacketHeader", "CivFrame", "HEADER_SIZE", @@ -201,8 +202,17 @@ class ScopeCompletionPolicy(StrEnum): """Policies for awaiting completion of scope commands.""" STRICT = "strict" # Wait for a CI-V ACK response - FAST = "fast" # Fire-and-forget, do not wait for ACK - VERIFY = "verify" # Fire-and-forget, but await actual scope data activity + FAST = "fast" # ACK completion without waiting for scope activity + VERIFY = "verify" # ACK completion followed by scope activity + + +@dataclass(frozen=True, slots=True) +class ScopeFixedEdgeRange: + """Selectable fixed-edge frequency range.""" + + range_index: int + start_hz: int + end_hz: int @dataclass(frozen=True, slots=True) diff --git a/src/rigplane/profiles/__init__.py b/src/rigplane/profiles/__init__.py index fb1253a38..26c319c69 100644 --- a/src/rigplane/profiles/__init__.py +++ b/src/rigplane/profiles/__init__.py @@ -23,6 +23,7 @@ from rigplane.core.exceptions import CommandError from rigplane.core.state_acquisition_policy import RadioAcquisitionProfile from rigplane.core.types import ( + ScopeFixedEdgeRange, MemoryProfile, MemoryWriteLayout, ToneSquelchType, @@ -537,6 +538,10 @@ class RadioProfile: # which take it as a parameter because commands/ may not import # profiles/ (.importlinter). scope_span_presets_hz: tuple[int, ...] = () + scope_fixed_edge_ranges: tuple[ScopeFixedEdgeRange, ...] = field( + default=(), kw_only=True + ) + scope_fixed_edge_step_hz: int | None = field(default=None, kw_only=True) # Per-profile RX codec preference override (#797). When non-None, the first # entry is used as the initial ``audio_codec`` for radios created under this # profile (unless the caller passes an explicit non-default value). Values diff --git a/src/rigplane/profiles/rig_loader.py b/src/rigplane/profiles/rig_loader.py index 36a839ec5..994ea0dc4 100644 --- a/src/rigplane/profiles/rig_loader.py +++ b/src/rigplane/profiles/rig_loader.py @@ -31,6 +31,7 @@ ) from rigplane.core.state_pipeline_contracts import FieldPath from rigplane.core.types import ( + ScopeFixedEdgeRange, MEMORY_WRITE_CLEAR_FORMS, MemoryWriteField, MemoryWriteLayout, @@ -720,6 +721,10 @@ class RigConfig: scope_ref_max_db: float | None = None scope_ref_step_db: float | None = None scope_span_presets_hz: tuple[int, ...] = () + scope_fixed_edge_ranges: tuple[ScopeFixedEdgeRange, ...] = field( + default=(), kw_only=True + ) + scope_fixed_edge_step_hz: int | None = field(default=None, kw_only=True) codec_preference: tuple[str, ...] | None = None tx_codec: str | None = None default_sample_rate_hz: int | None = None @@ -972,6 +977,8 @@ def to_profile(self) -> RadioProfile: scope_ref_max_db=self.scope_ref_max_db, scope_ref_step_db=self.scope_ref_step_db, scope_span_presets_hz=self.scope_span_presets_hz, + scope_fixed_edge_ranges=self.scope_fixed_edge_ranges, + scope_fixed_edge_step_hz=self.scope_fixed_edge_step_hz, codec_preference=self.codec_preference, tx_codec=self.tx_codec, default_sample_rate_hz=self.default_sample_rate_hz, @@ -3048,7 +3055,46 @@ def load_rig(path: Path) -> RigConfig: scope_ref_max_db: float | None = None scope_ref_step_db: float | None = None scope_span_presets_hz: tuple[int, ...] = () + scope_fixed_edge_ranges: tuple[ScopeFixedEdgeRange, ...] = () + scope_fixed_edge_step_hz: int | None = None if scope_section: + entries = scope_section.get("fixed_edge_ranges", []) + step = scope_section.get("fixed_edge_step_hz") + if "fixed_edge_ranges" in scope_section or step is not None: + if ( + not isinstance(entries, list) + or not entries + or type(step) is not int + or step <= 0 + ): + raise RigLoadError( + f"{filename}: fixed_edge_ranges require a positive fixed_edge_step_hz" + ) + fixed_ranges: list[ScopeFixedEdgeRange] = [] + for entry in entries: + if ( + not isinstance(entry, dict) + or set(entry) != {"range_index", "start_hz", "end_hz"} + or any(type(v) is not int for v in entry.values()) + ): + raise RigLoadError(f"{filename}: invalid fixed_edge_ranges") + item = ScopeFixedEdgeRange(**entry) + if not ( + 1 <= item.range_index <= 99 and 0 <= item.start_hz < item.end_hz + ): + raise RigLoadError(f"{filename}: invalid fixed_edge_ranges limits") + if any( + other.range_index == item.range_index + or max(other.start_hz, item.start_hz) + < min(other.end_hz, item.end_hz) + for other in fixed_ranges + ): + raise RigLoadError( + f"{filename}: overlapping or duplicate fixed_edge_ranges" + ) + fixed_ranges.append(item) + scope_fixed_edge_ranges = tuple(fixed_ranges) + scope_fixed_edge_step_hz = step scope_ref_min_db = ( float(scope_section["ref_min_db"]) if "ref_min_db" in scope_section @@ -3706,6 +3752,8 @@ def load_rig(path: Path) -> RigConfig: scope_ref_max_db=scope_ref_max_db, scope_ref_step_db=scope_ref_step_db, scope_span_presets_hz=scope_span_presets_hz, + scope_fixed_edge_ranges=scope_fixed_edge_ranges, + scope_fixed_edge_step_hz=scope_fixed_edge_step_hz, codec_preference=codec_preference, tx_codec=tx_codec, default_sample_rate_hz=default_sample_rate_hz, diff --git a/src/rigplane/runtime/_civ_rx.py b/src/rigplane/runtime/_civ_rx.py index c8b1fce3f..3f3a5dd18 100644 --- a/src/rigplane/runtime/_civ_rx.py +++ b/src/rigplane/runtime/_civ_rx.py @@ -4333,7 +4333,7 @@ def _publish_scope_span_observation( Fixed mode (``scope_frame.mode != 0``) publishes nothing: unlike span, there is no declared table of legal (start_hz, end_hz) pairs to match a fixed-mode frame's edges against — - ``_SCOPE_FIXED_EDGE_RANGE_STARTS_HZ`` only maps a start_hz to a + profile ``scope_fixed_edge_ranges`` only maps edges to a legal *band*, not to a specific stored (range, edge) preset, and a preset's actual edges are radio-side user-configured memory, not enumerable data. Publishing here would mean guessing which preset diff --git a/src/rigplane/runtime/_poller_types.py b/src/rigplane/runtime/_poller_types.py index 584765343..f2d288243 100644 --- a/src/rigplane/runtime/_poller_types.py +++ b/src/rigplane/runtime/_poller_types.py @@ -522,12 +522,11 @@ class VfoEqualize: @dataclass(frozen=True, slots=True) class EnableScope: policy: str = "fast" - generation: int = 0 @dataclass(frozen=True, slots=True) class DisableScope: - generation: int = 0 + pass @dataclass(frozen=True, slots=True) @@ -1306,7 +1305,6 @@ class CommandQueue: def __init__(self) -> None: self._segments: list[_CommandQueueSegment] = [] self._notify: asyncio.Event = asyncio.Event() - self._scope_demand_generation = 0 # MOR-1013: ids of the control sessions currently connected. An entry # outlives its author, and the poller keys on behalf of a session it # cannot otherwise see (decision D1-a), so the queue that carries the @@ -1386,13 +1384,6 @@ def session_is_live(self, session_id: str) -> bool: """Whether ``session_id`` still has a connected control session.""" return self._live_sessions is None or session_id in self._live_sessions - def _record_scope_demand(self, cmd: Command) -> None: - if isinstance(cmd, (EnableScope, DisableScope)): - self._scope_demand_generation = max( - self._scope_demand_generation, - cmd.generation, - ) - def _coalesced_tail(self) -> _CommandQueueSegment: if self._segments and self._segments[-1].kind == "coalesced": return self._segments[-1] @@ -1413,7 +1404,6 @@ def put( connection_generation: object | None = None, ) -> None: controller_ticket = self._controller_ticket(cmd) - self._record_scope_demand(cmd) entry = CommandQueueEntry( cmd, command_id=command_id, @@ -1451,7 +1441,6 @@ def put_ordered( positive_tx_submission: asyncio.Task[Any] | None = None, ) -> CommandQueueEntry: controller_ticket = self._controller_ticket(cmd) - self._record_scope_demand(cmd) entry = CommandQueueEntry( cmd, future=future, @@ -1544,10 +1533,6 @@ def drain_entries(self) -> list["CommandQueueEntry"]: def has_commands(self) -> bool: return any(segment.has_commands for segment in self._segments) - @property - def latest_scope_demand_generation(self) -> int: - return self._scope_demand_generation - async def wait(self, timeout: float | None = None) -> None: try: await asyncio.wait_for(self._notify.wait(), timeout=timeout) diff --git a/src/rigplane/runtime/_scope_runtime.py b/src/rigplane/runtime/_scope_runtime.py index 873533431..72aaea7ee 100644 --- a/src/rigplane/runtime/_scope_runtime.py +++ b/src/rigplane/runtime/_scope_runtime.py @@ -6,10 +6,12 @@ from __future__ import annotations +from rigplane.scope.models import ScopeDemand + import asyncio import logging import warnings -from typing import TYPE_CHECKING, AsyncGenerator +from typing import TYPE_CHECKING, AsyncGenerator, Literal if TYPE_CHECKING: from typing import Callable @@ -35,7 +37,6 @@ get_scope_speed, get_scope_vbw, parse_ack_nak, - parse_civ_frame, parse_scope_center_type_response, parse_scope_data_output_enabled_response, parse_scope_during_tx_response, @@ -57,6 +58,12 @@ from rigplane.core.radio_state import ScopeControlsState from rigplane.scope import ScopeFrame from rigplane.core.types import ScopeCompletionPolicy, ScopeFixedEdge +from rigplane.runtime.scope_session import ( + ScopeSession, + ScopeState, + ScopeSetting, + ScopeValue, +) logger = logging.getLogger(__name__) @@ -116,6 +123,114 @@ async def scope_stream(self) -> AsyncGenerator[ScopeFrame, None]: finally: self._scope_stream_callbacks.remove(callback) + def scope_session(self) -> ScopeSession: + """Return the single scope-control owner for this radio.""" + session = getattr(self, "_control_scope_session", None) + if not isinstance(session, ScopeSession): + session = ScopeSession( + self._read_scope_session, + self._write_scope_setting, + lambda range_index, edge: self.get_scope_fixed_edge( + range_index=range_index, edge=edge + ), + self._read_scope_receiver, + lambda receiver: float( + self._radio_state.main.freq + if receiver == 0 + else self._radio_state.sub.freq + ), + self._profile.scope_span_presets_hz, + lambda: bool(self._radio_state.ptt), + ready=self._connected, + ) + self._control_scope_session = session + return session + + async def _read_scope_receiver(self, receiver: int) -> ScopeState: + _, mode = await self._read_scope_view_setting("mode", receiver) + _, span = await self._read_scope_view_setting("span", receiver) + _, edge = await self._read_scope_view_setting("edge", receiver) + return ScopeState(False, False, mode, span, edge) + + async def _read_scope_view_setting( + self, name: Literal["mode", "span", "edge"], receiver: int + ) -> tuple[int | None, int]: + """Read an explicit receiver without changing shared SDK selection/cache.""" + self._check_connected() + builder = getattr(self._commands, f"get_scope_{name}") + resp = await self._send_civ_expect( + builder(to_addr=self._radio_addr, receiver=receiver), + label=f"get_scope_{name}", + ) + if name == "span": + command, sub, _ = self._expect_shape(get_scope_span) + return parse_scope_span_response( + resp, self._profile.scope_span_presets_hz, command=command, sub=sub + ) + if name == "mode": + command, sub, _ = self._expect_shape(get_scope_mode) + return parse_scope_mode_response(resp, command=command, sub=sub) + command, sub, _ = self._expect_shape(get_scope_edge) + return parse_scope_edge_response(resp, command=command, sub=sub) + + async def _read_scope_session(self) -> ScopeState: + panel, output = await self.get_scope_session_state() + controls = self._scope_controls() + return ScopeState( + panel, output, controls.mode or 0, controls.span or 0, controls.edge or 1 + ) + + async def _write_scope_setting( + self, name: ScopeSetting, value: ScopeValue, receiver: int + ) -> None: + """The sole writer for scope lifecycle and viewport commands.""" + self._check_connected() + if self.external_cat_session_active: + raise CommandError( + "scope restore deferred while an external CAT session owns the wire" + ) + if name == "scope_on": + builder = self._commands.scope_on if value else self._commands.scope_off + frame = builder(to_addr=self._radio_addr) + elif name == "output_on": + frame = self._commands.scope_data_output( + bool(value), to_addr=self._radio_addr + ) + elif name == "fixed_edge": + assert isinstance(value, ScopeFixedEdge) + frame = self._commands.scope_set_fixed_edge( + edge=value.edge, + start_hz=value.start_hz, + end_hz=value.end_hz, + range_index=value.range_index, + to_addr=self._radio_addr, + ) + elif name == "span": + assert isinstance(value, int) + frame = self._commands.scope_set_span( + value, + presets=self._profile.scope_span_presets_hz, + receiver=receiver, + to_addr=self._radio_addr, + ) + else: + assert isinstance(value, int) + builder = ( + self._commands.scope_set_mode + if name == "mode" + else self._commands.scope_set_edge + ) + frame = builder(value, receiver=receiver, to_addr=self._radio_addr) + response = await self._send_civ_raw(frame, wait_response=True) + if response is None or parse_ack_nak(response) is not True: + raise CommandError(f"Radio did not acknowledge scope {name}") + controls = self._scope_controls() + if name == "fixed_edge": + assert isinstance(value, ScopeFixedEdge) + controls.fixed_edge = value + elif name not in ("scope_on", "output_on"): + setattr(controls, name, value) + async def enable_scope( self, *, @@ -123,40 +238,15 @@ async def enable_scope( policy: ScopeCompletionPolicy | str = ScopeCompletionPolicy.VERIFY, timeout: float = 5.0, ) -> None: - """Enable scope display and data output on the radio. - - Args: - output: Also enable wave data output (default True). - policy: Completion policy (strict, fast, verify). - timeout: Verification timeout in seconds. - - Raises: - CommandError: If the radio rejects the command (in strict mode). - TimeoutError: If verification times out (in verify mode). - """ + """Acquire SDK scope demand through the shared acknowledged session.""" self._check_connected() pol = ScopeCompletionPolicy(policy) - wait_resp = pol == ScopeCompletionPolicy.STRICT - - if pol == ScopeCompletionPolicy.VERIFY: + if not self.scope_session().enabled: self._scope_activity_event.clear() - - resp = await self._send_civ_raw( - self._commands.scope_on(to_addr=self._radio_addr), wait_response=wait_resp - ) - if wait_resp and resp is not None: - if parse_ack_nak(resp) is False: - raise CommandError("Radio rejected scope enable") - if output: - resp = await self._send_civ_raw( - self._commands.scope_data_output(True, to_addr=self._radio_addr), - wait_response=wait_resp, - ) - if wait_resp and resp is not None: - if parse_ack_nak(resp) is False: - raise CommandError("Radio rejected scope data output enable") - - if pol == ScopeCompletionPolicy.VERIFY: + await self.scope_session().set_demand(ScopeDemand(1, int(output)), owner="sdk") + if not output: + await self.scope_session().configure(scope_on=True) + if output and pol == ScopeCompletionPolicy.VERIFY: try: await asyncio.wait_for( self._scope_activity_event.wait(), timeout=timeout @@ -189,42 +279,18 @@ async def get_scope_session_state(self) -> tuple[bool, bool]: ) async def restore_scope_session_state(self, state: tuple[bool, bool]) -> None: - """Restore the exact panel/output state captured before a web session.""" - self._check_connected() - panel_enabled, output_enabled = state - await self._send_civ_raw( - (self._commands.scope_on if panel_enabled else self._commands.scope_off)( - to_addr=self._radio_addr - ), - wait_response=False, - ) - await self._send_civ_raw( - self._commands.scope_data_output(output_enabled, to_addr=self._radio_addr), - wait_response=False, - ) + """Apply an explicit SDK panel/output snapshot through the shared writer.""" + session = self.scope_session() + await session.set_demand(ScopeDemand(0, 0), owner="sdk") + if not session.active: + await session.configure(scope_on=state[0], output_on=state[1]) async def disable_scope( self, *, policy: ScopeCompletionPolicy | str = ScopeCompletionPolicy.FAST ) -> None: - """Disable scope data output on the radio. - - Args: - policy: Completion policy, usually fast. - - Raises: - CommandError: If the radio rejects the command (strict mode). - """ - self._check_connected() - pol = ScopeCompletionPolicy(policy) - wait_resp = pol == ScopeCompletionPolicy.STRICT - - resp = await self._send_civ_raw( - self._commands.scope_data_output(False, to_addr=self._radio_addr), - wait_response=wait_resp, - ) - if wait_resp and resp is not None: - if parse_ack_nak(resp) is False: - raise CommandError("Radio rejected scope data output disable") + """Release SDK demand and restore the pre-session scope settings.""" + ScopeCompletionPolicy(policy) + await self.scope_session().set_demand(ScopeDemand(0, 0), owner="sdk") async def get_scope_receiver(self) -> int: """Read the selected scope receiver (0=MAIN, 1=SUB).""" @@ -275,89 +341,48 @@ async def set_scope_dual(self, dual: bool) -> None: async def get_scope_mode(self) -> int: """Read the current scope mode (0=center, 1=fixed, 2=scroll-C, 3=scroll-F).""" - self._check_connected() - receiver = self._scope_controls().receiver - resp = await self._send_civ_expect( - self._commands.get_scope_mode(to_addr=self._radio_addr, receiver=receiver), - label="get_scope_mode", + rx_hint, mode = await self._read_scope_view_setting( + "mode", self._scope_controls().receiver ) - command, sub, _ = self._expect_shape(get_scope_mode) - rx_hint, mode = parse_scope_mode_response(resp, command=command, sub=sub) self._apply_scope_receiver_hint(rx_hint) self._scope_controls().mode = mode return mode async def set_scope_mode(self, mode: int) -> None: - """Set the scope mode (0=center, 1=fixed, 2=scroll-C, 3=scroll-F).""" - self._check_connected() - receiver = self._scope_controls().receiver - await self._send_civ_raw( - self._commands.scope_set_mode( - mode, to_addr=self._radio_addr, receiver=receiver - ), - wait_response=False, + """Set the scope mode through the shared session.""" + await self.scope_session().configure( + receiver=self._scope_controls().receiver, mode=mode ) - self._scope_controls().mode = mode async def get_scope_span(self) -> int: """Read the scope span preset index (0..7).""" - self._check_connected() - receiver = self._scope_controls().receiver - resp = await self._send_civ_expect( - self._commands.get_scope_span(to_addr=self._radio_addr, receiver=receiver), - label="get_scope_span", - ) - command, sub, _ = self._expect_shape(get_scope_span) - rx_hint, span = parse_scope_span_response( - resp, - self._profile.scope_span_presets_hz, - command=command, - sub=sub, + rx_hint, span = await self._read_scope_view_setting( + "span", self._scope_controls().receiver ) self._apply_scope_receiver_hint(rx_hint) self._scope_controls().span = span return span async def set_scope_span(self, span: int) -> None: - """Set the scope span preset index (0..7).""" - self._check_connected() - receiver = self._scope_controls().receiver - await self._send_civ_raw( - self._commands.scope_set_span( - span, - to_addr=self._radio_addr, - presets=self._profile.scope_span_presets_hz, - receiver=receiver, - ), - wait_response=False, + """Set the scope span through the shared session.""" + await self.scope_session().configure( + receiver=self._scope_controls().receiver, span=span ) - self._scope_controls().span = span async def get_scope_edge(self) -> int: """Read the fixed-edge selection (1..4).""" - self._check_connected() - receiver = self._scope_controls().receiver - resp = await self._send_civ_expect( - self._commands.get_scope_edge(to_addr=self._radio_addr, receiver=receiver), - label="get_scope_edge", + rx_hint, edge = await self._read_scope_view_setting( + "edge", self._scope_controls().receiver ) - command, sub, _ = self._expect_shape(get_scope_edge) - rx_hint, edge = parse_scope_edge_response(resp, command=command, sub=sub) self._apply_scope_receiver_hint(rx_hint) self._scope_controls().edge = edge return edge async def set_scope_edge(self, edge: int) -> None: - """Set the fixed-edge selection (1..4).""" - self._check_connected() - receiver = self._scope_controls().receiver - await self._send_civ_raw( - self._commands.scope_set_edge( - edge, to_addr=self._radio_addr, receiver=receiver - ), - wait_response=False, + """Set the fixed-edge slot through the shared session.""" + await self.scope_session().configure( + receiver=self._scope_controls().receiver, edge=edge ) - self._scope_controls().edge = edge async def get_scope_hold(self) -> bool: """Read whether scope hold is enabled.""" @@ -570,28 +595,25 @@ async def set_scope_fixed_edge( end_hz: int, range_index: int | None = None, ) -> None: - """Set the fixed-edge scope bounds.""" - self._check_connected() - civ = self._commands.scope_set_fixed_edge( - edge=edge, - start_hz=start_hz, - end_hz=end_hz, - range_index=range_index, - to_addr=self._radio_addr, - ) - await self._send_civ_raw( - civ, - wait_response=False, - ) - # Re-parse the frame we just built to recover the resolved range_index - # (computed inside commands/scope.py: scope_set_fixed_edge) without - # duplicating logic. - command, sub, _ = self._expect_shape(get_scope_fixed_edge) - fixed_edge = parse_scope_fixed_edge_response( - parse_civ_frame(civ), command=command, sub=sub + """Set a fixed-edge slot using the profile's documented limits.""" + if range_index is None: + limits = next( + ( + item + for item in self._profile.scope_fixed_edge_ranges + if item.start_hz <= start_hz < end_hz <= item.end_hz + ), + None, + ) + if limits is None: + raise ValueError("scope fixed edges are outside profile limits") + range_index = limits.range_index + step = self._profile.scope_fixed_edge_step_hz + if step is not None and (start_hz % step or end_hz % step): + raise ValueError("scope fixed edges must follow the profile frequency step") + await self.scope_session().configure( + fixed_edge=ScopeFixedEdge(range_index, edge, start_hz, end_hz) ) - self._scope_controls().fixed_edge = fixed_edge - self._scope_controls().edge = fixed_edge.edge async def get_scope_rbw(self) -> int: """Read the scope RBW preset (0=wide, 1=mid, 2=narrow).""" diff --git a/src/rigplane/runtime/callable_support.py b/src/rigplane/runtime/callable_support.py index 783517a19..22201eda5 100644 --- a/src/rigplane/runtime/callable_support.py +++ b/src/rigplane/runtime/callable_support.py @@ -77,7 +77,7 @@ class RelativeVfoCommands: _SCOPE_BUILDERS = (scope_on, scope_data_output) BUILDER_RELATIONS: tuple[BuilderAllOf, ...] = ( - BuilderAllOf("disable_scope", (scope_data_output,), "alias"), + BuilderAllOf("disable_scope", (scope_data_output,), "composite"), BuilderAllOf("get_alc_meter", (get_alc,), "alias"), BuilderAllOf("get_antenna_1", (get_antenna_1,), "alias"), BuilderAllOf("get_antenna_2", (get_antenna_2,), "alias"), diff --git a/src/rigplane/runtime/radio_scope_source.py b/src/rigplane/runtime/radio_scope_source.py index 808cd56b4..da4531c15 100644 --- a/src/rigplane/runtime/radio_scope_source.py +++ b/src/rigplane/runtime/radio_scope_source.py @@ -4,9 +4,11 @@ from collections.abc import Callable from dataclasses import replace +import math from typing import Literal, Protocol from rigplane.profiles import RadioProfile +from rigplane.core.types import ScopeFixedEdge from rigplane.scope import ScopeFrame from rigplane.scope.models import ( Coverage, @@ -21,7 +23,9 @@ class RadioScopeWriter(Protocol): def tuned_frequency(self, receiver: int) -> float: ... - async def set_output(self, enabled: bool) -> None: ... + async def set_output(self, enabled: bool, *, release: bool = True) -> None: ... + def on_frame(self, frame: ScopeFrame) -> None: ... + async def set_fixed(self, edges: ScopeFixedEdge, receiver: int) -> None: ... async def set_center( self, center_hz: float, span_hz: float, receiver: int ) -> None: ... @@ -38,13 +42,17 @@ def __init__( max_fps: float = 30, ) -> None: self._writer = writer + self._fixed_ranges = profile.scope_fixed_edge_ranges + self._fixed_step = profile.scope_fixed_edge_step_hz self._caps = SourceCapabilities( tuple(range(profile.receiver_count)), - ("center",), + ("center", "fixed") if self._fixed_ranges else ("center",), profile.scope_span_presets_hz, None, max_fps, - "center ± span/2", + "profile fixed edges or center ± span/2" + if self._fixed_ranges + else "center ± span/2", ) if not self._caps.span_choices_hz: raise ValueError("radio scope requires profile span presets") @@ -72,23 +80,48 @@ def resolve_view(self, view: ViewRequest, receiver: int | None = None) -> View: def coverage_for(self, view: ViewRequest, receiver: int | None = None) -> Coverage: resolved = self.resolve_view(view, receiver) + fixed = ( + None + if isinstance(view, FollowView) + else self._fixed_edges(resolved, self._receiver(receiver)) + ) + if fixed is not None: + return Coverage(fixed.start_hz, fixed.end_hz) center = self._writer.tuned_frequency(self._receiver(receiver)) needed = 2 * max(abs(resolved.start_hz - center), abs(resolved.end_hz - center)) spans = self._caps.span_choices_hz span = next((span for span in spans if span >= needed), spans[-1]) return Coverage(center - span / 2, center + span / 2) + def _fixed_edges(self, view: View, receiver: int) -> ScopeFixedEdge | None: + if self._fixed_step is None: + return None + for limits in self._fixed_ranges: + start = math.floor(view.start_hz / self._fixed_step) * self._fixed_step + end = math.ceil(view.end_hz / self._fixed_step) * self._fixed_step + if limits.start_hz <= start < end <= limits.end_hz: + return ScopeFixedEdge(limits.range_index, receiver + 1, start, end) + return None + async def set_view( self, view: ViewRequest, receiver: int | None = None ) -> GrantedView: selected = self._receiver(receiver) resolved = self.resolve_view(view, selected) - coverage = self.coverage_for(resolved, selected) - await self._writer.set_center( - (coverage.start_hz + coverage.end_hz) / 2, - coverage.end_hz - coverage.start_hz, - selected, + coverage = self.coverage_for(view, selected) + fixed = ( + None + if isinstance(view, FollowView) + else self._fixed_edges(resolved, self._receiver(receiver)) ) + if fixed is not None: + await self._writer.set_fixed(fixed, selected) + else: + await self._writer.set_center( + (coverage.start_hz + coverage.end_hz) / 2, + coverage.end_hz - coverage.start_hz, + selected, + ) return GrantedView(resolved, coverage) async def start(self) -> None: @@ -101,10 +134,9 @@ async def start(self) -> None: await self.stop() raise - async def stop(self) -> None: - if self._active: - await self._writer.set_output(False) - self._active = False + async def stop(self, *, release: bool = True) -> None: + await self._writer.set_output(False, release=release) + self._active = False def on_frame(self, callback: Callable[[NativeFrame], None]) -> Callable[[], None]: self._callbacks.append(callback) @@ -118,6 +150,7 @@ def unsubscribe() -> None: def feed_frame(self, frame: ScopeFrame) -> None: if not self._active: return + self._writer.on_frame(frame) receiver = self._receiver(frame.receiver) native_width = self._native_widths.get(receiver) if frame.pixels and not frame.out_of_range: diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py new file mode 100644 index 000000000..3a9cdc530 --- /dev/null +++ b/src/rigplane/runtime/scope_session.py @@ -0,0 +1,427 @@ +"""One acknowledged scope-control session shared by SDK and web consumers.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace +from typing import Literal, Protocol, TypeAlias, cast + +from rigplane.core.types import ScopeFixedEdge +from rigplane.scope import ScopeFrame +from rigplane.scope.models import ScopeDemand + +ScopeSetting: TypeAlias = Literal[ + "scope_on", "output_on", "mode", "span", "edge", "fixed_edge" +] +ScopeValue: TypeAlias = bool | int | ScopeFixedEdge + + +@dataclass(frozen=True) +class ScopeState: + scope_on: bool + output_on: bool + mode: int = 0 + span: int = 2 + edge: int = 1 + + +@dataclass(frozen=True) +class ScopeSessionHealth: + hidden: bool | None + last_frame_at: float | None + out_of_range: bool + + +class ScopeSession: + def __init__( + self, + read: Callable[[], Awaitable[ScopeState]], + write: Callable[[ScopeSetting, ScopeValue, int], Awaitable[None]], + read_fixed: Callable[[int, int], Awaitable[ScopeFixedEdge]] | None = None, + read_receiver: Callable[[int], Awaitable[ScopeState]] | None = None, + frequency: Callable[[int], float] | None = None, + spans: tuple[int, ...] = (), + tx_probe: Callable[[], bool] | None = None, + *, + ready: bool = False, + clock: Callable[[], float] = time.monotonic, + disable_grace_seconds: float = 0, + silence_seconds: float = 5, + hidden_grace_seconds: float = 30, + ) -> None: + self._read = read + self._write = write + self._read_fixed = read_fixed + self._read_receiver = read_receiver + self._frequency = frequency + self._spans = spans + self.tx_probe = tx_probe + self._lock = asyncio.Lock() + self._ready = ready + self._tx = False + self._demands: dict[str, ScopeDemand] = {} + self._clock = clock + self._grace = disable_grace_seconds + self._silence = silence_seconds + self.hidden_grace_seconds = hidden_grace_seconds + self._hidden_at: float | None = None + self._connection_epoch: int | None = None + self._release_at: float | None = None + self._release_task: asyncio.Task[None] | None = None + self._last_frame_at: float | None = None + self._out_of_range = False + self._enabled_at: float | None = None + self._paused = False + self.generation = 0 + self.observed: ScopeState | None = None + self._original: ScopeState | None = None + self._desired: dict[tuple[int, ScopeSetting], ScopeValue] = {} + self._previous: dict[tuple[int, ScopeSetting], ScopeValue] = {} + self._views: dict[int, ScopeState] = {} + self._fixed: dict[tuple[int, int], ScopeFixedEdge] = {} + self._applied: dict[tuple[int, ScopeSetting], ScopeValue] = {} + self._uncertain: set[tuple[int, ScopeSetting]] = set() + self._restore_pending = False + + @property + def enabled(self) -> bool: + return self.observed is not None and self.observed.output_on + + @property + def restoring(self) -> bool: + return self._restore_pending + + @property + def active(self) -> bool: + return ( + any(d.subscribers for d in self._demands.values()) + or self._original is not None + ) + + def tuned_frequency(self, receiver: int) -> float: + if self._frequency is None: + raise RuntimeError("scope frequency observation is unavailable") + return self._frequency(receiver) + + async def set_output(self, enabled: bool, *, release: bool = True) -> None: + current = self._demands.get("web", ScopeDemand(0, 0)) + subscribers = max(1, current.subscribers) if enabled or not release else 0 + active = max(1, current.active) if enabled else 0 + await self.set_demand(ScopeDemand(subscribers, active)) + + async def set_center(self, center_hz: float, span_hz: float, receiver: int) -> None: + await self.configure( + receiver=receiver, mode=0, span=self._spans.index(int(span_hz)) + ) + + async def set_fixed(self, edges: ScopeFixedEdge, receiver: int) -> None: + await self.configure( + receiver=receiver, fixed_edge=edges, edge=edges.edge, mode=1 + ) + + def suspend(self) -> None: + self._ready = False + + async def set_ready(self, ready: bool) -> None: + async with self._lock: + self._ready = ready + await self._reconcile() + + async def set_tx(self, active: bool) -> None: + async with self._lock: + self._tx = active + await self._reconcile() + + async def invalidate(self, *, connection_epoch: int | None = None) -> None: + async with self._lock: + if ( + connection_epoch is not None + and connection_epoch == self._connection_epoch + ): + return + self._connection_epoch = connection_epoch + self.generation += 1 + if not self.menu_guard_active(): + self.observed = None + self._applied.clear() + self._ready = False + + def menu_guard_active(self) -> bool: + return ( + self._hidden_at is not None + and self._clock() - self._hidden_at <= self.hidden_grace_seconds + ) + + async def set_inputs(self, *, ready: bool, tx: bool) -> None: + async with self._lock: + self._ready, self._tx = ready, tx + await self._reconcile() + + async def set_demand(self, demand: ScopeDemand, *, owner: str = "web") -> None: + async with self._lock: + previous = self._demands.get(owner, ScopeDemand(0, 0)) + self._demands[owner] = demand + if demand != previous: + self.generation += 1 + self._paused = not any(d.active for d in self._demands.values()) + if any(d.subscribers for d in self._demands.values()): + self._restore_pending = False + self._release_at = None + if self._release_task is not None: + self._release_task.cancel() + self._release_task = None + elif self._original is not None and not self._restore_pending: + self._restore_pending = True + self._release_at = self._clock() + self._grace + if self._grace: + self._release_task = asyncio.create_task( + self._release_after_grace() + ) + elif self._original is None: + self._desired.clear() + await self._reconcile() + + async def _release_after_grace(self) -> None: + await asyncio.sleep(self._grace) + await self.reconcile() + + async def reconcile(self) -> None: + async with self._lock: + await self._reconcile() + + def on_frame(self, frame: ScopeFrame) -> None: + self._last_frame_at = self._clock() + self._out_of_range = frame.out_of_range + + def health( + self, + *, + civ_flowing: bool, + silence_seconds: float | None = None, + link_ready: bool = True, + ) -> ScopeSessionHealth: + now = self._clock() + silence = self._silence if silence_seconds is None else silence_seconds + if ( + not self._ready + or not link_ready + or self._tx + or (self.tx_probe is not None and self.tx_probe()) + ): + self._enabled_at = now + return ScopeSessionHealth(None, self._last_frame_at, self._out_of_range) + if not self.enabled or not any(d.active for d in self._demands.values()): + return ScopeSessionHealth(None, self._last_frame_at, self._out_of_range) + fresh = self._last_frame_at is not None and now - self._last_frame_at <= silence + if fresh: + self._hidden_at = None + return ScopeSessionHealth(False, self._last_frame_at, self._out_of_range) + last = max(t for t in (self._last_frame_at, self._enabled_at) if t is not None) + if not civ_flowing: + return ScopeSessionHealth(None, self._last_frame_at, self._out_of_range) + hidden = now - last > silence + self._hidden_at = now if hidden else None + return ScopeSessionHealth(hidden, self._last_frame_at, self._out_of_range) + + async def configure(self, *, receiver: int = 0, **settings: ScopeValue) -> None: + async with self._lock: + previous_desired = dict(self._desired) + if settings.get("mode") == 1 and "fixed_edge" in settings: + self._desired.pop((receiver, "span"), None) + elif settings.get("mode") == 0 and "span" in settings: + self._desired.pop((receiver, "fixed_edge"), None) + self._desired.pop((receiver, "edge"), None) + for name, value in settings.items(): + if name not in ( + "scope_on", + "output_on", + "mode", + "span", + "edge", + "fixed_edge", + ): + raise ValueError(f"unknown scope setting {name}") + if name == "fixed_edge" and isinstance(value, ScopeFixedEdge): + for key, prior in tuple(self._desired.items()): + if ( + key[1] == "fixed_edge" + and isinstance(prior, ScopeFixedEdge) + and (prior.range_index, prior.edge) + == (value.range_index, value.edge) + ): + del self._desired[key] + self._desired[receiver, cast(ScopeSetting, name)] = value + try: + await self._reconcile() + except ValueError: + self._desired = previous_desired + raise + + async def _put( + self, name: ScopeSetting, value: ScopeValue, receiver: int = 0 + ) -> None: + if not self._ready: + raise RuntimeError("scope control is deferred until radio readiness") + if self._tx or (self.tx_probe is not None and self.tx_probe()): + raise RuntimeError("scope control is deferred during transmit") + self._uncertain.add((receiver, name)) + await self._write(name, value, receiver) + self._uncertain.discard((receiver, name)) + if name == "fixed_edge" and isinstance(value, ScopeFixedEdge): + for key, prior in tuple(self._applied.items()): + if ( + key[1] == "fixed_edge" + and isinstance(prior, ScopeFixedEdge) + and (prior.range_index, prior.edge) + == (value.range_index, value.edge) + ): + del self._applied[key] + self._applied[receiver, name] = value + if name == "output_on" and value: + self._enabled_at = self._clock() + self._last_frame_at = None + self._out_of_range = False + if name != "fixed_edge" and receiver == 0: + base = self.observed or self._original + if base is not None: + if name == "scope_on": + self.observed = replace(base, scope_on=bool(value)) + elif name == "output_on": + self.observed = replace(base, output_on=bool(value)) + elif name == "mode": + self.observed = replace(base, mode=cast(int, value)) + elif name == "span": + self.observed = replace(base, span=cast(int, value)) + else: + self.observed = replace(base, edge=cast(int, value)) + + async def _reconcile(self) -> None: + if ( + not self._ready + or self._tx + or (self.tx_probe is not None and self.tx_probe()) + ): + return + if self._restore_pending: + if self._release_at is not None and self._clock() < self._release_at: + return + if self._original is None: + self._restore_pending = False + self._desired.clear() + return + failures: list[BaseException] = [] + for (receiver, name), value in tuple(self._previous.items()): + try: + await self._put(name, value, receiver) + except BaseException as exc: + failures.append(exc) + for value in self._fixed.values(): + try: + await self._put("fixed_edge", value) + except BaseException as exc: + failures.append(exc) + for name in ("scope_on", "output_on"): + try: + value = getattr(self._original, name) + if ( + (0, name) in self._uncertain + or self.observed is None + or getattr(self.observed, name) != value + ): + await self._put(name, value) + except BaseException as exc: + failures.append(exc) + if failures: + raise failures[0] + self.observed = self.observed or self._original + self._original = None + self._previous.clear() + self._fixed.clear() + self._views.clear() + self._desired.clear() + self._applied.clear() + self._uncertain.clear() + self._restore_pending = self._paused = False + self._connection_epoch = None + return + if ( + any(d.subscribers for d in self._demands.values()) + and self._original is None + ): + self._original = await self._read() + self.observed = self._original + if self._original.output_on: + self._enabled_at = self._clock() + for setting in ("scope_on", "output_on"): + self._applied[0, setting] = getattr(self._original, setting) + if any(d.subscribers for d in self._demands.values()): + assert self._original is not None + panel = ( + True if not self._paused else (self.observed or self._original).scope_on + ) + if (0, "scope_on") in self._desired: + panel = bool(self._desired[0, "scope_on"]) + for name, value in (("scope_on", panel), ("output_on", not self._paused)): + if ( + self._applied.get((0, name)) != value + or (0, name) in self._uncertain + ): + await self._put(name, value) + for (receiver, name), value in sorted( + self._desired.items(), + key=lambda item: ( + "fixed_edge", + "edge", + "mode", + "span", + "scope_on", + "output_on", + ).index(item[0][1]), + ): + if name in ("scope_on", "output_on") and any( + d.subscribers for d in self._demands.values() + ): + continue + if ( + self._original is None + and receiver == 0 + and self.observed is not None + and name != "fixed_edge" + and getattr(self.observed, name) == value + ): + continue + if self._applied.get((receiver, name)) == value: + continue + if name == "fixed_edge": + assert isinstance(value, ScopeFixedEdge) + slot = (value.range_index, value.edge) + if self._original is not None and slot not in self._fixed: + if self._read_fixed is None: + raise RuntimeError("scope fixed edge cannot be captured") + captured_fixed = await self._read_fixed(*slot) + if captured_fixed == value: + self._applied[receiver, name] = value + continue + self._fixed[slot] = captured_fixed + elif self._original is not None: + key = (receiver, name) + if key not in self._previous: + if receiver not in self._views: + self._views[receiver] = ( + await self._read_receiver(receiver) + if self._read_receiver is not None + else self._original + ) + captured = self._views[receiver] + if getattr(captured, name) == value: + self._applied[key] = value + continue + self._previous[key] = getattr(captured, name) + if self._applied.get((receiver, name)) != value: + await self._put(name, value, receiver) + + +class ScopeSessionProvider(Protocol): + def scope_session(self) -> ScopeSession: ... diff --git a/src/rigplane/scope/hub.py b/src/rigplane/scope/hub.py index 2632a5e4a..dba6bb4e7 100644 --- a/src/rigplane/scope/hub.py +++ b/src/rigplane/scope/hub.py @@ -13,6 +13,7 @@ GrantedView, NativeFrame, ScopeSourcePlugin, + ScopeDemand, ScopeWireFrame, SourceCapabilities, Subscription, @@ -32,6 +33,7 @@ class ScopeHealth: @dataclass class _Source: plugin: ScopeSourcePlugin + on_demand: Callable[[ScopeDemand], Awaitable[None]] | None = None lock: asyncio.Lock = field(default_factory=asyncio.Lock) clients: set[HubSubscription] = field(default_factory=set) unsubscribe: Callable[[], None] | None = None @@ -141,10 +143,16 @@ def __init__( self._loop = loop if loop is not None else asyncio.get_running_loop() self._sources: dict[str, _Source] = {} - def register(self, source_id: str, plugin: ScopeSourcePlugin) -> None: + def register( + self, + source_id: str, + plugin: ScopeSourcePlugin, + *, + on_demand: Callable[[ScopeDemand], Awaitable[None]] | None = None, + ) -> None: if source_id in self._sources: raise ValueError(f"duplicate scope source: {source_id}") - self._sources[source_id] = _Source(plugin) + self._sources[source_id] = _Source(plugin, on_demand) def capabilities(self, source_id: str) -> SourceCapabilities: return self._sources[source_id].plugin.capabilities() @@ -203,6 +211,13 @@ async def subscribe(self, request: Subscription) -> HubSubscription: ), ) source.clients.add(subscription) + try: + await self._notify_demand(source) + except BaseException: + subscription._discard() + source.clients.remove(subscription) + await _finish(self._notify_demand(source)) + raise return subscription except BaseException: if first: @@ -231,18 +246,31 @@ async def _unsubscribe(self, client: HubSubscription) -> None: return client._discard() source.clients.remove(client) - if not any(client.granted.max_fps > 0 for client in source.clients) and ( - source.unsubscribe is not None or source.stop_pending - ): - try: - self._detach(source) - finally: - await _finish(self._stop(source)) + try: + await self._notify_demand(source) + finally: + if not any(c.granted.max_fps > 0 for c in source.clients) and ( + source.unsubscribe is not None or source.stop_pending + ): + try: + self._detach(source) + finally: + await _finish(self._stop(source)) + + @staticmethod + async def _notify_demand(source: _Source) -> None: + if source.on_demand is not None: + await source.on_demand( + ScopeDemand( + len(source.clients), + sum(c.granted.max_fps > 0 for c in source.clients), + ) + ) @staticmethod async def _stop(source: _Source) -> None: source.stop_pending = True - await source.plugin.stop() + await source.plugin.stop(release=not source.clients) source.stop_pending = False def _receive_frame( diff --git a/src/rigplane/scope/models.py b/src/rigplane/scope/models.py index 300be338c..882a8d818 100644 --- a/src/rigplane/scope/models.py +++ b/src/rigplane/scope/models.py @@ -43,6 +43,16 @@ def _identity(value: object) -> None: ) +@dataclass(frozen=True) +class ScopeDemand: + subscribers: int + active: int + + def __post_init__(self) -> None: + if not 0 <= self.active <= self.subscribers: + raise ValueError("scope demand requires 0 <= active <= subscribers") + + @dataclass(frozen=True) class View: start_hz: float @@ -263,7 +273,7 @@ async def set_view( self, view: ViewRequest, receiver: int | None = None ) -> GrantedView: ... async def start(self) -> None: ... - async def stop(self) -> None: ... + async def stop(self, *, release: bool = True) -> None: ... def on_frame( self, callback: Callable[[NativeFrame], None] ) -> Callable[[], None]: ... diff --git a/src/rigplane/sdr/scope_source.py b/src/rigplane/sdr/scope_source.py index 9f56d832b..cf6d61de7 100644 --- a/src/rigplane/sdr/scope_source.py +++ b/src/rigplane/sdr/scope_source.py @@ -171,7 +171,7 @@ async def start(self) -> None: self._last_error = str(exc) raise - async def stop(self) -> None: + async def stop(self, *, release: bool = True) -> None: if not self._active: return self._last_error = None diff --git a/src/rigplane/web/radio_poller.py b/src/rigplane/web/radio_poller.py index a47ae3ee1..eb347150d 100644 --- a/src/rigplane/web/radio_poller.py +++ b/src/rigplane/web/radio_poller.py @@ -29,6 +29,8 @@ from __future__ import annotations +from rigplane.scope.models import ScopeDemand + import asyncio import dataclasses import logging @@ -627,10 +629,6 @@ def __init__( # This prevents EnableScope from hanging in tests that don't call start(). self._initial_fetch_done = asyncio.Event() self._initial_fetch_done.set() - self._scope_enable_deferred = False - self._scope_demand_generation = queue.latest_scope_demand_generation - self._scope_session_state: tuple[bool, bool] | None = None - self._scope_session_active = False self._vfo_command_lock = asyncio.Lock() self._vfo_binding_generation = 0 self._vfo_connect_attempt: tuple[int, object] | None = None @@ -1690,8 +1688,9 @@ async def execute_entry(entry: CommandQueueEntry) -> Observation | None: # Initial state is now fetched by CoreRadio._fetch_initial_state() # during connect(). Just signal readiness immediately. - self._scope_enable_deferred = False self._initial_fetch_done.set() + if CAP_SCOPE in self._caps: + await self._radio.scope_session().set_ready(True) # NB depth/width are global menu items that cannot ride the poll-query # envelope, so seed them once at connect via direct getters (MOR-491-B). @@ -1894,87 +1893,12 @@ def _current_active(self) -> str: _active = getattr(rs, "active", None) if rs is not None else None return _active if isinstance(_active, str) else "MAIN" - def _scope_demand_is_stale(self, generation: int) -> bool: - latest = max( - self._scope_demand_generation, - self._queue.latest_scope_demand_generation, - ) - if generation < latest: - logger.debug( - "radio-poller: dropping stale scope demand generation %d (current=%d)", - generation, - latest, - ) - return True - self._scope_demand_generation = generation - return False - - async def _enable_scope_session(self, *, policy: str) -> None: - """Capture scope state once, then enable through the existing wire lane.""" - radio = self._radio - get_state = getattr(radio, "get_scope_session_state", None) - restore_state = getattr(radio, "restore_scope_session_state", None) - if get_state is None or restore_state is None: - raise CommandError( - "scope backend cannot preserve pre-session panel/output state" - ) - if self._scope_session_state is None: - self._scope_session_state = await get_state() - try: - await radio.enable_scope(policy=policy) # type: ignore[attr-defined] - except BaseException: - # Once enable starts, its wire effect is uncertain: FAST can fail - # after the panel ON write, and VERIFY can time out after both ON - # writes were applied. Roll back before reporting failure. The - # captured tuple remains owned if rollback itself fails, allowing a - # later DisableScope teardown to retry deterministically. - self._scope_session_active = True - try: - await self.restore_scope_session() - except BaseException: - logger.exception( - "radio-poller: scope enable failed and rollback is pending" - ) - raise - self._scope_session_active = True - - async def restore_scope_session(self) -> None: - """Restore exactly what the first successful scope viewer inherited.""" - if not self._scope_session_active or self._scope_session_state is None: - return - if getattr(self._radio, "external_cat_session_active", False) is True: - raise CommandError( - "scope restore deferred while an external CAT session owns the wire" - ) - restore_state = getattr(self._radio, "restore_scope_session_state", None) - if restore_state is None: - raise CommandError( - "scope backend cannot restore pre-session panel/output state" - ) - await restore_state(self._scope_session_state) - self._scope_session_active = False - self._scope_session_state = None - async def restore_scope_after_shutdown(self) -> None: - """Restore scope through this stopped poller's sole command executor. - - ``stop_web_server`` calls this only after ``stop()`` and the final TX - safety drain. Re-entering ``_execute`` therefore uses the same sole - poller executor as the live ``CommandQueue`` consumer, with no second - task or radio-control lane. The backend write continues through its - existing ``_send_civ_raw`` / ``IcomCommander`` serialization. - """ - if CAP_SCOPE not in self._caps: - raise CommandError("scope restore unavailable on this radio") - generation = ( - max( - self._scope_demand_generation, - self._queue.latest_scope_demand_generation, - ) - + 1 - ) - await self._execute(DisableScope(generation=generation)) - if self._scope_session_active: + """Release web scope demand after the final TX safety drain.""" + session = self._radio.scope_session() + await session.set_demand(ScopeDemand(0, 0)) + await session.set_inputs(ready=True, tx=False) + if session.restoring: raise CommandError("scope restore did not settle") def _managed_tx( @@ -2829,33 +2753,24 @@ def after_readback() -> None: await radio.equalize_vfo_ab(0) else: await radio.equalize_main_sub() - case EnableScope(policy=policy, generation=generation): + case EnableScope(policy=policy): if CAP_SCOPE in self._caps: - # Defer scope enable during initial fetch to avoid - # CI-V packet queue overflow (scope data + fetch). - if not self._initial_fetch_done.is_set(): - if self._scope_demand_is_stale(generation): - return None - if not self._scope_enable_deferred: - logger.info( - "radio-poller: deferring scope enable until initial fetch completes" - ) - self._scope_enable_deferred = True - self._queue.put( - EnableScope(policy=policy, generation=generation) + session = radio.scope_session() + await session.set_ready(self._initial_fetch_done.is_set()) + try: + await session.set_demand(ScopeDemand(1, 1), owner="sdk") + if policy == "verify" and session.enabled: + await radio.enable_scope(policy=policy) + except BaseException: + await asyncio.shield( + session.set_demand(ScopeDemand(0, 0), owner="sdk") ) - else: - if self._scope_demand_is_stale(generation): - return None - await self._enable_scope_session(policy=policy) - logger.info("radio-poller: scope enabled") - await self._fetch_scope_controls() - case DisableScope(generation=generation): + raise + case DisableScope(): if CAP_SCOPE in self._caps: - if self._scope_demand_is_stale(generation): - return None - await self.restore_scope_session() - logger.info("radio-poller: scope session state restored") + await radio.scope_session().set_demand( + ScopeDemand(0, 0), owner="sdk" + ) case SwitchScopeReceiver(receiver=receiver): # Fire-and-forget scope receiver select (0x27 0x12), routed # through the profile-bound command map (self._cmd_map) via diff --git a/src/rigplane/web/scope_sources.py b/src/rigplane/web/scope_sources.py index a3609f4b8..db0bc9358 100644 --- a/src/rigplane/web/scope_sources.py +++ b/src/rigplane/web/scope_sources.py @@ -24,30 +24,6 @@ } -class WebRadioScopeWriter: - def __init__(self, server: WebServer) -> None: - self._server = server - - def tuned_frequency(self, receiver: int) -> float: - state = self._server._radio_state - return float(state.main.freq if receiver == 0 else state.sub.freq) - - async def set_output(self, enabled: bool) -> None: - await self._server._set_radio_scope_output(enabled) - - async def set_center(self, center_hz: float, span_hz: float, receiver: int) -> None: - from .radio_poller import SetScopeMode, SetScopeSpan, SwitchScopeReceiver # noqa: TID251 - - span = self._server._get_profile().scope_span_presets_hz.index(int(span_hz)) - for command in ( - SwitchScopeReceiver(receiver), - SetScopeMode(0), - SetScopeSpan(span), - ): - if not await self._server._execute_scope_command(command): - raise RuntimeError(f"radio scope {type(command).__name__} failed") - - class WebScopePcmTap: def __init__(self, server: WebServer, *, sample_rate: int | None = None) -> None: if sample_rate is not None and ( diff --git a/src/rigplane/web/server.py b/src/rigplane/web/server.py index 02464176a..627b3d1a9 100644 --- a/src/rigplane/web/server.py +++ b/src/rigplane/web/server.py @@ -118,7 +118,8 @@ from ..scope.hub import ScopeHub from ..scope.models import SourceKind from ..runtime.radio_scope_source import RadioScopeSource -from .scope_sources import WebRadioScopeWriter, WebScopePcmTap # noqa: TID251 +from .scope_sources import WebScopePcmTap # noqa: TID251 +from ..runtime.scope_session import ScopeSession, ScopeSessionProvider from ..audio.scope_source import AudioFftSource from ..radio_state import RadioState from ..capabilities import CAP_AUDIO @@ -167,12 +168,7 @@ from .radio_poller import ( # noqa: TID251 CommandQueue, CommandQueueEntry, - DisableScope, - EnableScope, RadioPoller, - SetScopeMode, - SetScopeSpan, - SwitchScopeReceiver, ) from .monitor_mute import MonitorMuteState # noqa: TID251 from .runtime_helpers import ( # noqa: TID251 @@ -1308,13 +1304,8 @@ def __init__( self._scope_handlers: set["ScopeHandler"] = set() self._scope_hub: ScopeHub | None = None self._radio_scope_source: RadioScopeSource | None = None - self._radio_scope_demand = False # Confirmed hardware-session state only. A queued command is not proof. - self._scope_enabled = False - self._scope_enable_failed = False - self._scope_demand_generation = 0 - self._scope_command_futures: set[asyncio.Future[None]] = set() - self._scope_enable_lock: asyncio.Lock = asyncio.Lock() + self._radio_scope_session: ScopeSession | None = None raw_radio_state = ( getattr(radio, "radio_state", None) if radio is not None else None ) @@ -1453,32 +1444,13 @@ def __init__( self._scope_health_interval: float = ( 10.0 # seconds of missing/blank frames before the hidden verdict ) - self._scope_reenable_task: asyncio.Task[None] | None = None - self._scope_reenable_poll_interval: float = 0.5 - self._scope_reenable_timeout: float = 30.0 # prevent GC of fire-and-forget tasks self._bg_tasks: set[asyncio.Task[Any]] = set() # Serialises the managed TX rebind; see _service_managed_tx_release. self._managed_tx_rebind_lock: asyncio.Lock = asyncio.Lock() # Unsubscribes the managed TX authority invalidation listener on stop. self._managed_tx_change_unsubscribe: Callable[[], None] | None = None - # MOR-3690 (reopened): the monitor tells "frames arrive" apart from - # "frames carry data". All-zero frames that keep arriving — or NO - # frames at all while CI-V traffic keeps flowing (an IC-705 over USB - # serial sends nothing while in its own menu) — mean the radio - # deliberately shows another screen: a valid state, never a - # transport fault, and never a reason to write scope-enable (that - # write kicks the operator out of the menu). - self._scope_started_at: float = 0.0 - self._scope_hidden_published: bool | None = None # last fact verdict - self._scope_hidden_at: float | None = None # when last confirmed - # The reconnect path is the only automatic scope re-enable left; it - # fires at most once per viewing session (demand generation) AND - # connection epoch (provider generation), and never while the menu - # guard is active. None = never sent. - self._scope_reenable_generation: int | None = None - self._scope_reenable_provider_generation: int | None = None - # Band plan registry + self._scope_hidden_published: bool | None = None from .band_plan import DEFAULT_BAND_PLANS_DIR, BandPlanRegistry # noqa: TID251 self._band_plan = BandPlanRegistry( @@ -1799,13 +1771,23 @@ def scope_hub(self) -> ScopeHub: ) if ( self._hardware_scope_available + and self._radio is not None and profile is not None and profile.scope_span_presets_hz ): + self._radio_scope_session = cast( + ScopeSessionProvider, self._radio + ).scope_session() + self._radio_scope_session.tx_probe = self._scope_tx_known self._radio_scope_source = RadioScopeSource( - profile, WebRadioScopeWriter(self) + profile, self._radio_scope_session + ) + self._set_scope_data_callback(self._radio_scope_source.feed_frame) + hub.register( + "radio", + self._radio_scope_source, + on_demand=self._radio_scope_session.set_demand, ) - hub.register("radio", self._radio_scope_source) if self._audio_scope_source is not None: hub.register("audio_fft", self._audio_scope_source) self._scope_hub = hub @@ -1850,83 +1832,6 @@ def unregister_scope_handler(self, handler: "ScopeHandler") -> None: self._scope_handlers.discard(handler) self._broadcast_ws_client_state_update() - async def _set_radio_scope_output(self, enabled: bool) -> None: - async with self._scope_enable_lock: - if enabled and self._stopping: - raise RuntimeError("scope server is stopping") - self._scope_demand_generation += 1 - self._radio_scope_demand = enabled - if enabled: - self._set_scope_data_callback( - self._radio_scope_source.feed_frame - if self._radio_scope_source - else None - ) - await self._enable_hardware_scope( - reason="subscription", generation=self._scope_demand_generation - ) - if self._scope_enable_failed: - raise RuntimeError("radio scope enable failed") - else: - self._cancel_scope_reenable_task() - self._set_scope_data_callback(None) - if self._stopping: - # web_startup restores after the final TX drain, with its - # existing bound and the stopped poller's sole executor. - return - completed = await self._execute_scope_command( - DisableScope(generation=self._scope_demand_generation) - ) - if self._stopping: - return - if not completed: - raise RuntimeError("radio scope disable failed") - self._scope_enabled = False - self._scope_enable_failed = False - - async def _enable_hardware_scope(self, *, reason: str, generation: int) -> None: - if self._scope_enabled: - return - if self._scope_enable_failed: - return - if self._radio_ready(): - if await self._execute_scope_command(EnableScope(generation=generation)): - self._scope_enabled = True - self._scope_started_at = asyncio.get_running_loop().time() - else: - self._schedule_scope_enable_when_ready(reason=reason, generation=generation) - - async def _execute_scope_command( - self, - command: EnableScope - | DisableScope - | SwitchScopeReceiver - | SetScopeMode - | SetScopeSpan, - ) -> bool: - """Execute a scope command through the existing ordered command pipeline.""" - if self._stopping: - return False - future: asyncio.Future[None] = asyncio.get_running_loop().create_future() - self._scope_command_futures.add(future) - try: - self._command_queue.put_ordered(command, future=future) - await future - except asyncio.CancelledError: - if self._stopping: - return False - raise - except Exception as exc: - if isinstance(command, EnableScope) and not self._scope_enabled: - self._scope_enable_failed = True - logger.warning("scope: %s failed: %s", type(command).__name__, exc) - return False - finally: - self._scope_command_futures.discard(future) - if isinstance(command, EnableScope): - self._scope_enable_failed = False - return True - @staticmethod def _active_primary_freq_mode_value( snapshot: StateSnapshot, name: str @@ -3388,14 +3293,18 @@ async def finish_startup_busy_hold(self) -> None: async def _refetch_and_reenable(self) -> None: """Release TX first, then refetch state, signal readiness and scope.""" + if self._radio_scope_session is not None: + await self._radio_scope_session.invalidate( + connection_epoch=self.command_state_store.provider_generation + ) try: # Inside the guard, not above it (MOR-1187's shape, MOR-1196): # servicing the release resolves the supervisor, which is # backend code and free to raise anything. Above the # ``finally`` it escapes with the gate still clear, and since # this method is the only thing in ``src/`` that ever re-sets - # the gate, the poller defers and re-queues ``EnableScope`` for - # good. The failure still propagates — the point is that + # the gate, readiness would remain closed for good. + # The failure still propagates — the point is that # cleanup runs first, not that anything is swallowed. await self._service_managed_tx_release() try: @@ -3431,157 +3340,8 @@ async def _refetch_and_reenable(self) -> None: "reconnect: scan facts seed failed", exc_info=True, ) - # Re-enable scope after refetch completes — at most once per - # viewing session, and never while the menu guard says the radio - # shows its own menu (MOR-3690 reopened: an IC-705 over USB sends - # no scope frames in its menu, and an unguarded write here kicked - # the operator out of it on every reconnect cycle). - # Do NOT gate on self._radio_ready(): that property waits for - # CI-V broadcast data, but on IC-7610 in the "deaf" firmware - # state broadcast may only resume once a scope-enable command - # is sent — creating a deadlock where scope re-enable waits - # for the very signal it would itself trigger. The session - # is already up at this point (soft_reconnect completed auth + - # discovery), so queue EnableScope without waiting; if the - # radio is genuinely unreachable the command will fail on its - # own, which is strictly better than a silent 30-second wait - # every reconnect cycle. - generation = self._scope_demand_generation - if ( - self._radio_scope_demand - and self._radio is not None - and self._hardware_scope_available - and not self._scope_enable_failed - ): - # The existing scope-enable lock serialises the attempt check - # and the awaited command: overlapping refetch tasks cannot - # both pass the generation check before either enable lands, - # so the ordered queue never sees the claimed bound exceeded. - async with self._scope_enable_lock: - if ( - not self._radio_scope_demand - or generation != self._scope_demand_generation - or self._scope_enable_failed - ): - return - if self._scope_menu_guard_active(now=time.monotonic()): - logger.info( - "scope: reconnect re-enable skipped — radio is in its " - "own menu (scope hidden on the radio)" - ) - elif self._scope_reenable_recorded_for( - self.command_state_store.provider_generation - ): - logger.info( - "scope: reconnect re-enable already sent for this " - "viewing session (generation %d), skipping", - self._scope_demand_generation, - ) - else: - self._set_scope_data_callback( - self._radio_scope_source.feed_frame - if self._radio_scope_source - else None - ) - if await self._execute_scope_command( - EnableScope(generation=generation) - ): - self._scope_enabled = True - self._scope_started_at = asyncio.get_running_loop().time() - self._scope_reenable_generation = generation - self._scope_reenable_provider_generation = ( - self.command_state_store.provider_generation - ) - logger.info( - "scope: re-enable confirmed after reconnect (%d handlers)", - len(self._scope_handlers), - ) - - def _scope_reenable_recorded_for(self, provider_generation: int) -> bool: - """Whether the once-per-session reconnect enable already fired for - THIS connection epoch. - - The bound is per viewing session AND per provider epoch: a genuinely - new connection (the backend's generation advance reached the shared - store's provider generation — the invalidation edge - ``_on_provider_generation`` already subscribes to) re-arms it, so a - second real outage while the same viewer stays connected still gets - its scope output restored; repeated refetches inside one epoch do - not. - """ - return ( - self._scope_reenable_generation == self._scope_demand_generation - and self._scope_reenable_provider_generation == provider_generation - ) - - def _cancel_scope_reenable_task(self) -> None: - """Cancel readiness work invalidated by a last-viewer transition.""" - task = self._scope_reenable_task - self._scope_reenable_task = None - if task is not None and not task.done(): - task.cancel() - - def _schedule_scope_enable_when_ready( - self, *, reason: str, generation: int - ) -> None: - """Schedule delayed scope enable once radio becomes ready.""" - if ( - self._scope_reenable_task is not None - and not self._scope_reenable_task.done() - ): - return - loop = asyncio.get_running_loop() - self._scope_reenable_task = loop.create_task( - self._wait_and_enable_scope(reason=reason, generation=generation), - name="scope-reenable-when-ready", - ) - - async def _wait_and_enable_scope(self, *, reason: str, generation: int) -> None: - """Wait until radio_ready before queuing EnableScope.""" - current_task = asyncio.current_task() - deadline = time.monotonic() + self._scope_reenable_timeout - try: - while True: - if ( - generation != self._scope_demand_generation - or not self._radio_scope_demand - or self._radio is None - ): - return - if not self._hardware_scope_available: - return - if self._scope_enable_failed: - return - if self._radio_ready(): - self._set_scope_data_callback( - self._radio_scope_source.feed_frame - if self._radio_scope_source - else None - ) - if await self._execute_scope_command( - EnableScope(generation=generation) - ): - self._scope_enabled = True - self._scope_started_at = asyncio.get_running_loop().time() - logger.info( - "scope: enable confirmed after %s (%d handlers)", - reason, - len(self._scope_handlers), - ) - return - if time.monotonic() >= deadline: - logger.warning( - "scope: radio not ready after %.0fs (%s), skipping re-enable", - self._scope_reenable_timeout, - reason, - ) - return - await asyncio.sleep(self._scope_reenable_poll_interval) - except asyncio.CancelledError: - pass - finally: - if self._scope_reenable_task is current_task: - self._scope_reenable_task = None + if self._radio_scope_session is not None: + await self._radio_scope_session.set_ready(True) def _scope_tx_known(self) -> bool: """Whether a transmit is known from managed intent or observed PTT.""" @@ -3597,11 +3357,6 @@ def _scope_tx_known(self) -> bool: return False _SCOPE_HIDDEN_PATH = FieldPath.parse("health.health.scope_hidden") - #: MOR-3690: the hidden verdict blocks the automatic reconnect re-enable - #: only while fresh. A verdict older than this may predate a radio - #: power-cycle (which also closes the menu) and no longer counts as - #: "known to be in the menu". - _SCOPE_HIDDEN_RECONNECT_GRACE = 300.0 def _scope_health_source(self) -> SourceMetadata: """Source qualifier for the scope-health state facts (MOR-3690).""" @@ -3632,46 +3387,13 @@ def _civ_link_flowing(self, now: float) -> bool: return False return now - float(last) <= self._scope_health_interval - def _scope_menu_guard_active(self, *, now: float) -> bool: - """Whether the radio is known to show its own menu (scope hidden). - - MOR-3690 (reopened): the ONE guard every automatic scope-enable - write must pass — no automatic path may kick the operator out of - the radio's menu. The explicit user-demand enable (a web client - opening the scope view) is deliberately not gated: it is the - operator's own request and the escape hatch for a stale verdict. - """ - if self._scope_hidden_published is not True: - return False - published_at = self._scope_hidden_at - if published_at is None: - return False - return now - published_at <= self._SCOPE_HIDDEN_RECONNECT_GRACE - def _publish_scope_hidden( self, hidden: bool, *, now: float, detail: str = "" ) -> None: - """Publish the scope-hidden verdict as a state fact, on change only. - - MOR-3690: ``True`` means the radio deliberately shows another - screen (front-panel menu): all-zero frames keep arriving, or — an - IC-705 over USB serial — no frames arrive at all while CI-V traffic - keeps flowing. ``False`` means non-zero frames flow again. No - verdict is published while the monitor cannot tell (CI-V silent - too, TX, link down, no viewers), so the last verdict never flaps. - """ + """Publish a change in the typed scope visibility facts.""" if self._scope_hidden_published is hidden: - # A repeated definite observation of the SAME verdict refreshes - # its evidence clock BEFORE the duplicate publication is - # suppressed: a menu held open past the reconnect grace window - # keeps re-confirming itself every monitor window, and the - # reconnect guard must keep reading that as fresh evidence — - # otherwise the still-open menu goes stale and the next - # reconnect ejects the operator from it. - self._scope_hidden_at = now return self._scope_hidden_published = hidden - self._scope_hidden_at = now self.command_state_store.apply_current( Observation( path=self._SCOPE_HIDDEN_PATH, @@ -3685,47 +3407,28 @@ def _publish_scope_hidden( ( "hidden on the radio (menu), not re-enabling" if hidden - else "visible again (non-zero frames flowing)" + else "scope frames present" ), f" ({detail})" if detail else "", ) async def _scope_health_monitor(self) -> None: - """Use typed hub arrival evidence; empty or OOR coverage is present.""" + """Publish typed session visibility facts.""" try: while True: await asyncio.sleep(self._scope_health_interval) - if ( - not self._radio_scope_demand - or self._radio is None - or not self._scope_enabled - ): + session = self._radio_scope_session + if session is None or self._radio is None: continue + await session.reconcile() now = asyncio.get_running_loop().time() - if not self._radio_ready() or self._scope_tx_known(): - self._scope_started_at = now - continue - if self._scope_hub is None or self._radio_scope_source is None: - continue - arrivals = [ - self._scope_hub.health("radio", receiver).last_frame_at - for receiver in self._radio_scope_source.capabilities().receivers - ] - last = max( - ( - self._scope_started_at, - *(value for value in arrivals if value is not None), - ) + fact = session.health( + civ_flowing=self._civ_link_flowing(now), + silence_seconds=self._scope_health_interval, + link_ready=self._radio_ready(), ) - idle = max(0.0, now - last) - if idle <= self._scope_health_interval: - self._publish_scope_hidden(False, now=now) - elif self._civ_link_flowing(now): - self._publish_scope_hidden( - True, - now=now, - detail=f"no scope frames for {idle:.0f}s, CI-V link healthy", - ) + if fact.hidden is not None: + self._publish_scope_hidden(fact.hidden, now=now) except asyncio.CancelledError: pass @@ -4066,12 +3769,8 @@ async def stop(self) -> None: from .web_startup import stop_web_server # noqa: TID251 self._stopping = True - if self._scope_command_futures: - self._scope_enable_failed = True - for future in tuple(self._scope_command_futures): - # The ordered consumer skips cancelled futures; stale scope - # writes must not run if this server is later restarted. - future.cancel() + if self._radio_scope_session is not None: + self._radio_scope_session.suspend() self._controller.revoke() unsubscribe = self._managed_tx_change_unsubscribe self._managed_tx_change_unsubscribe = None @@ -4081,20 +3780,7 @@ async def stop(self) -> None: self._unsubscribe_provider_generation() self._detach_audio_session_listener() self._detach_reconnect_status_listener() - cancelled = False - for handler in tuple(self._scope_handlers): - try: - await handler._unsubscribe() - except asyncio.CancelledError: - cancelled = True - except Exception: - logger.warning( - "scope shutdown cleanup failed; restoration remains pending", - exc_info=True, - ) await stop_web_server(self) - if cancelled: - raise asyncio.CancelledError async def serve_forever( self, diff --git a/src/rigplane/web/web_startup.py b/src/rigplane/web/web_startup.py index e22729f89..12a57bff1 100644 --- a/src/rigplane/web/web_startup.py +++ b/src/rigplane/web/web_startup.py @@ -1024,18 +1024,17 @@ async def _stop_web_server(server: WebServer) -> None: for task in ( server._zombie_reaper_task, server._scope_health_task, - server._scope_reenable_task, ): if task is not None: task.cancel() server._zombie_reaper_task = None server._scope_health_task = None - server._scope_reenable_task = None # 6. Cancel all background + client tasks first all_tasks = list(server._bg_tasks) + list(server._client_tasks) for task in all_tasks: - task.cancel() + if not task.cancelling(): + task.cancel() # 7. Close TCP listener (now that client tasks are cancelled, # wait_closed() won't block on open connections) @@ -1066,6 +1065,18 @@ async def _stop_web_server(server: WebServer) -> None: ) server._bg_tasks.clear() + scope_cancelled = False + for handler in tuple(server._scope_handlers): + try: + await handler._unsubscribe() + except asyncio.CancelledError: + scope_cancelled = True + except Exception: + logger.warning( + "scope shutdown cleanup failed; restoration remains pending", + exc_info=True, + ) + # 9. Now — and only now — the teardown unkeys those client tasks enqueued on # their way out are in the queue. Bounded, TX-safety only: this executes # pending PttOff entries and discards the rest (MOR-1181). The drain is @@ -1089,8 +1100,7 @@ async def _stop_web_server(server: WebServer) -> None: # incomplete path the flags/snapshot stay intact and the log records that # restoration remains pending. scope_restore_pending = bool( - getattr(server, "_scope_enabled", False) - or getattr(server, "_scope_enable_failed", False) + server._radio_scope_session is not None and server._radio_scope_session.active ) if scope_restore_pending: if radio_poller is None: @@ -1115,10 +1125,10 @@ async def _stop_web_server(server: WebServer) -> None: exc, ) else: - server._scope_enabled = False - server._scope_enable_failed = False logger.info("scope: pre-session state restored during shutdown") # Radio disconnect is handled by the caller's context manager # (async with radio: in _run). Do NOT disconnect here. logger.info("web server stopped") + if scope_cancelled: + raise asyncio.CancelledError From aabf9912f6c88df31f0ca661c21c83e4f3fa0921 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:55:38 -0500 Subject: [PATCH 26/62] test(MOR-4274): pin SDK lifecycle and real RX review regressions Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 138 +++++++++++++++++++++ tests/scope/test_radio_scope_source.py | 11 ++ tests/test_web_server_coverage.py | 19 +++ 3 files changed, 168 insertions(+) create mode 100644 tests/runtime/test_scope_session_review.py diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py new file mode 100644 index 000000000..2cc57e862 --- /dev/null +++ b/tests/runtime/test_scope_session_review.py @@ -0,0 +1,138 @@ +"""R5 review regressions: real Core RX, SDK and local WebSocket seams.""" +import asyncio +from contextlib import asynccontextmanager + +import aiohttp +import pytest + +from rigplane.commands import CONTROLLER_ADDR, build_civ_frame, parse_civ_frame +from rigplane.core.exceptions import CommandError, TimeoutError +from rigplane.radio import IcomRadio +from rigplane.scope.models import FollowView, Subscription, View +from rigplane.testing.timing import await_until +from rigplane.web.protocol import encode_scope_subscription +from rigplane.web.server import WebConfig, WebServer +from rigplane_testkit.icom.scope_control import ScopeControlSimulator + +pytestmark = [pytest.mark.realtime(reason="real local simulator and WebSocket seams"), pytest.mark.timeout(30)] + + +@pytest.fixture +async def sdk(): + sim = ScopeControlSimulator(scope_stream=True) + await sim.start() + radio = IcomRadio("127.0.0.1", port=sim.control_port, username="testuser", password="testpass", model="IC-7610") + await radio.connect() + try: + yield radio, sim + finally: + await radio.disconnect() + await sim.stop() + + +@asynccontextmanager +async def web(radio): + server = WebServer(radio, WebConfig(host="127.0.0.1", port=0)) + await server.start() + base = f"http://127.0.0.1:{server._server.sockets[0].getsockname()[1]}" + try: + async with aiohttp.ClientSession() as client: + response = await client.post(base + "/api/v1/controller", json={"kind": "browser"}) + key = (await response.json())["controller_key"] + yield server, client, base, (f"rigplane-controller-v1.{key}",) + finally: + await server.stop() + + +async def grant(ws, receiver=0, fps=20): + await ws.send_str(encode_scope_subscription(Subscription("radio", receiver, FollowView("vfo", 100000), 100, fps))) + while True: + message = await ws.receive(timeout=5) + if message.type is aiohttp.WSMsgType.TEXT: + assert '"error"' not in message.data + return + + +def ptt_reply(radio, on): + radio._update_state_cache_from_frame(parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0x1C, sub=0, data=bytes([on])))) + + +async def test_sdk_only_tx_off_drives_deferred_demand(sdk): + radio, sim = sdk + ptt_reply(radio, True) + await radio.enable_scope(policy="fast") + assert sim._scope_11 == 0 + ptt_reply(radio, False) + assert await await_until(lambda: sim._scope_11 == 1) + + +async def test_sdk_only_reconnect_reapplies_demand(sdk): + radio, sim = sdk + await radio.enable_scope(policy="fast") + sim._scope_11 = 0 + await radio.soft_reconnect() + assert await await_until(lambda: sim._scope_11 == 1) + + +async def test_sub_view_real_dispatch_preserves_main_sdk_selection(sdk): + radio, sim = sdk + async with web(radio) as (_, client, base, protocols): + async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: + await grant(ws, receiver=1) + assert radio._scope_controls().receiver == 0 + await radio.set_scope_span(4) + assert sim.views[0][1] == 4 + assert sim.views[1][1] == 5 + + +async def test_sdk_frames_stay_visible_when_web_pauses(sdk): + radio, _ = sdk + await radio.enable_scope(policy="verify") + async with web(radio) as (_, client, base, protocols): + async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: + await grant(ws) + await grant(ws, fps=0) + arrivals = asyncio.Queue() + radio._scope_stream_callbacks.append(arrivals.put_nowait) + try: + for _ in range(3): + await asyncio.wait_for(arrivals.get(), 5) + assert radio.scope_session().health(civ_flowing=True, silence_seconds=0.05).hidden is False + finally: + radio._scope_stream_callbacks.remove(arrivals.put_nowait) + + +async def test_control_ws_reapplies_span_after_real_front_panel_readback(sdk): + radio, sim = sdk + await radio.set_scope_span(3) + sim.views[0] = (0, 4, 1) + assert await radio.get_scope_span() == 4 + async with web(radio) as (_, client, base, protocols): + async with client.ws_connect(base + "/api/v1/ws", protocols=protocols) as ws: + await ws.send_json({"type": "cmd", "id": "span", "name": "set_scope_span", "params": {"span": 3}}) + assert await await_until(lambda: sim.views[0][1] == 3) + + +async def test_uncertain_write_cannot_suppress_the_previous_acknowledged_value(sdk, monkeypatch): + radio, sim = sdk + await radio.set_scope_mode(1) + send = radio._send_civ_raw + async def lost_ack(payload, **kwargs): + response = await send(payload, **kwargs) + frame = parse_civ_frame(payload) + if frame.command == 0x27 and frame.sub == 0x14 and frame.data == b"\x00\x02": + raise CommandError("lost ACK") + return response + monkeypatch.setattr(radio, "_send_civ_raw", lost_ack) + with pytest.raises(CommandError, match="lost ACK"): + await radio.set_scope_mode(2) + await radio.set_scope_mode(1) + assert sim.views[0][0] == 1 + + +async def test_verify_requires_new_activity_after_a_previous_stream(sdk): + radio, sim = sdk + await radio.enable_scope(policy="verify") + sim._scope_stream = False + with pytest.raises(TimeoutError, match="verification"): + await radio.enable_scope(policy="verify", timeout=0.1) diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index 14c3621e8..2574c9b11 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -226,3 +226,14 @@ async def set_fixed(self, edges, receiver): (1, 0), (2, 1), ] + + +async def test_off_band_fixed_request_uses_center_uncovered_fallback(): + class FixedWriter(Writer): + async def set_fixed(self, edges, receiver): + self.writes.append((edges, receiver)) + writer = FixedWriter() + source = RadioScopeSource(load_rig(Path("rigs/ic7610.toml")).to_profile(), writer) + grant = await source.set_view(View(7_050_000, 7_150_000), 0) + assert grant.coverage == Coverage(13_850_000, 14_350_000) + assert writer.writes[-1] == (14_100_000, 500000, 0) diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index 704521c9c..ca9dfed23 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -5519,3 +5519,22 @@ async def test_health_snapshot_cannot_delay_fresh_scope_resume( await session.set_output(True) assert session.enabled assert virtual_clock() - started <= 0.1 + + +async def test_scope_monitor_survives_pending_restore_failure(virtual_clock, monkeypatch): + from rigplane.core.exceptions import CommandError + srv, radio, _ = await _virtual_scope_server(monkeypatch, virtual_clock) + session = srv._radio_scope_session + radio.disable_scope.side_effect = CommandError("external CAT owns wire") + with pytest.raises(CommandError): + await session.set_output(False) + monitor = asyncio.create_task(srv._scope_health_monitor()) + try: + await _elapse_scope_windows(virtual_clock, 1) + assert not monitor.done() + radio.disable_scope.side_effect = None + await _elapse_scope_windows(virtual_clock, 1) + assert not session.restoring + finally: + monitor.cancel() + await asyncio.gather(monitor, return_exceptions=True) From a27e4294ecb796c8174698b474b86ce54399d0ca Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 20:57:55 -0500 Subject: [PATCH 27/62] test(MOR-4274): exercise the pending output restoration failure Co-Authored-By: Claude Opus 5.5 --- tests/test_web_server_coverage.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index ca9dfed23..5780f5a56 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -5525,14 +5525,15 @@ async def test_scope_monitor_survives_pending_restore_failure(virtual_clock, mon from rigplane.core.exceptions import CommandError srv, radio, _ = await _virtual_scope_server(monkeypatch, virtual_clock) session = srv._radio_scope_session - radio.disable_scope.side_effect = CommandError("external CAT owns wire") + await session.set_output(False, release=False) + radio.enable_scope.side_effect = CommandError("external CAT owns wire") with pytest.raises(CommandError): await session.set_output(False) monitor = asyncio.create_task(srv._scope_health_monitor()) try: await _elapse_scope_windows(virtual_clock, 1) assert not monitor.done() - radio.disable_scope.side_effect = None + radio.enable_scope.side_effect = None await _elapse_scope_windows(virtual_clock, 1) assert not session.restoring finally: From 35a6eb1800981656c10c3296da64e74df337f444 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:01:22 -0500 Subject: [PATCH 28/62] test(MOR-4274): activate controller before scope and provide fresh VERIFY activity Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 61 ++++++++++++++++++---- tests/runtime/test_scope_session_wire.py | 2 + 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 2cc57e862..c780946e2 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -1,4 +1,5 @@ """R5 review regressions: real Core RX, SDK and local WebSocket seams.""" + import asyncio from contextlib import asynccontextmanager @@ -8,20 +9,29 @@ from rigplane.commands import CONTROLLER_ADDR, build_civ_frame, parse_civ_frame from rigplane.core.exceptions import CommandError, TimeoutError from rigplane.radio import IcomRadio -from rigplane.scope.models import FollowView, Subscription, View +from rigplane.scope.models import FollowView, Subscription from rigplane.testing.timing import await_until from rigplane.web.protocol import encode_scope_subscription from rigplane.web.server import WebConfig, WebServer from rigplane_testkit.icom.scope_control import ScopeControlSimulator -pytestmark = [pytest.mark.realtime(reason="real local simulator and WebSocket seams"), pytest.mark.timeout(30)] +pytestmark = [ + pytest.mark.realtime(reason="real local simulator and WebSocket seams"), + pytest.mark.timeout(30), +] @pytest.fixture async def sdk(): sim = ScopeControlSimulator(scope_stream=True) await sim.start() - radio = IcomRadio("127.0.0.1", port=sim.control_port, username="testuser", password="testpass", model="IC-7610") + radio = IcomRadio( + "127.0.0.1", + port=sim.control_port, + username="testuser", + password="testpass", + model="IC-7610", + ) await radio.connect() try: yield radio, sim @@ -37,15 +47,24 @@ async def web(radio): base = f"http://127.0.0.1:{server._server.sockets[0].getsockname()[1]}" try: async with aiohttp.ClientSession() as client: - response = await client.post(base + "/api/v1/controller", json={"kind": "browser"}) + response = await client.post( + base + "/api/v1/controller", json={"kind": "browser"} + ) key = (await response.json())["controller_key"] - yield server, client, base, (f"rigplane-controller-v1.{key}",) + protocols = (f"rigplane-controller-v1.{key}",) + async with client.ws_connect(base + "/api/v1/ws", protocols=protocols) as control: + await control.send_json({"type": "subscribe", "streams": ["state"]}) + yield server, client, base, protocols finally: await server.stop() async def grant(ws, receiver=0, fps=20): - await ws.send_str(encode_scope_subscription(Subscription("radio", receiver, FollowView("vfo", 100000), 100, fps))) + await ws.send_str( + encode_scope_subscription( + Subscription("radio", receiver, FollowView("vfo", 100000), 100, fps) + ) + ) while True: message = await ws.receive(timeout=5) if message.type is aiohttp.WSMsgType.TEXT: @@ -54,7 +73,13 @@ async def grant(ws, receiver=0, fps=20): def ptt_reply(radio, on): - radio._update_state_cache_from_frame(parse_civ_frame(build_civ_frame(CONTROLLER_ADDR, radio._radio_addr, 0x1C, sub=0, data=bytes([on])))) + radio._update_state_cache_from_frame( + parse_civ_frame( + build_civ_frame( + CONTROLLER_ADDR, radio._radio_addr, 0x1C, sub=0, data=bytes([on]) + ) + ) + ) async def test_sdk_only_tx_off_drives_deferred_demand(sdk): @@ -97,7 +122,12 @@ async def test_sdk_frames_stay_visible_when_web_pauses(sdk): try: for _ in range(3): await asyncio.wait_for(arrivals.get(), 5) - assert radio.scope_session().health(civ_flowing=True, silence_seconds=0.05).hidden is False + assert ( + radio.scope_session() + .health(civ_flowing=True, silence_seconds=0.05) + .hidden + is False + ) finally: radio._scope_stream_callbacks.remove(arrivals.put_nowait) @@ -109,20 +139,31 @@ async def test_control_ws_reapplies_span_after_real_front_panel_readback(sdk): assert await radio.get_scope_span() == 4 async with web(radio) as (_, client, base, protocols): async with client.ws_connect(base + "/api/v1/ws", protocols=protocols) as ws: - await ws.send_json({"type": "cmd", "id": "span", "name": "set_scope_span", "params": {"span": 3}}) + await ws.send_json( + { + "type": "cmd", + "id": "span", + "name": "set_scope_span", + "params": {"span": 3}, + } + ) assert await await_until(lambda: sim.views[0][1] == 3) -async def test_uncertain_write_cannot_suppress_the_previous_acknowledged_value(sdk, monkeypatch): +async def test_uncertain_write_cannot_suppress_the_previous_acknowledged_value( + sdk, monkeypatch +): radio, sim = sdk await radio.set_scope_mode(1) send = radio._send_civ_raw + async def lost_ack(payload, **kwargs): response = await send(payload, **kwargs) frame = parse_civ_frame(payload) if frame.command == 0x27 and frame.sub == 0x14 and frame.data == b"\x00\x02": raise CommandError("lost ACK") return response + monkeypatch.setattr(radio, "_send_civ_raw", lost_ack) with pytest.raises(CommandError, match="lost ACK"): await radio.set_scope_mode(2) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 56389479c..1dbd20467 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -116,6 +116,8 @@ async def expect(payload, **kwargs): monkeypatch.setattr(radio, "_check_connected", lambda: None) monkeypatch.setattr(radio, "_send_civ_expect", expect) await radio.enable_scope(policy="verify") + import asyncio + asyncio.get_running_loop().call_soon(radio._scope_activity_event.set) await radio.enable_scope(policy="verify") assert writes == [(0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x01")] await radio.disable_scope() From f53ff7c3569712a51a698b05182544227c29bd28 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:03:29 -0500 Subject: [PATCH 29/62] test(MOR-4274): exercise a real SDK reconnect and reuse its controller socket Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 24 ++++++++-------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index c780946e2..45360d125 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -54,7 +54,7 @@ async def web(radio): protocols = (f"rigplane-controller-v1.{key}",) async with client.ws_connect(base + "/api/v1/ws", protocols=protocols) as control: await control.send_json({"type": "subscribe", "streams": ["state"]}) - yield server, client, base, protocols + yield server, client, base, protocols, control finally: await server.stop() @@ -94,14 +94,14 @@ async def test_sdk_only_tx_off_drives_deferred_demand(sdk): async def test_sdk_only_reconnect_reapplies_demand(sdk): radio, sim = sdk await radio.enable_scope(policy="fast") - sim._scope_11 = 0 - await radio.soft_reconnect() + await radio.disconnect() + await radio.connect() assert await await_until(lambda: sim._scope_11 == 1) async def test_sub_view_real_dispatch_preserves_main_sdk_selection(sdk): radio, sim = sdk - async with web(radio) as (_, client, base, protocols): + async with web(radio) as (_, client, base, protocols, _): async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: await grant(ws, receiver=1) assert radio._scope_controls().receiver == 0 @@ -113,7 +113,7 @@ async def test_sub_view_real_dispatch_preserves_main_sdk_selection(sdk): async def test_sdk_frames_stay_visible_when_web_pauses(sdk): radio, _ = sdk await radio.enable_scope(policy="verify") - async with web(radio) as (_, client, base, protocols): + async with web(radio) as (_, client, base, protocols, _): async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: await grant(ws) await grant(ws, fps=0) @@ -137,17 +137,9 @@ async def test_control_ws_reapplies_span_after_real_front_panel_readback(sdk): await radio.set_scope_span(3) sim.views[0] = (0, 4, 1) assert await radio.get_scope_span() == 4 - async with web(radio) as (_, client, base, protocols): - async with client.ws_connect(base + "/api/v1/ws", protocols=protocols) as ws: - await ws.send_json( - { - "type": "cmd", - "id": "span", - "name": "set_scope_span", - "params": {"span": 3}, - } - ) - assert await await_until(lambda: sim.views[0][1] == 3) + async with web(radio) as (_, _, _, _, ws): + await ws.send_json({"type": "cmd", "id": "span", "name": "set_scope_span", "params": {"span": 3}}) + assert await await_until(lambda: sim.views[0][1] == 3) async def test_uncertain_write_cannot_suppress_the_previous_acknowledged_value( From bd92997a37378a5f6b766efa2183831d0a374a8f Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:05:53 -0500 Subject: [PATCH 30/62] test(MOR-4274): cover shared fixed-slot readback and format review regressions Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session.py | 10 ++++++++++ tests/runtime/test_scope_session_review.py | 13 +++++++++++-- tests/runtime/test_scope_session_wire.py | 1 + tests/scope/test_radio_scope_source.py | 1 + tests/test_web_server_coverage.py | 5 ++++- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py index b1564899b..b9fa3b5e4 100644 --- a/tests/runtime/test_scope_session.py +++ b/tests/runtime/test_scope_session.py @@ -390,3 +390,13 @@ async def test_matching_fixed_slot_is_not_programmed_or_restored_again(): assert writer.views == [] await session.set_output(False) assert writer.views == [] + + +async def test_fixed_readback_updates_the_shared_slot_across_receivers(): + writer = Writer() + session = ScopeSession(writer.read, writer.write, ready=True) + desired = ScopeFixedEdge(6, 1, 14000000, 14350000) + await session.configure(receiver=1, fixed_edge=desired) + session.observe("fixed_edge", ScopeFixedEdge(6, 1, 14100000, 14300000), 0) + await session.configure(receiver=1, fixed_edge=desired) + assert writer.views == [("fixed_edge", desired, 1), ("fixed_edge", desired, 1)] diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 45360d125..05bec1222 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -52,7 +52,9 @@ async def web(radio): ) key = (await response.json())["controller_key"] protocols = (f"rigplane-controller-v1.{key}",) - async with client.ws_connect(base + "/api/v1/ws", protocols=protocols) as control: + async with client.ws_connect( + base + "/api/v1/ws", protocols=protocols + ) as control: await control.send_json({"type": "subscribe", "streams": ["state"]}) yield server, client, base, protocols, control finally: @@ -138,7 +140,14 @@ async def test_control_ws_reapplies_span_after_real_front_panel_readback(sdk): sim.views[0] = (0, 4, 1) assert await radio.get_scope_span() == 4 async with web(radio) as (_, _, _, _, ws): - await ws.send_json({"type": "cmd", "id": "span", "name": "set_scope_span", "params": {"span": 3}}) + await ws.send_json( + { + "type": "cmd", + "id": "span", + "name": "set_scope_span", + "params": {"span": 3}, + } + ) assert await await_until(lambda: sim.views[0][1] == 3) diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py index 1dbd20467..6c16cd8f8 100644 --- a/tests/runtime/test_scope_session_wire.py +++ b/tests/runtime/test_scope_session_wire.py @@ -117,6 +117,7 @@ async def expect(payload, **kwargs): monkeypatch.setattr(radio, "_send_civ_expect", expect) await radio.enable_scope(policy="verify") import asyncio + asyncio.get_running_loop().call_soon(radio._scope_activity_event.set) await radio.enable_scope(policy="verify") assert writes == [(0x27, 0x10, b"\x01"), (0x27, 0x11, b"\x01")] diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index 2574c9b11..109320c87 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -232,6 +232,7 @@ async def test_off_band_fixed_request_uses_center_uncovered_fallback(): class FixedWriter(Writer): async def set_fixed(self, edges, receiver): self.writes.append((edges, receiver)) + writer = FixedWriter() source = RadioScopeSource(load_rig(Path("rigs/ic7610.toml")).to_profile(), writer) grant = await source.set_view(View(7_050_000, 7_150_000), 0) diff --git a/tests/test_web_server_coverage.py b/tests/test_web_server_coverage.py index 5780f5a56..666bb606e 100644 --- a/tests/test_web_server_coverage.py +++ b/tests/test_web_server_coverage.py @@ -5521,8 +5521,11 @@ async def test_health_snapshot_cannot_delay_fresh_scope_resume( assert virtual_clock() - started <= 0.1 -async def test_scope_monitor_survives_pending_restore_failure(virtual_clock, monkeypatch): +async def test_scope_monitor_survives_pending_restore_failure( + virtual_clock, monkeypatch +): from rigplane.core.exceptions import CommandError + srv, radio, _ = await _virtual_scope_server(monkeypatch, virtual_clock) session = srv._radio_scope_session await session.set_output(False, release=False) From 0977b8dc836416aed1cf5102c5443aaa6be591bd Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:19:57 -0500 Subject: [PATCH 31/62] test(MOR-4274): follow selected receiver and Core frame publication contracts Co-Authored-By: Claude Opus 5.5 --- tests/test_civ_rx_coverage.py | 27 ++++++++++++------- .../test_simulator_blank_scope_mor3690.py | 6 ++++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/test_civ_rx_coverage.py b/tests/test_civ_rx_coverage.py index 904d09291..eb1296d24 100644 --- a/tests/test_civ_rx_coverage.py +++ b/tests/test_civ_rx_coverage.py @@ -5601,15 +5601,15 @@ def test_update_radio_state_advanced_scope_family( for frame in ( _make_frame(cmd=0x27, sub=0x12, data=b"\x01"), _make_frame(cmd=0x27, sub=0x13, data=b"\x01"), - _make_frame(cmd=0x27, sub=0x14, data=b"\x00\x03"), - _make_frame(cmd=0x27, sub=0x15, data=b"\x00" + bcd_encode(250_000)), - _make_frame(cmd=0x27, sub=0x16, data=b"\x00\x04"), - _make_frame(cmd=0x27, sub=0x17, data=b"\x00\x01"), - _make_frame(cmd=0x27, sub=0x19, data=b"\x00\x10\x50\x01"), - _make_frame(cmd=0x27, sub=0x1A, data=b"\x00\x02"), + _make_frame(cmd=0x27, sub=0x14, data=b"\x01\x03"), + _make_frame(cmd=0x27, sub=0x15, data=b"\x01" + bcd_encode(250_000)), + _make_frame(cmd=0x27, sub=0x16, data=b"\x01\x04"), + _make_frame(cmd=0x27, sub=0x17, data=b"\x01\x01"), + _make_frame(cmd=0x27, sub=0x19, data=b"\x01\x10\x50\x01"), + _make_frame(cmd=0x27, sub=0x1A, data=b"\x01\x02"), _make_frame(cmd=0x27, sub=0x1B, data=b"\x01"), - _make_frame(cmd=0x27, sub=0x1C, data=b"\x00\x02"), - _make_frame(cmd=0x27, sub=0x1D, data=b"\x00\x01"), + _make_frame(cmd=0x27, sub=0x1C, data=b"\x01\x02"), + _make_frame(cmd=0x27, sub=0x1D, data=b"\x01\x01"), _make_frame( cmd=0x27, sub=0x1E, @@ -5751,6 +5751,9 @@ def test_scope_control_observation_backed( expected: dict[str, object], ) -> None: """MOR-557: 0x27 scope-control responses emit StateStore observations.""" + radio._civ_runtime._update_state_cache_from_frame( + _make_frame(cmd=0x27, sub=0x12, data=b"\x00") + ) radio._civ_runtime._update_state_cache_from_frame(frame) snapshot = radio._state_store.snapshot() @@ -5826,8 +5829,8 @@ async def test_scope_waveform_fixed_mode_emits_no_span_edge_or_fixed_edge( republishes nothing beyond ``mode`` itself. ``edge`` has no frame-carried source at all. ``fixed_edge`` has no declared table of legal (start_hz, end_hz) pairs to match a frame's edges against - (``_SCOPE_FIXED_EDGE_RANGE_STARTS_HZ`` maps a start_hz to a band, not - to a specific stored preset) — MOR-2256 rules out guessing which + (``RadioProfile.scope_fixed_edge_ranges`` describes bands, not + specific stored presets) — MOR-2256 rules out guessing which preset is active. ``span`` is center-mode-only by construction (``_publish_scope_span_observation`` returns immediately when ``scope_frame.mode != 0``), so a fixed-mode frame never reaches its @@ -6069,6 +6072,10 @@ def test_scope_control_observations_project_public(radio: IcomRadio) -> None: ) for frame, _expected in _SCOPE_CONTROL_OBSERVATION_CASES: + if frame.sub == 0x14: + radio._civ_runtime._update_state_cache_from_frame( + _make_frame(cmd=0x27, sub=0x12, data=b"\x00") + ) radio._civ_runtime._update_state_cache_from_frame(frame) payload = build_public_state_payload_from_snapshot( diff --git a/tests/testkit/test_simulator_blank_scope_mor3690.py b/tests/testkit/test_simulator_blank_scope_mor3690.py index 1187ab352..08c3933a1 100644 --- a/tests/testkit/test_simulator_blank_scope_mor3690.py +++ b/tests/testkit/test_simulator_blank_scope_mor3690.py @@ -118,10 +118,14 @@ async def test_watchdog_ignores_simulator_blank_stream( queued.clear() source = srv._radio_scope_source assert source is not None + from rigplane.radio import IcomRadio + publisher = IcomRadio("127.0.0.1", model="IC-7610") + publisher._control_scope_session = srv._radio_scope_session + publisher.on_scope_data(source.feed_frame) monitor = asyncio.create_task(srv._scope_health_monitor()) try: for index in range(6): - source.feed_frame(frames[index % len(frames)]) + publisher._civ_runtime._publish_scope_frame(frames[index % len(frames)]) await clock.elapse(srv._scope_health_interval / 2) finally: monitor.cancel() From fced7a74b4d8c1ada4e21fc7441cfcdd08e29214 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:21:30 -0500 Subject: [PATCH 32/62] fix(MOR-4274): reconcile Core lifecycle and receiver-qualified observations Drive the shared session from Core readiness, connection generations and observed TX release. Keep receiver selection independent of addressed scope replies, publish session frame arrivals without web demand, and reconcile readback/uncertain state before suppressing writes. Require fresh VERIFY activity, constrain FIXED to the tuned range, and retain the existing monitor after recoverable failures. Co-Authored-By: Claude Opus 5.5 --- docs/api/scope.md | 4 +- src/rigplane/backends/_icom_serial_base.py | 3 + src/rigplane/runtime/_civ_rx.py | 75 +++++++++---- src/rigplane/runtime/_control_phase.py | 3 + src/rigplane/runtime/_scope_runtime.py | 15 ++- src/rigplane/runtime/radio.py | 1 + src/rigplane/runtime/radio_scope_source.py | 5 +- src/rigplane/runtime/scope_session.py | 102 +++++++++++++----- src/rigplane/web/server.py | 7 +- tests/test_profile_command_binding.py | 2 - .../test_simulator_blank_scope_mor3690.py | 1 + 11 files changed, 161 insertions(+), 57 deletions(-) diff --git a/docs/api/scope.md b/docs/api/scope.md index 923728ed1..7cc563157 100644 --- a/docs/api/scope.md +++ b/docs/api/scope.md @@ -301,4 +301,6 @@ that data negotiate CENTER views. Hardware scope providers used by the web server must expose `src/rigplane/runtime/scope_session.py: ScopeSessionProvider.scope_session`; -this replaces the web-owned writer adapter. +this replaces the web-owned writer adapter. Providers must feed frame arrival, +readiness, connection changes and TX observations into that session even when +web subscriptions are paused; Core does this from its RX and lifecycle paths. diff --git a/src/rigplane/backends/_icom_serial_base.py b/src/rigplane/backends/_icom_serial_base.py index 94b0f4e9e..b7f70188f 100644 --- a/src/rigplane/backends/_icom_serial_base.py +++ b/src/rigplane/backends/_icom_serial_base.py @@ -612,6 +612,7 @@ async def connect(self) -> None: and self._civ_transport is not None ): await self._arm_managed_tx() + await self._scope_session_ready() # MOR-3436: an owner connect re-establishes session audio demand # that survived its teardown, unless the resident reconnect loop # — which recovers from its own snapshot — is the caller. @@ -821,6 +822,7 @@ async def soft_reconnect(self) -> None: and self._civ_transport is not None ): await self.rearm_managed_tx() + await self._scope_session_ready() if self._on_reconnect is not None: try: self._on_reconnect() @@ -1361,6 +1363,7 @@ async def _serial_identity_reread_loop(self) -> None: ): try: await self._arm_managed_tx() + await self._scope_session_ready() except Exception: # MOR-3071 round 4: ``connect()`` lets an # ``_arm_managed_tx`` failure propagate to its diff --git a/src/rigplane/runtime/_civ_rx.py b/src/rigplane/runtime/_civ_rx.py index 3f3a5dd18..1dddf9351 100644 --- a/src/rigplane/runtime/_civ_rx.py +++ b/src/rigplane/runtime/_civ_rx.py @@ -8,7 +8,7 @@ from collections.abc import Callable from dataclasses import dataclass from dataclasses import replace as _replace_dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from typing import Literal from rigplane.core.acquisition_scheduler import ( @@ -96,6 +96,7 @@ bcd_decode, ctcss_booleans_for_tone_squelch_type, ) +from rigplane.runtime.scope_session import ScopeSession, ScopeSetting from rigplane.runtime._connection_state import RadioConnectionState from rigplane.runtime._dual_rx_runtime import _relative_vfo_routing_available from rigplane.runtime import radio_reconnect @@ -1186,6 +1187,11 @@ def advance_generation(self, reason: str) -> None: ConnectionError(f"CI-V generation advanced: {reason}") ) self._host._state_store.begin_provider_generation() + session = getattr(self._host, "_control_scope_session", None) + if isinstance(session, ScopeSession): + session.invalidate_now( + connection_epoch=self._host._state_store.provider_generation + ) # A new generation may face a different selection: unknown until # the radio answers 0x07 0xD2 again (MOR-3591, fail closed). self._host._forget_selection() @@ -3468,8 +3474,16 @@ def _scope_control_observations(self, frame: CivFrame) -> list[Observation]: elif frame.sub == 0x1F: receiver, rbw = parse_scope_rbw_response(frame) pairs.append(("rbw", rbw)) - if receiver is not None: - pairs.append(("receiver", receiver)) + session = getattr(self._host, "_control_scope_session", None) + if isinstance(session, ScopeSession): + for name, value in pairs: + if name in ("mode", "span", "edge", "fixed_edge"): + session.observe(cast(ScopeSetting, name), value, receiver or 0) + if ( + receiver is not None + and receiver != self._host._radio_state.scope_controls.receiver + ): + return [] return [ self._observation( FieldPath.scope_control("display", name), value, frame=frame @@ -3997,6 +4011,12 @@ def _handle_1c( if frame.sub == 0x00: if frame.data: rs.ptt = bool(frame.data[0]) + session = getattr(self._host, "_control_scope_session", None) + if isinstance(session, ScopeSession) and frame.data in ( + b"\x00", + b"\x01", + ): + session.notify_tx(rs.ptt) def _handle_1e( self, @@ -4111,55 +4131,55 @@ def _handle_27( scope.dual = parse_scope_single_dual_response(frame) elif frame.sub == 0x14: receiver, mode = parse_scope_mode_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.mode = mode elif frame.sub == 0x15: receiver, span = parse_scope_span_response( frame, self._host._profile.scope_span_presets_hz ) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.span = span elif frame.sub == 0x16: receiver, edge = parse_scope_edge_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.edge = edge elif frame.sub == 0x17: receiver, hold = parse_scope_hold_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.hold = hold elif frame.sub == 0x19: receiver, ref_db = parse_scope_ref_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.ref_db = ref_db elif frame.sub == 0x1A: receiver, speed = parse_scope_speed_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.speed = speed elif frame.sub == 0x1B: scope.during_tx = parse_scope_during_tx_response(frame) elif frame.sub == 0x1C: receiver, center_type = parse_scope_center_type_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.center_type = center_type elif frame.sub == 0x1D: receiver, vbw_narrow = parse_scope_vbw_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.vbw_narrow = vbw_narrow elif frame.sub == 0x1E: scope.fixed_edge = parse_scope_fixed_edge_response(frame) scope.edge = scope.fixed_edge.edge elif frame.sub == 0x1F: receiver, rbw = parse_scope_rbw_response(frame) - if receiver is not None: - scope.receiver = receiver + if receiver is not None and receiver != scope.receiver: + return scope.rbw = rbw # Top-level dispatch table for ``_update_radio_state_from_frame``. @@ -4269,6 +4289,11 @@ def _publish_scope_mode_observation( """ if not 0 <= scope_frame.mode <= 3: return + session = getattr(self._host, "_control_scope_session", None) + if isinstance(session, ScopeSession): + session.observe("mode", scope_frame.mode, receiver) + if receiver != self._host._radio_state.scope_controls.receiver: + return last_modes = self._host._scope_stream_last_mode if last_modes.get(receiver) == scope_frame.mode: return @@ -4352,6 +4377,11 @@ def _publish_scope_span_observation( span = _span_index_for_hz(span_hz, presets) if span is None: return + session = getattr(self._host, "_control_scope_session", None) + if isinstance(session, ScopeSession): + session.observe("span", span, receiver) + if receiver != self._host._radio_state.scope_controls.receiver: + return last_spans = self._host._scope_stream_last_span if last_spans.get(receiver) == span: return @@ -4372,6 +4402,9 @@ def _publish_scope_span_observation( def _publish_scope_frame(self, frame: ScopeFrame) -> None: """Publish a frame to the registered callback and SDK adapters.""" + session = getattr(self._host, "_control_scope_session", None) + if isinstance(session, ScopeSession): + session.on_frame(frame) callback = self._host._scope_callback if callback is not None: try: diff --git a/src/rigplane/runtime/_control_phase.py b/src/rigplane/runtime/_control_phase.py index 2520b66cd..60dd67b76 100644 --- a/src/rigplane/runtime/_control_phase.py +++ b/src/rigplane/runtime/_control_phase.py @@ -1117,6 +1117,9 @@ async def _after_reconnect_parts( "soft_reconnect: managed TX re-arm failed", exc_info=True ) + scope_ready = getattr(h, "_scope_session_ready", None) + if scope_ready is not None: + await scope_ready() on_reconnect = getattr(h, "_on_reconnect", None) if on_reconnect is not None: try: diff --git a/src/rigplane/runtime/_scope_runtime.py b/src/rigplane/runtime/_scope_runtime.py index 72aaea7ee..8962fedf8 100644 --- a/src/rigplane/runtime/_scope_runtime.py +++ b/src/rigplane/runtime/_scope_runtime.py @@ -146,6 +146,17 @@ def scope_session(self) -> ScopeSession: self._control_scope_session = session return session + async def _scope_session_ready(self) -> None: + session = getattr(self, "_control_scope_session", None) + if isinstance(session, ScopeSession): + try: + await session.set_ready(True) + except Exception: + logger.warning( + "scope reconciliation remains pending after readiness", + exc_info=True, + ) + async def _read_scope_receiver(self, receiver: int) -> ScopeState: _, mode = await self._read_scope_view_setting("mode", receiver) _, span = await self._read_scope_view_setting("span", receiver) @@ -228,7 +239,7 @@ async def _write_scope_setting( if name == "fixed_edge": assert isinstance(value, ScopeFixedEdge) controls.fixed_edge = value - elif name not in ("scope_on", "output_on"): + elif name not in ("scope_on", "output_on") and receiver == controls.receiver: setattr(controls, name, value) async def enable_scope( @@ -241,7 +252,7 @@ async def enable_scope( """Acquire SDK scope demand through the shared acknowledged session.""" self._check_connected() pol = ScopeCompletionPolicy(policy) - if not self.scope_session().enabled: + if pol == ScopeCompletionPolicy.VERIFY or not self.scope_session().enabled: self._scope_activity_event.clear() await self.scope_session().set_demand(ScopeDemand(1, int(output)), owner="sdk") if not output: diff --git a/src/rigplane/runtime/radio.py b/src/rigplane/runtime/radio.py index 13bdef8b4..2dc7edadd 100644 --- a/src/rigplane/runtime/radio.py +++ b/src/rigplane/runtime/radio.py @@ -2479,6 +2479,7 @@ async def _finish_lan_identity_completion(self) -> None: if self._civ_epoch != epoch: return await self._arm_managed_tx() + await self._scope_session_ready() await self._run_hard_reconnect_deferred_audio(epoch) async def _recover_audio_after_connect( diff --git a/src/rigplane/runtime/radio_scope_source.py b/src/rigplane/runtime/radio_scope_source.py index da4531c15..1b4d5f489 100644 --- a/src/rigplane/runtime/radio_scope_source.py +++ b/src/rigplane/runtime/radio_scope_source.py @@ -24,7 +24,6 @@ class RadioScopeWriter(Protocol): def tuned_frequency(self, receiver: int) -> float: ... async def set_output(self, enabled: bool, *, release: bool = True) -> None: ... - def on_frame(self, frame: ScopeFrame) -> None: ... async def set_fixed(self, edges: ScopeFixedEdge, receiver: int) -> None: ... async def set_center( self, center_hz: float, span_hz: float, receiver: int @@ -96,7 +95,10 @@ def coverage_for(self, view: ViewRequest, receiver: int | None = None) -> Covera def _fixed_edges(self, view: View, receiver: int) -> ScopeFixedEdge | None: if self._fixed_step is None: return None + tuned = self._writer.tuned_frequency(receiver) for limits in self._fixed_ranges: + if not limits.start_hz <= tuned < limits.end_hz: + continue start = math.floor(view.start_hz / self._fixed_step) * self._fixed_step end = math.ceil(view.end_hz / self._fixed_step) * self._fixed_step if limits.start_hz <= start < end <= limits.end_hz: @@ -150,7 +152,6 @@ def unsubscribe() -> None: def feed_frame(self, frame: ScopeFrame) -> None: if not self._active: return - self._writer.on_frame(frame) receiver = self._receiver(frame.receiver) native_width = self._native_widths.get(receiver) if frame.pixels and not frame.out_of_range: diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index 3a9cdc530..f073e4c63 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, replace @@ -12,6 +13,8 @@ from rigplane.scope import ScopeFrame from rigplane.scope.models import ScopeDemand +logger = logging.getLogger(__name__) + ScopeSetting: TypeAlias = Literal[ "scope_on", "output_on", "mode", "span", "edge", "fixed_edge" ] @@ -60,7 +63,7 @@ def __init__( self.tx_probe = tx_probe self._lock = asyncio.Lock() self._ready = ready - self._tx = False + self._tx = bool(tx_probe and tx_probe()) self._demands: dict[str, ScopeDemand] = {} self._clock = clock self._grace = disable_grace_seconds @@ -84,6 +87,7 @@ def __init__( self._applied: dict[tuple[int, ScopeSetting], ScopeValue] = {} self._uncertain: set[tuple[int, ScopeSetting]] = set() self._restore_pending = False + self._event_tasks: set[asyncio.Task[None]] = set() @property def enabled(self) -> bool: @@ -134,19 +138,63 @@ async def set_tx(self, active: bool) -> None: self._tx = active await self._reconcile() + def invalidate_now(self, *, connection_epoch: int | None = None) -> None: + if connection_epoch is not None and connection_epoch == self._connection_epoch: + return + self._connection_epoch = connection_epoch + self.generation += 1 + if not self.menu_guard_active(): + self.observed = None + self._applied.clear() + self._ready = False + async def invalidate(self, *, connection_epoch: int | None = None) -> None: async with self._lock: - if ( - connection_epoch is not None - and connection_epoch == self._connection_epoch - ): + self.invalidate_now(connection_epoch=connection_epoch) + + def observe(self, name: ScopeSetting, value: ScopeValue, receiver: int = 0) -> None: + if name == "fixed_edge" and isinstance(value, ScopeFixedEdge): + matching = [ + key + for key, prior in self._applied.items() + if key[1] == name + and isinstance(prior, ScopeFixedEdge) + and (prior.range_index, prior.edge) == (value.range_index, value.edge) + ] + if matching: + for key in matching: + self._applied[key] = value return - self._connection_epoch = connection_epoch - self.generation += 1 - if not self.menu_guard_active(): - self.observed = None - self._applied.clear() - self._ready = False + self._applied[receiver, name] = value + if name != "fixed_edge" and receiver == 0: + base = self.observed or self._original + if base is not None: + if name == "scope_on": + self.observed = replace(base, scope_on=bool(value)) + elif name == "output_on": + self.observed = replace(base, output_on=bool(value)) + elif name == "mode": + self.observed = replace(base, mode=cast(int, value)) + elif name == "span": + self.observed = replace(base, span=cast(int, value)) + else: + self.observed = replace(base, edge=cast(int, value)) + + def notify_tx(self, tx: bool) -> None: + previous, self._tx = self._tx, tx + if previous and not tx: + task = asyncio.create_task(self.reconcile()) + self._event_tasks.add(task) + + def finished(done: asyncio.Task[None]) -> None: + self._event_tasks.discard(done) + if not done.cancelled() and done.exception() is not None: + logger.warning( + "scope event reconciliation remains pending: %s", + done.exception(), + ) + + task.add_done_callback(finished) def menu_guard_active(self) -> bool: return ( @@ -283,19 +331,7 @@ async def _put( self._enabled_at = self._clock() self._last_frame_at = None self._out_of_range = False - if name != "fixed_edge" and receiver == 0: - base = self.observed or self._original - if base is not None: - if name == "scope_on": - self.observed = replace(base, scope_on=bool(value)) - elif name == "output_on": - self.observed = replace(base, output_on=bool(value)) - elif name == "mode": - self.observed = replace(base, mode=cast(int, value)) - elif name == "span": - self.observed = replace(base, span=cast(int, value)) - else: - self.observed = replace(base, edge=cast(int, value)) + self.observe(name, value, receiver) async def _reconcile(self) -> None: if ( @@ -390,9 +426,13 @@ async def _reconcile(self) -> None: and self.observed is not None and name != "fixed_edge" and getattr(self.observed, name) == value + and (receiver, name) not in self._uncertain ): continue - if self._applied.get((receiver, name)) == value: + if ( + self._applied.get((receiver, name)) == value + and (receiver, name) not in self._uncertain + ): continue if name == "fixed_edge": assert isinstance(value, ScopeFixedEdge) @@ -401,7 +441,10 @@ async def _reconcile(self) -> None: if self._read_fixed is None: raise RuntimeError("scope fixed edge cannot be captured") captured_fixed = await self._read_fixed(*slot) - if captured_fixed == value: + if ( + captured_fixed == value + and (receiver, name) not in self._uncertain + ): self._applied[receiver, name] = value continue self._fixed[slot] = captured_fixed @@ -415,11 +458,14 @@ async def _reconcile(self) -> None: else self._original ) captured = self._views[receiver] - if getattr(captured, name) == value: + if getattr(captured, name) == value and key not in self._uncertain: self._applied[key] = value continue self._previous[key] = getattr(captured, name) - if self._applied.get((receiver, name)) != value: + if ( + self._applied.get((receiver, name)) != value + or (receiver, name) in self._uncertain + ): await self._put(name, value, receiver) diff --git a/src/rigplane/web/server.py b/src/rigplane/web/server.py index 627b3d1a9..9fd10fba6 100644 --- a/src/rigplane/web/server.py +++ b/src/rigplane/web/server.py @@ -3420,7 +3420,12 @@ async def _scope_health_monitor(self) -> None: session = self._radio_scope_session if session is None or self._radio is None: continue - await session.reconcile() + try: + await session.reconcile() + except Exception: + logger.warning( + "scope reconciliation remains pending", exc_info=True + ) now = asyncio.get_running_loop().time() fact = session.health( civ_flowing=self._civ_link_flowing(now), diff --git a/tests/test_profile_command_binding.py b/tests/test_profile_command_binding.py index bcc1c76cd..1f26f540c 100644 --- a/tests/test_profile_command_binding.py +++ b/tests/test_profile_command_binding.py @@ -543,8 +543,6 @@ def _fake_build_from_map( builders_module = sys.modules["rigplane.commands._builders"] monkeypatch.setattr(builders_module, "_build_from_map", _fake_build_from_map) target = inspect.unwrap(builder) - if target.__name__ == "scope_set_fixed_edge": - return {"edge": 1, "range_index": 1, "start_hz": 1000000, "end_hz": 2000000} probe_map = cmd_map if "ctcss_tones_centihz" in inspect.signature(target).parameters: # Tone builders validate both their profile domain and their named diff --git a/tests/testkit/test_simulator_blank_scope_mor3690.py b/tests/testkit/test_simulator_blank_scope_mor3690.py index 08c3933a1..51bac8142 100644 --- a/tests/testkit/test_simulator_blank_scope_mor3690.py +++ b/tests/testkit/test_simulator_blank_scope_mor3690.py @@ -119,6 +119,7 @@ async def test_watchdog_ignores_simulator_blank_stream( source = srv._radio_scope_source assert source is not None from rigplane.radio import IcomRadio + publisher = IcomRadio("127.0.0.1", model="IC-7610") publisher._control_scope_session = srv._radio_scope_session publisher.on_scope_data(source.feed_frame) From 592aecb954ef85490be4d075c750b793c460d4ce Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:44:16 -0500 Subject: [PATCH 33/62] test(MOR-4274): preserve MAIN selection during SUB FIXED capture Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 05bec1222..4aaba44da 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -178,3 +178,21 @@ async def test_verify_requires_new_activity_after_a_previous_stream(sdk): sim._scope_stream = False with pytest.raises(TimeoutError, match="verification"): await radio.enable_scope(policy="verify", timeout=0.1) + + +async def test_sub_fixed_slot_capture_preserves_main_selected_edge(sdk): + from rigplane.scope.models import View + + radio, sim = sdk + radio._radio_state.sub.freq = 14100000 + async with web(radio) as (_, client, base, protocols, _): + async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: + await ws.send_str(encode_scope_subscription( + Subscription("radio", 1, View(14100000, 14120000), 100, 20) + )) + message = await ws.receive(timeout=5) + assert message.type is aiohttp.WSMsgType.TEXT + assert '"error"' not in message.data + assert sim.views[1][2] == 2 + assert radio._scope_controls().receiver == 0 + assert radio._scope_controls().edge == sim.views[0][2] == 1 From c67c53f557d27463c96509a78986c51858f687dc Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:44:27 -0500 Subject: [PATCH 34/62] fix(MOR-4274): separate fixed-slot and selected-edge observations Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/_civ_rx.py | 1 - src/rigplane/runtime/_scope_runtime.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/rigplane/runtime/_civ_rx.py b/src/rigplane/runtime/_civ_rx.py index 1dddf9351..4446bc88d 100644 --- a/src/rigplane/runtime/_civ_rx.py +++ b/src/rigplane/runtime/_civ_rx.py @@ -4175,7 +4175,6 @@ def _handle_27( scope.vbw_narrow = vbw_narrow elif frame.sub == 0x1E: scope.fixed_edge = parse_scope_fixed_edge_response(frame) - scope.edge = scope.fixed_edge.edge elif frame.sub == 0x1F: receiver, rbw = parse_scope_rbw_response(frame) if receiver is not None and receiver != scope.receiver: diff --git a/src/rigplane/runtime/_scope_runtime.py b/src/rigplane/runtime/_scope_runtime.py index 8962fedf8..159a7fa1a 100644 --- a/src/rigplane/runtime/_scope_runtime.py +++ b/src/rigplane/runtime/_scope_runtime.py @@ -595,7 +595,6 @@ async def get_scope_fixed_edge( command, sub, _ = self._expect_shape(get_scope_fixed_edge) fixed_edge = parse_scope_fixed_edge_response(resp, command=command, sub=sub) self._scope_controls().fixed_edge = fixed_edge - self._scope_controls().edge = fixed_edge.edge return fixed_edge async def set_scope_fixed_edge( From 36b9aa34db070e224ab80b4bae13f6e79cbe301c Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:44:55 -0500 Subject: [PATCH 35/62] test(MOR-4274): reapply initially unchanged span after panel drift Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 4aaba44da..0d66d80f1 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -136,6 +136,7 @@ async def test_sdk_frames_stay_visible_when_web_pauses(sdk): async def test_control_ws_reapplies_span_after_real_front_panel_readback(sdk): radio, sim = sdk + await radio.enable_scope(policy="fast") await radio.set_scope_span(3) sim.views[0] = (0, 4, 1) assert await radio.get_scope_span() == 4 From 656e444d2a31de4317a7740670ef487ddf9d49b0 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:45:14 -0500 Subject: [PATCH 36/62] fix(MOR-4274): compare current observations when suppressing scope writes Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/scope_session.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index f073e4c63..ada0a31bc 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -457,8 +457,12 @@ async def _reconcile(self) -> None: if self._read_receiver is not None else self._original ) + for setting in ("mode", "span", "edge"): + self._applied[receiver, cast(ScopeSetting, setting)] = getattr( + self._views[receiver], setting + ) captured = self._views[receiver] - if getattr(captured, name) == value and key not in self._uncertain: + if self._applied.get(key) == value and key not in self._uncertain: self._applied[key] = value continue self._previous[key] = getattr(captured, name) From b9acc010b3f0f849fc1c683b3ec94f261d81e178 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:45:53 -0500 Subject: [PATCH 37/62] test(MOR-4274): retire standalone control setters before health reconciliation Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 0d66d80f1..c3d363351 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -197,3 +197,29 @@ async def test_sub_fixed_slot_capture_preserves_main_selected_edge(sdk): assert sim.views[1][2] == 2 assert radio._scope_controls().receiver == 0 assert radio._scope_controls().edge == sim.views[0][2] == 1 + + +async def test_standalone_control_ws_setter_retires_after_ack(sdk, monkeypatch): + radio, sim = sdk + async with web(radio) as (server, _, _, _, ws): + server._scope_health_interval = 0.05 + await ws.send_json({"type": "cmd", "id": "span", "name": "set_scope_span", + "params": {"span": 2}}) + assert await await_until(lambda: sim.views[0][1] == 2) + sim.views[0] = (0, 4, 1) + assert await radio.get_scope_span() == 4 + reconciled = asyncio.Event() + reconcile = radio.scope_session().reconcile + + async def tick(): + await reconcile() + reconciled.set() + + monkeypatch.setattr(radio.scope_session(), "reconcile", tick) + monitor = asyncio.create_task(server._scope_health_monitor()) + try: + await asyncio.wait_for(reconciled.wait(), 5) + assert sim.views[0][1] == 4 + finally: + monitor.cancel() + await asyncio.gather(monitor, return_exceptions=True) From f0426031703f0f165af0f0d573b95fe9e15ab498 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:46:13 -0500 Subject: [PATCH 38/62] fix(MOR-4274): retire acknowledged scope settings without demand Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/scope_session.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index ada0a31bc..7b289fc46 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -420,15 +420,6 @@ async def _reconcile(self) -> None: d.subscribers for d in self._demands.values() ): continue - if ( - self._original is None - and receiver == 0 - and self.observed is not None - and name != "fixed_edge" - and getattr(self.observed, name) == value - and (receiver, name) not in self._uncertain - ): - continue if ( self._applied.get((receiver, name)) == value and (receiver, name) not in self._uncertain @@ -472,6 +463,13 @@ async def _reconcile(self) -> None: ): await self._put(name, value, receiver) + if not any(d.subscribers for d in self._demands.values()): + self._desired = { + key: value + for key, value in self._desired.items() + if key in self._uncertain or self._applied.get(key) != value + } + class ScopeSessionProvider(Protocol): def scope_session(self) -> ScopeSession: ... From 0e68943fb69b67415c7e239fd313fa45405617e2 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:46:33 -0500 Subject: [PATCH 39/62] test(MOR-4274): require arrival evidence and hidden transition in health regression Co-Authored-By: Claude Opus 5.5 --- tests/web/test_scope_hub_channel.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/web/test_scope_hub_channel.py b/tests/web/test_scope_hub_channel.py index 6d8e3fc9d..6c95e8703 100644 --- a/tests/web/test_scope_hub_channel.py +++ b/tests/web/test_scope_hub_channel.py @@ -474,8 +474,14 @@ async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( assert decoded.out_of_range is out_of_range assert decoded.pixels is None assert decoded.coverage_start_hz == decoded.coverage_end_hz + assert session.health(civ_flowing=True).last_frame_at == virtual_clock() + server._radio.last_valid_frame_monotonic = virtual_clock() await virtual_clock.elapse(0.1) assert server._scope_hidden_published is False + for _ in range(3): + server._radio.last_valid_frame_monotonic = virtual_clock() + await virtual_clock.elapse(0.05) + assert server._scope_hidden_published is True finally: task.cancel() await asyncio.gather(task, return_exceptions=True) From bb0bb69d673406a352fa7666dc40840dd2376170 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:46:53 -0500 Subject: [PATCH 40/62] test(MOR-4274): route health regression through Core frame publication Co-Authored-By: Claude Opus 5.5 --- tests/web/test_scope_hub_channel.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/web/test_scope_hub_channel.py b/tests/web/test_scope_hub_channel.py index 6c95e8703..a90ab5144 100644 --- a/tests/web/test_scope_hub_channel.py +++ b/tests/web/test_scope_hub_channel.py @@ -442,7 +442,7 @@ async def receive(index, ws): async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( virtual_clock, monkeypatch, out_of_range, pixels ): - from types import SimpleNamespace + from rigplane.radio import IcomRadio from rigplane.web.protocol import decode_scope_frame_v2 from test_web_server_coverage import _scope_radio @@ -456,7 +456,8 @@ async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( server.scope_hub.register("radio", source, on_demand=session.set_demand) server._radio_scope_source = source server._radio_scope_session = session - server._radio = SimpleNamespace(last_valid_frame_monotonic=virtual_clock()) + server._radio = radio + radio.last_valid_frame_monotonic = virtual_clock() server._hardware_scope_available = True server._scope_health_interval = 0.1 monkeypatch.setattr(server, "_radio_ready", lambda: True) @@ -464,10 +465,13 @@ async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( sub = await server.scope_hub.subscribe( Subscription("radio", 0, View(20_000_000, 20_100_000), 1024, 20) ) + publisher = IcomRadio("127.0.0.1", model="IC-705") + publisher._control_scope_session = session + publisher.on_scope_data(source.feed_frame) task = asyncio.create_task(server._scope_health_monitor()) try: - for _ in range(3): - source.feed_frame( + for _ in range(6): + publisher._civ_runtime._publish_scope_frame( ScopeFrame(0, 1, 14_000_000, 14_100_000, pixels, out_of_range) ) decoded = decode_scope_frame_v2(await sub.receive()) @@ -476,7 +480,7 @@ async def test_typed_oor_and_uncovered_arrivals_never_publish_hidden( assert decoded.coverage_start_hz == decoded.coverage_end_hz assert session.health(civ_flowing=True).last_frame_at == virtual_clock() server._radio.last_valid_frame_monotonic = virtual_clock() - await virtual_clock.elapse(0.1) + await virtual_clock.elapse(0.05) assert server._scope_hidden_published is False for _ in range(3): server._radio.last_valid_frame_monotonic = virtual_clock() From 86f062d9d8c7d78650350df5980d455a9ddcd616 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:48:10 -0500 Subject: [PATCH 41/62] test(MOR-4274): resume retained SDK demand after late serial identity Co-Authored-By: Claude Opus 5.5 --- tests/test_icom7610_serial_radio.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_icom7610_serial_radio.py b/tests/test_icom7610_serial_radio.py index a0ea33784..866377b8d 100644 --- a/tests/test_icom7610_serial_radio.py +++ b/tests/test_icom7610_serial_radio.py @@ -3773,3 +3773,34 @@ async def test_startup_gate_releases_at_once_while_identity_is_no_response( if record.levelno >= logging.WARNING and "identity" in record.getMessage() ] assert len(warnings) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reconnect", [False, True]) +async def test_retained_sdk_scope_resumes_after_late_serial_identity(reconnect): + link = _GateSerialLink(answer_identity=True, model_id=IC_7610_ADDR) + radio = Icom7610SerialRadio(device="/dev/fake-scope", civ_link=link) + radio._civ_min_interval = 0.001 + radio._civ_get_timeout = 0.05 + radio._SERIAL_IDENTITY_REREAD_BACKOFF_S = (0.05,) + radio._SERIAL_IDENTITY_REREAD_STEADY_S = 0.05 + try: + await radio.connect() + await radio.enable_scope(policy="fast") + if not reconnect: + await radio.disconnect() + link.policy_answer = False + link.scope_state[0x11] = False + if reconnect: + link.ready = link.healthy = False + await radio.soft_reconnect() + else: + await radio.connect() + assert await await_until( + lambda: radio.connection_identity.status is RadioIdentityStatus.NO_RESPONSE + ) + link.policy_answer = True + assert await await_until(lambda: radio.connected) + assert await await_until(lambda: link.scope_state[0x11]) + finally: + await radio.disconnect() From 3466294f1bb876b88b072c968bec69bfd0d3706b Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:48:37 -0500 Subject: [PATCH 42/62] fix(MOR-4274): resume scope after every serial identity completion Co-Authored-By: Claude Opus 5.5 --- src/rigplane/backends/_icom_serial_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rigplane/backends/_icom_serial_base.py b/src/rigplane/backends/_icom_serial_base.py index b7f70188f..e5811f027 100644 --- a/src/rigplane/backends/_icom_serial_base.py +++ b/src/rigplane/backends/_icom_serial_base.py @@ -1363,7 +1363,6 @@ async def _serial_identity_reread_loop(self) -> None: ): try: await self._arm_managed_tx() - await self._scope_session_ready() except Exception: # MOR-3071 round 4: ``connect()`` lets an # ``_arm_managed_tx`` failure propagate to its @@ -1380,6 +1379,7 @@ async def _serial_identity_reread_loop(self) -> None: self._serial_device, exc_info=True, ) + await self._scope_session_ready() # MOR-3436: the hold completed — run the audio recovery a # connect/soft_reconnect deferred here, once, if the link # and the owner are unchanged. From 4a10a583462d2c50d1b4146c2788315b1a32773b Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:48:54 -0500 Subject: [PATCH 43/62] test(MOR-4274): refresh selected display when returning from SUB Co-Authored-By: Claude Opus 5.5 --- tests/test_civ_rx_coverage.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_civ_rx_coverage.py b/tests/test_civ_rx_coverage.py index eb1296d24..917287925 100644 --- a/tests/test_civ_rx_coverage.py +++ b/tests/test_civ_rx_coverage.py @@ -7503,3 +7503,21 @@ async def _count_receive(timeout: float = 5.0) -> bytes: assert len(routed) == 1 finally: await radio._civ_runtime.stop_pump() + + +@pytest.mark.parametrize("middle_mode", [0, 1]) +async def test_scope_selection_return_refreshes_stream_display_facts(radio, middle_mode): + for receiver, mode, hz in ((0, 0, 5000), (1, middle_mode, 2500), (0, 0, 5000)): + radio._civ_runtime._update_state_cache_from_frame( + _make_frame(cmd=0x27, sub=0x12, data=bytes([receiver])) + ) + await radio._civ_runtime._route_civ_frame( + _make_scope_waveform_frame(receiver=receiver, mode=mode, end_hz=hz), + generation=radio._civ_epoch, + ) + snapshot = radio._state_store.snapshot() + assert snapshot.field("scope_controls.global.display.mode").value == mode + if mode == 0: + assert snapshot.field("scope_controls.global.display.span").value == ( + radio._profile.scope_span_presets_hz.index(hz) + ) From e5d9c30fba515d3c27fbb372f2a3b36efd5b4132 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:49:05 -0500 Subject: [PATCH 44/62] fix(MOR-4274): retain dedup facts only for the last selected stream Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/_civ_rx.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rigplane/runtime/_civ_rx.py b/src/rigplane/runtime/_civ_rx.py index 4446bc88d..9b5bec98b 100644 --- a/src/rigplane/runtime/_civ_rx.py +++ b/src/rigplane/runtime/_civ_rx.py @@ -4307,6 +4307,7 @@ def _publish_scope_mode_observation( ) changeset = self._host._state_store.apply(observation) if changeset.observed_paths: + last_modes.clear() last_modes[receiver] = scope_frame.mode self._record_scheduler_result_for_observation(observation, changeset) self._notify_state_store_changed(changeset) @@ -4395,6 +4396,7 @@ def _publish_scope_span_observation( ) changeset = self._host._state_store.apply(observation) if changeset.observed_paths: + last_spans.clear() last_spans[receiver] = span self._record_scheduler_result_for_observation(observation, changeset) self._notify_state_store_changed(changeset) From d618f272359789eeb1dc57f34902e59637b78cd9 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:49:34 -0500 Subject: [PATCH 45/62] test(MOR-4274): reject invalid scope settings before deferral Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index c3d363351..e856a2444 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -223,3 +223,16 @@ async def tick(): finally: monitor.cancel() await asyncio.gather(monitor, return_exceptions=True) + + +@pytest.mark.parametrize("defer", ["readiness", "tx"]) +@pytest.mark.parametrize("setting", ["mode", "span", "edge"]) +async def test_invalid_deferred_setting_cannot_poison_later_work(sdk, defer, setting): + radio, sim = sdk + session = radio.scope_session() + await session.set_inputs(ready=defer != "readiness", tx=defer == "tx") + with pytest.raises(ValueError): + await session.configure(**{setting: 99}) + await session.configure(span=2) + await session.set_inputs(ready=True, tx=False) + assert sim.views[0][1] == 2 From 158970ed17317d8475b770acae77372ca42d7ec8 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 21:50:16 -0500 Subject: [PATCH 46/62] fix(MOR-4274): validate scope requests before accepting deferred work Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/_scope_runtime.py | 23 +++++++++++++++-------- src/rigplane/runtime/scope_session.py | 17 +++++++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/rigplane/runtime/_scope_runtime.py b/src/rigplane/runtime/_scope_runtime.py index 159a7fa1a..23bb2814d 100644 --- a/src/rigplane/runtime/_scope_runtime.py +++ b/src/rigplane/runtime/_scope_runtime.py @@ -142,6 +142,7 @@ def scope_session(self) -> ScopeSession: self._profile.scope_span_presets_hz, lambda: bool(self._radio_state.ptt), ready=self._connected, + validate=self._scope_setting_frame, ) self._control_scope_session = session return session @@ -191,15 +192,9 @@ async def _read_scope_session(self) -> ScopeState: panel, output, controls.mode or 0, controls.span or 0, controls.edge or 1 ) - async def _write_scope_setting( + def _scope_setting_frame( self, name: ScopeSetting, value: ScopeValue, receiver: int - ) -> None: - """The sole writer for scope lifecycle and viewport commands.""" - self._check_connected() - if self.external_cat_session_active: - raise CommandError( - "scope restore deferred while an external CAT session owns the wire" - ) + ) -> bytes: if name == "scope_on": builder = self._commands.scope_on if value else self._commands.scope_off frame = builder(to_addr=self._radio_addr) @@ -232,6 +227,18 @@ async def _write_scope_setting( else self._commands.scope_set_edge ) frame = builder(value, receiver=receiver, to_addr=self._radio_addr) + return frame + + async def _write_scope_setting( + self, name: ScopeSetting, value: ScopeValue, receiver: int + ) -> None: + """The sole writer for scope lifecycle and viewport commands.""" + self._check_connected() + if self.external_cat_session_active: + raise CommandError( + "scope restore deferred while an external CAT session owns the wire" + ) + frame = self._scope_setting_frame(name, value, receiver) response = await self._send_civ_raw(frame, wait_response=True) if response is None or parse_ack_nak(response) is not True: raise CommandError(f"Radio did not acknowledge scope {name}") diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index 7b289fc46..984943006 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -49,11 +49,13 @@ def __init__( tx_probe: Callable[[], bool] | None = None, *, ready: bool = False, + validate: Callable[[ScopeSetting, ScopeValue, int], object] | None = None, clock: Callable[[], float] = time.monotonic, disable_grace_seconds: float = 0, silence_seconds: float = 5, hidden_grace_seconds: float = 30, ) -> None: + self._validate = validate self._read = read self._write = write self._read_fixed = read_fixed @@ -275,12 +277,6 @@ def health( async def configure(self, *, receiver: int = 0, **settings: ScopeValue) -> None: async with self._lock: - previous_desired = dict(self._desired) - if settings.get("mode") == 1 and "fixed_edge" in settings: - self._desired.pop((receiver, "span"), None) - elif settings.get("mode") == 0 and "span" in settings: - self._desired.pop((receiver, "fixed_edge"), None) - self._desired.pop((receiver, "edge"), None) for name, value in settings.items(): if name not in ( "scope_on", @@ -291,6 +287,15 @@ async def configure(self, *, receiver: int = 0, **settings: ScopeValue) -> None: "fixed_edge", ): raise ValueError(f"unknown scope setting {name}") + if self._validate is not None: + self._validate(cast(ScopeSetting, name), value, receiver) + previous_desired = dict(self._desired) + if settings.get("mode") == 1 and "fixed_edge" in settings: + self._desired.pop((receiver, "span"), None) + elif settings.get("mode") == 0 and "span" in settings: + self._desired.pop((receiver, "fixed_edge"), None) + self._desired.pop((receiver, "edge"), None) + for name, value in settings.items(): if name == "fixed_edge" and isinstance(value, ScopeFixedEdge): for key, prior in tuple(self._desired.items()): if ( From 1121bba400af4a963b27b99bfc88e45b9213c6db Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:04:08 -0500 Subject: [PATCH 47/62] test(MOR-4274): format frozen review regression coverage Full rebased fast run exposed restore replay and builder AST parity regressions; retained as RED evidence before their correction. Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/scope_session.py | 4 ++-- tests/runtime/test_scope_session_review.py | 18 +++++++++++++----- tests/test_civ_rx_coverage.py | 4 +++- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index 984943006..b7de9c85f 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -454,8 +454,8 @@ async def _reconcile(self) -> None: else self._original ) for setting in ("mode", "span", "edge"): - self._applied[receiver, cast(ScopeSetting, setting)] = getattr( - self._views[receiver], setting + self._applied[receiver, cast(ScopeSetting, setting)] = ( + getattr(self._views[receiver], setting) ) captured = self._views[receiver] if self._applied.get(key) == value and key not in self._uncertain: diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index e856a2444..9fd034a96 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -188,9 +188,11 @@ async def test_sub_fixed_slot_capture_preserves_main_selected_edge(sdk): radio._radio_state.sub.freq = 14100000 async with web(radio) as (_, client, base, protocols, _): async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: - await ws.send_str(encode_scope_subscription( - Subscription("radio", 1, View(14100000, 14120000), 100, 20) - )) + await ws.send_str( + encode_scope_subscription( + Subscription("radio", 1, View(14100000, 14120000), 100, 20) + ) + ) message = await ws.receive(timeout=5) assert message.type is aiohttp.WSMsgType.TEXT assert '"error"' not in message.data @@ -203,8 +205,14 @@ async def test_standalone_control_ws_setter_retires_after_ack(sdk, monkeypatch): radio, sim = sdk async with web(radio) as (server, _, _, _, ws): server._scope_health_interval = 0.05 - await ws.send_json({"type": "cmd", "id": "span", "name": "set_scope_span", - "params": {"span": 2}}) + await ws.send_json( + { + "type": "cmd", + "id": "span", + "name": "set_scope_span", + "params": {"span": 2}, + } + ) assert await await_until(lambda: sim.views[0][1] == 2) sim.views[0] = (0, 4, 1) assert await radio.get_scope_span() == 4 diff --git a/tests/test_civ_rx_coverage.py b/tests/test_civ_rx_coverage.py index 917287925..2376f1dcc 100644 --- a/tests/test_civ_rx_coverage.py +++ b/tests/test_civ_rx_coverage.py @@ -7506,7 +7506,9 @@ async def _count_receive(timeout: float = 5.0) -> bytes: @pytest.mark.parametrize("middle_mode", [0, 1]) -async def test_scope_selection_return_refreshes_stream_display_facts(radio, middle_mode): +async def test_scope_selection_return_refreshes_stream_display_facts( + radio, middle_mode +): for receiver, mode, hz in ((0, 0, 5000), (1, middle_mode, 2500), (0, 0, 5000)): radio._civ_runtime._update_state_cache_from_frame( _make_frame(cmd=0x27, sub=0x12, data=bytes([receiver])) From 3520f55cfe254e17dd52634147260044879284c1 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:16:21 -0500 Subject: [PATCH 48/62] fix(MOR-4274): retain restored observations and verify builder delegation Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/scope_session.py | 1 - tests/test_supports_command.py | 13 ++++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index b7de9c85f..0e770e44b 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -382,7 +382,6 @@ async def _reconcile(self) -> None: self._fixed.clear() self._views.clear() self._desired.clear() - self._applied.clear() self._uncertain.clear() self._restore_pending = self._paused = False self._connection_epoch = None diff --git a/tests/test_supports_command.py b/tests/test_supports_command.py index c04a297e7..dfb85ba5d 100644 --- a/tests/test_supports_command.py +++ b/tests/test_supports_command.py @@ -157,9 +157,12 @@ def _assert_parity(relations, sources=None) -> None: assert {b.__name__ for b in scope} == {"scope_on", "scope_data_output"} assert "scope_session" in _self_calls(ScopeRuntimeMixin, "enable_scope", sources) assert "scope_session" in _self_calls(ScopeRuntimeMixin, "disable_scope", sources) - assert "scope_data_output" in _command_calls( + assert "_scope_setting_frame" in _self_calls( ScopeRuntimeMixin, "_write_scope_setting", sources ) + assert "scope_data_output" in _command_calls( + ScopeRuntimeMixin, "_scope_setting_frame", sources + ) assert by_name["capture_scope_frame"].builders == scope assert by_name["capture_scope_frames"].builders == scope assert "capture_scope_frames" in _self_calls( @@ -418,6 +421,14 @@ def test_disconnected_public_and_delegate_links_fail_ast_parity(self): delegate_module = inspect.getmodule(dual_watch.builders[0]) assert delegate_module is not None mutants = ( + ( + (ScopeRuntimeMixin, "_write_scope_setting"), + "async def f(self):\n return None", + ), + ( + (ScopeRuntimeMixin, "_scope_setting_frame"), + 'def f(self):\n return b""', + ), ( (CoreRadio, "get_mode_info"), 'async def f(self):\n """_get_mode_info_main"""\n return await self._get_unselected_mode()', From 013c174b0ca6193e9a36af7a6f43686bbfea1b97 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:41:12 -0500 Subject: [PATCH 49/62] test(MOR-4274): reproduce stale panel and output facts Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 9fd034a96..a979d3a61 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -244,3 +244,13 @@ async def test_invalid_deferred_setting_cannot_poison_later_work(sdk, defer, set await session.configure(span=2) await session.set_inputs(ready=True, tx=False) assert sim.views[0][1] == 2 + + +async def test_panel_output_readbacks_allow_sdk_reenable(sdk): + radio, sim = sdk + await radio.enable_scope(policy="verify") + sim._scope_10 = sim._scope_11 = 0 + assert await radio.get_scope_session_state() == (False, False) + assert not radio.scope_session().enabled + await radio.enable_scope(policy="verify") + assert sim._scope_10 == sim._scope_11 == 1 From c20db6038ce094d527fa8df9a1172928dd69210f Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:41:46 -0500 Subject: [PATCH 50/62] fix(MOR-4274): consume validated panel and output readbacks Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/_civ_rx.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/rigplane/runtime/_civ_rx.py b/src/rigplane/runtime/_civ_rx.py index 9b5bec98b..bfe3cce13 100644 --- a/src/rigplane/runtime/_civ_rx.py +++ b/src/rigplane/runtime/_civ_rx.py @@ -45,6 +45,8 @@ parse_scope_center_type_response, parse_scope_during_tx_response, parse_scope_edge_response, + parse_scope_enabled_response, + parse_scope_data_output_enabled_response, parse_scope_fixed_edge_response, parse_scope_hold_response, parse_scope_main_sub_response, @@ -3437,6 +3439,16 @@ def _scope_control_observations(self, frame: CivFrame) -> list[Observation]: """ receiver: int | None = None pairs: list[tuple[str, Any]] = [] + session = getattr(self._host, "_control_scope_session", None) + if frame.sub in (0x10, 0x11): + value = ( + parse_scope_enabled_response(frame) + if frame.sub == 0x10 + else parse_scope_data_output_enabled_response(frame) + ) + if isinstance(session, ScopeSession): + session.observe("scope_on" if frame.sub == 0x10 else "output_on", value) + return [] if frame.sub == 0x12: pairs.append(("receiver", parse_scope_main_sub_response(frame))) elif frame.sub == 0x13: From 82e9ebe45a3863d340d5284c35d7b550f40c735f Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:42:27 -0500 Subject: [PATCH 51/62] test(MOR-4274): require SUB and concurrent MAIN streaming from single display Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index a979d3a61..30b24c990 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -103,13 +103,20 @@ async def test_sdk_only_reconnect_reapplies_demand(sdk): async def test_sub_view_real_dispatch_preserves_main_sdk_selection(sdk): radio, sim = sdk + sim._scope_12 = sim._scope_13 = 0 async with web(radio) as (_, client, base, protocols, _): async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: await grant(ws, receiver=1) + assert (await ws.receive(timeout=5)).type is aiohttp.WSMsgType.BINARY + async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as main: + await grant(main) + assert (await main.receive(timeout=5)).type is aiohttp.WSMsgType.BINARY + assert (await ws.receive(timeout=5)).type is aiohttp.WSMsgType.BINARY assert radio._scope_controls().receiver == 0 await radio.set_scope_span(4) assert sim.views[0][1] == 4 assert sim.views[1][1] == 5 + assert sim._scope_12 == sim._scope_13 == 0 async def test_sdk_frames_stay_visible_when_web_pauses(sdk): From 6625185a111b6b198c42a1d1f42a90f1dd6867cc Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:43:24 -0500 Subject: [PATCH 52/62] test(MOR-4274): load both receiver waveform fixtures for streaming regression Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 30b24c990..890db98d0 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -23,7 +23,7 @@ @pytest.fixture async def sdk(): - sim = ScopeControlSimulator(scope_stream=True) + sim = ScopeControlSimulator(scope_stream=True, dual_receiver=True) await sim.start() radio = IcomRadio( "127.0.0.1", From eda64536cd876e8884db9186d2fa69501e507a20 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:43:47 -0500 Subject: [PATCH 53/62] fix(MOR-4274): restore dual streaming through the shared scope session Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/_scope_runtime.py | 9 +++++++-- src/rigplane/runtime/scope_session.py | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/rigplane/runtime/_scope_runtime.py b/src/rigplane/runtime/_scope_runtime.py index 23bb2814d..ecaef8f73 100644 --- a/src/rigplane/runtime/_scope_runtime.py +++ b/src/rigplane/runtime/_scope_runtime.py @@ -189,7 +189,8 @@ async def _read_scope_session(self) -> ScopeState: panel, output = await self.get_scope_session_state() controls = self._scope_controls() return ScopeState( - panel, output, controls.mode or 0, controls.span or 0, controls.edge or 1 + panel, output, controls.mode or 0, controls.span or 0, controls.edge or 1, + await self.get_scope_dual() if self._profile.receiver_count > 1 else False, ) def _scope_setting_frame( @@ -202,6 +203,8 @@ def _scope_setting_frame( frame = self._commands.scope_data_output( bool(value), to_addr=self._radio_addr ) + elif name == "dual": + frame = self._commands.scope_single_dual(bool(value), to_addr=self._radio_addr) elif name == "fixed_edge": assert isinstance(value, ScopeFixedEdge) frame = self._commands.scope_set_fixed_edge( @@ -243,7 +246,9 @@ async def _write_scope_setting( if response is None or parse_ack_nak(response) is not True: raise CommandError(f"Radio did not acknowledge scope {name}") controls = self._scope_controls() - if name == "fixed_edge": + if name == "dual": + controls.dual = bool(value) + elif name == "fixed_edge": assert isinstance(value, ScopeFixedEdge) controls.fixed_edge = value elif name not in ("scope_on", "output_on") and receiver == controls.receiver: diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index 0e770e44b..2de4fd7a2 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) ScopeSetting: TypeAlias = Literal[ - "scope_on", "output_on", "mode", "span", "edge", "fixed_edge" + "scope_on", "output_on", "mode", "span", "edge", "fixed_edge", "dual" ] ScopeValue: TypeAlias = bool | int | ScopeFixedEdge @@ -28,6 +28,7 @@ class ScopeState: mode: int = 0 span: int = 2 edge: int = 1 + dual: bool = False @dataclass(frozen=True) @@ -119,12 +120,14 @@ async def set_output(self, enabled: bool, *, release: bool = True) -> None: async def set_center(self, center_hz: float, span_hz: float, receiver: int) -> None: await self.configure( - receiver=receiver, mode=0, span=self._spans.index(int(span_hz)) + receiver=receiver, mode=0, span=self._spans.index(int(span_hz)), + **({"dual": True} if receiver else {}), ) async def set_fixed(self, edges: ScopeFixedEdge, receiver: int) -> None: await self.configure( - receiver=receiver, fixed_edge=edges, edge=edges.edge, mode=1 + receiver=receiver, fixed_edge=edges, edge=edges.edge, mode=1, + **({"dual": True} if receiver else {}), ) def suspend(self) -> None: @@ -168,6 +171,10 @@ def observe(self, name: ScopeSetting, value: ScopeValue, receiver: int = 0) -> N self._applied[key] = value return self._applied[receiver, name] = value + if name == "dual": + if self.observed is not None: + self.observed = replace(self.observed, dual=bool(value)) + return if name != "fixed_edge" and receiver == 0: base = self.observed or self._original if base is not None: @@ -285,6 +292,7 @@ async def configure(self, *, receiver: int = 0, **settings: ScopeValue) -> None: "span", "edge", "fixed_edge", + "dual", ): raise ValueError(f"unknown scope setting {name}") if self._validate is not None: @@ -412,6 +420,7 @@ async def _reconcile(self) -> None: for (receiver, name), value in sorted( self._desired.items(), key=lambda item: ( + "dual", "fixed_edge", "edge", "mode", @@ -443,6 +452,8 @@ async def _reconcile(self) -> None: self._applied[receiver, name] = value continue self._fixed[slot] = captured_fixed + elif name == "dual" and self._original is not None: + self._previous.setdefault((receiver, name), self._original.dual) elif self._original is not None: key = (receiver, name) if key not in self._previous: From a6bfa6093a216f777806fc66a3ee8ac9a1cf9cef Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:44:02 -0500 Subject: [PATCH 54/62] test(MOR-4274): reproduce stale fixed slot across WebSocket sessions Co-Authored-By: Claude Opus 5.5 --- tests/runtime/test_scope_session_review.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index 890db98d0..fce7da5be 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -261,3 +261,27 @@ async def test_panel_output_readbacks_allow_sdk_reenable(sdk): assert not radio.scope_session().enabled await radio.enable_scope(policy="verify") assert sim._scope_10 == sim._scope_11 == 1 + + +async def test_successive_fixed_sessions_capture_fresh_panel_slot(sdk): + from rigplane.core.types import ScopeFixedEdge + from rigplane.scope.models import View + + radio, sim = sdk + radio._radio_state.main.freq = 14100000 + requested = ScopeFixedEdge(6, 1, 14100000, 14120000) + changed = ScopeFixedEdge(6, 1, 14000000, 14300000) + sim.fixed[b"\x06\x01"] = requested + async with web(radio) as (_, client, base, protocols, _): + for original in (requested, changed): + sim.fixed[b"\x06\x01"] = original + async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: + await ws.send_str(encode_scope_subscription( + Subscription("radio", 0, View(14100000, 14120000), 100, 20) + )) + message = await ws.receive(timeout=5) + assert message.type is aiohttp.WSMsgType.TEXT + assert '"error"' not in message.data + assert sim.fixed[b"\x06\x01"] == requested + assert await await_until(lambda: not radio.scope_session().active) + assert sim.fixed[b"\x06\x01"] == original From 08aefba380463add07c172b316103fc9268f58a9 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:44:14 -0500 Subject: [PATCH 55/62] fix(MOR-4274): refresh applied facts on each scope acquisition Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/scope_session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index 2de4fd7a2..c945b1271 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -398,6 +398,7 @@ async def _reconcile(self) -> None: any(d.subscribers for d in self._demands.values()) and self._original is None ): + self._applied.clear() self._original = await self._read() self.observed = self._original if self._original.output_on: From 9d8479dc36339e973c4782347d274936292a031a Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 22:44:33 -0500 Subject: [PATCH 56/62] test(MOR-4274): reproduce scope startup failures killing command execution Co-Authored-By: Claude Opus 5.5 --- tests/test_radio_poller_coverage.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_radio_poller_coverage.py b/tests/test_radio_poller_coverage.py index e434ff6de..db6671d2c 100644 --- a/tests/test_radio_poller_coverage.py +++ b/tests/test_radio_poller_coverage.py @@ -7045,3 +7045,29 @@ async def test_shutdown_drain_executes_cancelled_unkey_after_callback_turn() -> radio.set_freq.assert_not_awaited() assert reply.cancelled() assert not queue.has_commands + + +@pytest.mark.parametrize("failure", [TimeoutError, RigplaneTimeoutError, CommandError]) +async def test_startup_scope_failure_keeps_command_executor_alive(failure, caplog): + from rigplane.scope.models import ScopeDemand + + radio, queue = _make_radio(), CommandQueue() + session = radio.scope_session() + await session.set_ready(False) + await session.set_demand(ScopeDemand(1, 1)) + radio.enable_scope.side_effect = [failure("scope unavailable"), None] + completed = asyncio.get_running_loop().create_future() + queue.put_ordered(SetFreq(14_074_000), future=completed) + poller = RadioPoller(radio, queue, radio_state=RadioState()) + + async def stop_turn(*args, **kwargs): + raise asyncio.CancelledError + + queue.wait = stop_turn + await poller._run() + assert completed.done() and completed.exception() is None + radio.set_freq.assert_awaited_once() + assert not session.enabled + assert "scope" in caplog.text and "pending" in caplog.text + await session.reconcile() + assert session.enabled From 61e931f2fbedd78f0604ca6378abb6a642409b84 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 8 Nov 2026 23:01:38 -0500 Subject: [PATCH 57/62] fix(MOR-4274): keep scope recovery pending without blocking command startup Capture dual state only for requested SUB streaming and retain MAIN-only lifecycle commands. Keep recoverable startup reconciliation failures available for the existing retry driver while the command executor starts. Co-Authored-By: Claude Opus 5.5 --- src/rigplane/runtime/_scope_runtime.py | 8 +++++--- src/rigplane/runtime/scope_session.py | 18 +++++++++++++++--- src/rigplane/web/radio_poller.py | 13 ++++++++++++- tests/runtime/test_scope_session_review.py | 16 +++++++++++----- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/rigplane/runtime/_scope_runtime.py b/src/rigplane/runtime/_scope_runtime.py index ecaef8f73..3be7728aa 100644 --- a/src/rigplane/runtime/_scope_runtime.py +++ b/src/rigplane/runtime/_scope_runtime.py @@ -143,6 +143,7 @@ def scope_session(self) -> ScopeSession: lambda: bool(self._radio_state.ptt), ready=self._connected, validate=self._scope_setting_frame, + read_dual=self.get_scope_dual, ) self._control_scope_session = session return session @@ -189,8 +190,7 @@ async def _read_scope_session(self) -> ScopeState: panel, output = await self.get_scope_session_state() controls = self._scope_controls() return ScopeState( - panel, output, controls.mode or 0, controls.span or 0, controls.edge or 1, - await self.get_scope_dual() if self._profile.receiver_count > 1 else False, + panel, output, controls.mode or 0, controls.span or 0, controls.edge or 1 ) def _scope_setting_frame( @@ -204,7 +204,9 @@ def _scope_setting_frame( bool(value), to_addr=self._radio_addr ) elif name == "dual": - frame = self._commands.scope_single_dual(bool(value), to_addr=self._radio_addr) + frame = self._commands.scope_single_dual( + bool(value), to_addr=self._radio_addr + ) elif name == "fixed_edge": assert isinstance(value, ScopeFixedEdge) frame = self._commands.scope_set_fixed_edge( diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py index c945b1271..92c8e0eb6 100644 --- a/src/rigplane/runtime/scope_session.py +++ b/src/rigplane/runtime/scope_session.py @@ -50,6 +50,7 @@ def __init__( tx_probe: Callable[[], bool] | None = None, *, ready: bool = False, + read_dual: Callable[[], Awaitable[bool]] | None = None, validate: Callable[[ScopeSetting, ScopeValue, int], object] | None = None, clock: Callable[[], float] = time.monotonic, disable_grace_seconds: float = 0, @@ -57,6 +58,7 @@ def __init__( hidden_grace_seconds: float = 30, ) -> None: self._validate = validate + self._read_dual = read_dual self._read = read self._write = write self._read_fixed = read_fixed @@ -120,13 +122,18 @@ async def set_output(self, enabled: bool, *, release: bool = True) -> None: async def set_center(self, center_hz: float, span_hz: float, receiver: int) -> None: await self.configure( - receiver=receiver, mode=0, span=self._spans.index(int(span_hz)), + receiver=receiver, + mode=0, + span=self._spans.index(int(span_hz)), **({"dual": True} if receiver else {}), ) async def set_fixed(self, edges: ScopeFixedEdge, receiver: int) -> None: await self.configure( - receiver=receiver, fixed_edge=edges, edge=edges.edge, mode=1, + receiver=receiver, + fixed_edge=edges, + edge=edges.edge, + mode=1, **({"dual": True} if receiver else {}), ) @@ -454,7 +461,12 @@ async def _reconcile(self) -> None: continue self._fixed[slot] = captured_fixed elif name == "dual" and self._original is not None: - self._previous.setdefault((receiver, name), self._original.dual) + if (receiver, name) not in self._previous: + self._previous[receiver, name] = ( + await self._read_dual() + if self._read_dual is not None + else self._original.dual + ) elif self._original is not None: key = (receiver, name) if key not in self._previous: diff --git a/src/rigplane/web/radio_poller.py b/src/rigplane/web/radio_poller.py index eb347150d..d12b13f2d 100644 --- a/src/rigplane/web/radio_poller.py +++ b/src/rigplane/web/radio_poller.py @@ -1690,7 +1690,18 @@ async def execute_entry(entry: CommandQueueEntry) -> Observation | None: # during connect(). Just signal readiness immediately. self._initial_fetch_done.set() if CAP_SCOPE in self._caps: - await self._radio.scope_session().set_ready(True) + try: + await self._radio.scope_session().set_ready(True) + except ( + TimeoutError, + RigplaneTimeoutError, + CommandError, + RadioConnectionError, + ): + logger.warning( + "radio-poller: startup scope reconciliation remains pending", + exc_info=True, + ) # NB depth/width are global menu items that cannot ride the poll-query # envelope, so seed them once at connect via direct getters (MOR-491-B). diff --git a/tests/runtime/test_scope_session_review.py b/tests/runtime/test_scope_session_review.py index fce7da5be..1ce5d1d84 100644 --- a/tests/runtime/test_scope_session_review.py +++ b/tests/runtime/test_scope_session_review.py @@ -108,7 +108,9 @@ async def test_sub_view_real_dispatch_preserves_main_sdk_selection(sdk): async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: await grant(ws, receiver=1) assert (await ws.receive(timeout=5)).type is aiohttp.WSMsgType.BINARY - async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as main: + async with client.ws_connect( + base + "/api/v1/scope", protocols=protocols + ) as main: await grant(main) assert (await main.receive(timeout=5)).type is aiohttp.WSMsgType.BINARY assert (await ws.receive(timeout=5)).type is aiohttp.WSMsgType.BINARY @@ -275,10 +277,14 @@ async def test_successive_fixed_sessions_capture_fresh_panel_slot(sdk): async with web(radio) as (_, client, base, protocols, _): for original in (requested, changed): sim.fixed[b"\x06\x01"] = original - async with client.ws_connect(base + "/api/v1/scope", protocols=protocols) as ws: - await ws.send_str(encode_scope_subscription( - Subscription("radio", 0, View(14100000, 14120000), 100, 20) - )) + async with client.ws_connect( + base + "/api/v1/scope", protocols=protocols + ) as ws: + await ws.send_str( + encode_scope_subscription( + Subscription("radio", 0, View(14100000, 14120000), 100, 20) + ) + ) message = await ws.receive(timeout=5) assert message.type is aiohttp.WSMsgType.TEXT assert '"error"' not in message.data From f77d411949be022dab789911bb7181735564ede8 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Mon, 9 Nov 2026 02:50:27 -0500 Subject: [PATCH 58/62] test(MOR-4274): bind scope stress writer to real session contract Autospec ScopeSession instead of maintaining a hand-shaped scope writer. Assert every granted field through the real decoder while retaining the bounded queue, latest-wins delivery and clean teardown checks for both profiles. Co-Authored-By: Claude Opus 5.5 --- tests/test_scope_stress.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/test_scope_stress.py b/tests/test_scope_stress.py index 7fc2a5b7a..5a1a31231 100644 --- a/tests/test_scope_stress.py +++ b/tests/test_scope_stress.py @@ -11,6 +11,7 @@ import asyncio from contextlib import suppress from typing import ClassVar, TYPE_CHECKING +from unittest.mock import call, create_autospec import pytest @@ -20,8 +21,15 @@ from rigplane.radio import IcomRadio from rigplane.profiles import resolve_radio_profile from rigplane.runtime.radio_scope_source import RadioScopeSource +from rigplane.runtime.scope_session import ScopeSession from rigplane.scope import ScopeFrame -from rigplane.scope.models import ScopeTarget, Subscription, View +from rigplane.scope.models import ( + Coverage, + GrantedSubscription, + ScopeTarget, + Subscription, + View, +) from rigplane.testing.timing import VirtualClock from rigplane.types import bcd_encode from rigplane.web.handlers.scope import ScopeHandler @@ -144,20 +152,6 @@ def radio_with_mock() -> tuple[IcomRadio, MockTransport]: # --------------------------------------------------------------------------- -class _ScopeWriter: - def __init__(self) -> None: - self.output: list[bool] = [] - - def tuned_frequency(self, receiver: int) -> float: - return 14_100_000 - - async def set_output(self, enabled: bool) -> None: - self.output.append(enabled) - - async def set_center(self, center_hz: float, span_hz: float, receiver: int) -> None: - pass - - class _SlowScopeSocket: """Connection fake whose binary sends wait for explicit drain permits.""" @@ -210,7 +204,8 @@ async def test_scope_stress_slow_consumer_queue_bounded( ) -> None: """Bound v2 delivery to two hub frames plus one blocked send; newest wins.""" server = WebServer(None) - writer = _ScopeWriter() + writer = create_autospec(ScopeSession, instance=True, spec_set=True) + writer.tuned_frequency.return_value = 14_100_000 source = RadioScopeSource(resolve_radio_profile(model=model), writer) server.scope_hub.register("radio", source) ws = _SlowScopeSocket() @@ -225,12 +220,14 @@ async def test_scope_stress_slow_consumer_queue_bounded( ) await virtual_clock.elapse(0) assert len(ws.text) == 1 - assert decode_scope_granted(ws.text[0]).width == width + assert decode_scope_granted(ws.text[0]) == GrantedSubscription( + "radio", 0, view, Coverage(view.start_hz, view.end_hz), width, 10 + ) stream = handler._streams[ScopeTarget("radio", 0)] subscription = stream.subscription sender = stream.sender assert sender is not None and not sender.done() - assert writer.output == [True] + assert writer.set_output.await_args_list == [call(True)] def publish(index: int) -> None: source.feed_frame( @@ -293,7 +290,10 @@ def publish(index: int) -> None: assert sender.done() and handler._streams == {} assert subscription._latest is None and subscription._pending is None assert subscription._timer is None - assert writer.output == [True, False] + assert writer.set_output.await_args_list == [ + call(True), + call(False, release=True), + ] assert handler not in server._scope_handlers finally: consumer.cancel() From bca4ec3ef81f0f03799d0c480ae766f5d452f604 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Mon, 9 Nov 2026 03:11:48 -0500 Subject: [PATCH 59/62] test(MOR-4274): retain MAIN through refused scope view updates Co-Authored-By: Claude Opus 5.5 --- ...pe-subscription-v2.svelte.isolated.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/frontend/src/lib/runtime/__tests__/scope-subscription-v2.svelte.isolated.test.ts b/frontend/src/lib/runtime/__tests__/scope-subscription-v2.svelte.isolated.test.ts index f7bda5974..76759ed88 100644 --- a/frontend/src/lib/runtime/__tests__/scope-subscription-v2.svelte.isolated.test.ts +++ b/frontend/src/lib/runtime/__tests__/scope-subscription-v2.svelte.isolated.test.ts @@ -113,6 +113,35 @@ it('retries a refused startup follow subscription after observed facts change', expect(publish.mock.lastCall?.[0].view).toEqual({ follow: 'vfo', spanHz: 100000 }); }); +it.each([0, 1])('keeps MAIN pixels and geometry after a receiver %i update is refused during SUB traffic', receiver => { + const { value } = stream(); + const main = Symbol('main'), sub = Symbol('sub'); + value.updateSurfaceSubscription(main, request()); + value.updateSurfaceSubscription(sub, { ...request(), receiver: 1 }); + value.setSlotFilter(scopeSlotKey('radio', 0)); + const ingress = (receiver: number) => value.ingress(ingressScopeFrame( + scopeWire({ sourceId: 'radio', receiver }), + { source: 'radio', sourceId: 'radio', epoch: 1, mode: 3 }, + )); + ingress(0); + const geometry = get(value.geometry); + const frame = value.latest('radio:0'); + expect(geometry).toMatchObject({ receiver: 0, hasPixels: true, freshness: 'fresh' }); + expect(frame?.pixels).not.toBeNull(); + value.updateSurfaceSubscription(main, { ...request(), view: { startHz: 14_000_000, endHz: 14_100_000 } }); + value.handleMessage({ type: 'error', sourceId: 'radio', receiver, message: 'CI-V command 0x27 refused (NAK)' }); + ingress(1); + expect(get(value.error)).toBe('CI-V command 0x27 refused (NAK)'); + expect(get(value.geometry)).toBe(geometry); + expect(value.latest('radio:0')).toBe(frame); + value.setFreshness('stale'); + expect(get(value.geometry)?.freshness).toBe('stale'); + value.setSessionEpoch(2); + expect(get(value.geometry)).toBeNull(); + expect(value.latest('radio:0')).toBeNull(); + value.dispose(); +}); + it('observes native backing width, intersection and document visibility for demand only', () => { const { value, publish } = stream(); From b286f0de37928e03c32c427aed90ef87ccede3f5 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Mon, 9 Nov 2026 03:12:18 -0500 Subject: [PATCH 60/62] fix(MOR-4274): preserve active scope after rejected view negotiation Keep cached frames and selected geometry when an update is refused; the server preserves the previous subscription. Startup errors still clear provisional grants, and provider/session retirement still clears retained frames. Co-Authored-By: Claude Opus 5.5 --- .../panels/lcd/__tests__/audio-fft-demand.isolated.test.ts | 5 +++-- frontend/src/lib/runtime/scope-stream.ts | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components-v2/panels/lcd/__tests__/audio-fft-demand.isolated.test.ts b/frontend/src/components-v2/panels/lcd/__tests__/audio-fft-demand.isolated.test.ts index 593fb053b..a37c69ca7 100644 --- a/frontend/src/components-v2/panels/lcd/__tests__/audio-fft-demand.isolated.test.ts +++ b/frontend/src/components-v2/panels/lcd/__tests__/audio-fft-demand.isolated.test.ts @@ -466,8 +466,9 @@ it.each(['AudioSpectrumPanel', 'AmberScope', 'AmberCockpit'] as const)( flushSync(); expect(scopeChildProbe.pushed).toHaveBeenLastCalledWith(null); mocks.channel.fire(scopeWire({ source: 'audio_fft', sourceId: 'audio_fft', receiver: null })); flushSync(); expect(scopeChildProbe.pushed.mock.lastCall?.[0]).toBeInstanceOf(Uint8Array); - mocks.channel.message({ type: 'error', sourceId: 'audio_fft', receiver: null, message: 'source stopped' }); - flushSync(); expect(scopeChildProbe.pushed).toHaveBeenLastCalledWith(null); + const retained = scopeChildProbe.pushed.mock.lastCall?.[0]; + mocks.channel.message({ type: 'error', sourceId: 'audio_fft', receiver: null, message: 'view update refused' }); + flushSync(); expect(scopeChildProbe.pushed.mock.lastCall?.[0]).toBe(retained); mocks.channel.fire(scopeWire({ source: 'audio_fft', sourceId: 'audio_fft', receiver: null })); flushSync(); expect(scopeChildProbe.pushed.mock.lastCall?.[0]).toBeInstanceOf(Uint8Array); mocks.channel.disconnect(); diff --git a/frontend/src/lib/runtime/scope-stream.ts b/frontend/src/lib/runtime/scope-stream.ts index a57edc5bc..952a54b51 100644 --- a/frontend/src/lib/runtime/scope-stream.ts +++ b/frontend/src/lib/runtime/scope-stream.ts @@ -148,7 +148,9 @@ export class ScopeStream { if (selected && this.matchingSubscription(granted)?.maxFps !== 0) this.publishGeometry(scopeGrantedGeometry(granted, this.context)); } else if (message && typeof message === 'object' && 'type' in message && message.type === 'error' && 'message' in message && typeof message.message === 'string') { - this.reset(); this.retryNeeded = true; this.errorStore.set(message.message); + // A rejected update leaves the server's previous subscription running. + if (this.frames.size === 0) this.reset(); + this.retryNeeded = true; this.errorStore.set(message.message); } } From 0af70a6ba5b1917907d4ffcb3fde8fb90d5977ae Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Mon, 9 Nov 2026 03:41:19 -0500 Subject: [PATCH 61/62] test(MOR-4274): expose frozen spectrum and invisible view refusal Co-Authored-By: Claude Opus 5.5 --- .../__tests__/SpectrumPanel.component.test.ts | 43 +++++++++++++++++++ .../e2e/journey/operator-journey.spec.ts | 22 ++++++++++ tests/e2e/journey_web_server.py | 10 +++-- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/spectrum/__tests__/SpectrumPanel.component.test.ts b/frontend/src/components/spectrum/__tests__/SpectrumPanel.component.test.ts index 128de13fb..da14e3978 100644 --- a/frontend/src/components/spectrum/__tests__/SpectrumPanel.component.test.ts +++ b/frontend/src/components/spectrum/__tests__/SpectrumPanel.component.test.ts @@ -4782,6 +4782,49 @@ describe('SpectrumPanel band-plan region wiring (MOR-3631)', () => { }); }); +describe('scope negotiation refusal (MOR-4274)', () => { + it('keeps the mounted spectrum advancing after refusal and SUB-only traffic', async () => { + const target = mountPanel(); + emitFrame({ pixels: new Uint8Array([20, 40, 60]), sequence: 1 }); + await flushWaterfallPaint(); + const canvas = target.querySelector('.spectrum-area canvas'); + expect(canvas).not.toBeNull(); + scopeControllerHarness.stream('hardware').handleMessage({ type: 'error', message: 'View negotiation refused' }); + flushSync(); + for (let sequence = 2; sequence < 5; sequence += 1) { + emitFrame({ receiver: 1, sequence }); + await flushWaterfallPaint(); + spectrumRendererHarness.render.mockClear(); + const pixels = new Uint8Array([20 * sequence, 40, 60]); + emitFrame({ pixels, sequence }); + await flushWaterfallPaint(); + expect(target.querySelector('.spectrum-area canvas')).toBe(canvas); + expect(spectrumRendererHarness.render).toHaveBeenCalled(); + expect([...spectrumRendererHarness.render.mock.calls.at(-1)![1]]).toEqual([...pixels]); + } + scopeControllerHarness.stream('hardware').clearMode(); + flushSync(); + expect(target.querySelector('.spectrum-area canvas')).toBeNull(); + }); + + it('shows the refusal through panel status alongside the live scope', async () => { + const target = mountPanel(); + emitFrame(); await flushWaterfallPaint(); + const stream = scopeControllerHarness.stream('hardware'); + stream.handleMessage({ type: 'error', message: 'View negotiation refused' }); + flushSync(); + expect(target.querySelector('[data-testid="toolbar-scope-status"]')?.textContent).toContain('Scope view refused'); + expect(target.querySelector('[data-testid="toolbar-scope-status"]')?.textContent).toContain('View negotiation refused'); + expect(target.querySelector('.spectrum-area canvas')).not.toBeNull(); + const request = stream.subscription!; + stream.handleMessage({ type: 'granted', ...request, + view: { startHz: 14_000_000, endHz: 14_100_000 }, + coverage: { startHz: 14_000_000, endHz: 14_100_000 }, fps: request.maxFps }); + flushSync(); + expect(target.textContent).not.toContain('Scope view refused'); + }); +}); + describe('golden wire through actual WebSocket transport (MOR-4270)', () => { it('paints panel, LCD pixels and audio overlays from producer bytes with unchanged buffers', async () => { const previousController = scopeControllerHarness; diff --git a/frontend/tests/e2e/journey/operator-journey.spec.ts b/frontend/tests/e2e/journey/operator-journey.spec.ts index ab5d2c947..8bcba85ce 100644 --- a/frontend/tests/e2e/journey/operator-journey.spec.ts +++ b/frontend/tests/e2e/journey/operator-journey.spec.ts @@ -469,6 +469,9 @@ interface PanoramaWitness { pinned: boolean; baselineSignal: number; pixelLosses: number; + refusals: string[]; + changingMain: number; + advancingSpectrum: number; main: number; sub: number; audioWire: number; @@ -494,6 +497,7 @@ async function installPanoramaWitness(page: Page): Promise { const w: PanoramaWitness = { requests: [], grants: [], acknowledgments: [], active: false, pinned: false, baselineSignal: 0, pixelLosses: 0, main: 0, sub: 0, audioWire: 0, pcmBuffers: 0, + refusals: [], changingMain: 0, advancingSpectrum: 0, finiteSamples: 0, nonzeroSamples: 0, invalidSamples: 0, subChecks: 0, losses: 0, latestMain: -1, lastReceiver: -1, frames: [], samples: [], pcm: [], }; @@ -502,6 +506,8 @@ async function installPanoramaWitness(page: Page): Promise { let canvas: HTMLCanvasElement | null = null; let width = 0; let height = 0; + let mainPixels = ''; + let renderedSignal: number | null = null; const selector = '.spectrum-panel[data-waterfall]:not(.audio-fft)'; // The production canvas is transparent. Exclude its ten/five grid lines, // bottom labels, edges, and central tuning/passband overlays. The remaining @@ -568,6 +574,7 @@ async function installPanoramaWitness(page: Page): Promise { if (message.type === 'response' && message.ok === true) w.acknowledgments.push(String(message.id)); const grant = parseGrant(message); if (grant?.sourceId === 'radio') w.grants.push(grant); + if (path === '/api/v1/scope' && message.type === 'error') w.refusals.push(message.message); } catch { /* Non-JSON control traffic is irrelevant to this witness. */ } return; } @@ -581,6 +588,11 @@ async function installPanoramaWitness(page: Page): Promise { if (!parsed.ok || parsed.frame.source !== 'radio' || parsed.frame.outOfRange || !parsed.frame.pixels) return; const { receiver, width: pixels, viewStartHz: start, viewEndHz: end, seq: sequence } = parsed.frame; if (receiver !== 0 && receiver !== 1) return; + if (receiver === 0 && w.refusals.length > 0) { + const nextPixels = Array.from(parsed.frame.pixels).join(','); + if (mainPixels && nextPixels !== mainPixels) w.changingMain += 1; + mainPixels = nextPixels; + } if (receiver === 0) { w.main += 1; w.latestMain = sequence; } else w.sub += 1; w.lastReceiver = receiver; @@ -594,6 +606,11 @@ async function installPanoramaWitness(page: Page): Promise { const current = document.querySelector(selector); const currentCanvas = current?.querySelector('.spectrum-area canvas') ?? null; const rect = currentCanvas?.getBoundingClientRect(); + if (receiver === 0 && w.pinned && w.refusals.length > 0 && currentCanvas === canvas) { + const signal = signalPixels(currentCanvas); + if (renderedSignal !== null && signal !== renderedSignal) w.advancingSpectrum += 1; + renderedSignal = signal; + } if (receiver === 0 && w.lastReceiver === 0 && current && currentCanvas && rect && rect.width > 0 && rect.height > 0 && !w.pinned) { const signal = signalPixels(currentCanvas); if (signal < 20) return; @@ -711,7 +728,9 @@ panoramaTest.describe('simulated mixed receiver panorama with real PCM', () => { expect(before.nonzeroSamples).toBeGreaterThan(0); expect(before.invalidSamples).toBe(0); const modeKey = page.getByTestId('standard-mode-LSB').getByRole('radio'); + await expect.poll(async () => (await panoramaWitness(page)).pinned).toBe(true); await modeKey.click(); + await expect.poll(async () => (await panoramaWitness(page)).refusals.length).toBeGreaterThan(0); await expect.poll(() => sentParams(page, 'set_mode')).toContainEqual({ mode: 'LSB', receiver: 0 }); await expectServerMain(page, { mode: 'LSB' }); await expect(modeKey).toHaveAttribute('aria-checked', 'true'); @@ -750,6 +769,9 @@ panoramaTest.describe('simulated mixed receiver panorama with real PCM', () => { { message: 'Actual MAIN signal must render while mixed scope, PCM and controls are healthy' }).toBeGreaterThanOrEqual(20); await expect.poll(async () => (await panoramaWitness(page)).subChecks).toBeGreaterThanOrEqual(20); const rendered = await panoramaWitness(page); + expect(rendered.changingMain, 'MAIN wire pixels must change after negotiation refusal').toBeGreaterThan(2); + expect.soft(rendered.advancingSpectrum, 'Mounted MAIN spectrum must advance after refusal across SUB-only intervals').toBeGreaterThan(2); + await expect.soft(page.locator('[data-testid="toolbar-scope-status"]')).toContainText('Scope view refused'); expect(rendered.pixelLosses, 'Actual MAIN signal pixels cleared during a SUB-only interval').toBe(0); expect(rendered.losses, 'MAIN canvas disappeared or changed geometry after valid SUB before next MAIN').toBe(0); } finally { diff --git a/tests/e2e/journey_web_server.py b/tests/e2e/journey_web_server.py index 29fa0062b..88305351e 100644 --- a/tests/e2e/journey_web_server.py +++ b/tests/e2e/journey_web_server.py @@ -193,10 +193,12 @@ async def run() -> None: for template in templates: assert len(template) == 711 and template[7:9] == b"\x01\x01" assert template[-1] == 0xFD - amplitude = bytes( - 15 + ((index + receiver * 17) % 90) // 3 for index in range(689) - ) - shaped.append(template[:21] + amplitude + template[-1:]) + for phase in range(2): + amplitude = bytes( + 15 + phase * 25 + ((index + receiver * 17) % 90) // 3 + for index in range(689) + ) + shaped.append(template[:21] + amplitude + template[-1:]) simulator._scope_templates[receiver] = shaped await simulator.start() radio = IcomRadio( From 1271cadacfa71f47e8b9ba34b463ba03b8e34596 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Mon, 9 Nov 2026 03:43:21 -0500 Subject: [PATCH 62/62] fix(MOR-4274): retain live spectrum and display view refusals Use geometry retirement for sample cleanup and the existing toolbar status slot for negotiation errors. Co-Authored-By: Claude Opus 5.5 --- .../components/spectrum/SpectrumPanel.svelte | 17 +++++++++++++++-- frontend/src/lib/i18n/locales/en-US.json | 1 + frontend/src/lib/i18n/locales/ja-JP.json | 1 + frontend/src/lib/i18n/locales/ru-RU.json | 1 + .../tests/e2e/journey/operator-journey.spec.ts | 3 +++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/spectrum/SpectrumPanel.svelte b/frontend/src/components/spectrum/SpectrumPanel.svelte index dab7ecd66..ed8ffc997 100644 --- a/frontend/src/components/spectrum/SpectrumPanel.svelte +++ b/frontend/src/components/spectrum/SpectrumPanel.svelte @@ -49,6 +49,7 @@ import { getManagedAppTxController } from '../../lib/runtime/tx-controller/managed-app-host'; import type { ManagedTxState } from '../../lib/runtime/tx-controller/managed-state'; import SpectrumToolbar from './SpectrumToolbar.svelte'; + import StatusIndicator from '../../lib/Button/StatusIndicator.svelte'; import { getBandPlanRegion, loadBandPlanConfig } from '$lib/stores/band-plan-region.svelte'; import BandPlanOverlay from './BandPlanOverlay.svelte'; import EiBiBrowser from './EiBiBrowser.svelte'; @@ -1304,6 +1305,7 @@ // R53: the panel keeps no pixels across a drop — replay may only paint a // frame the sink actually delivered to THIS display since the last drop. let samplePlaneLive = false; + let scopeError = $state(null); let cachedWhileOff = false; let acceptScopeFrame: ((frame: ScopeFrame, slotKey: string) => void) | null = null; @@ -1359,7 +1361,7 @@ clearSample(); if (sourceIsAudio) runtime.scope.registerPresentationDriver(presentationResources); const scopeStream = runtime.scope.stream(sourceIsAudio ? 'audio_fft' : 'hardware'); - const stopError = scopeStream.error.subscribe(error => { if (error) clearSample(); }); + const stopError = scopeStream.error.subscribe(error => { scopeError = error; }); const stopGeometry = scopeStream.geometry.subscribe((geometry) => { frameGeometry = geometry; }); cachedWhileOff = false; const acceptFrame = (frame: ScopeFrame, slotKey: string) => { @@ -1471,6 +1473,15 @@ {/snippet} + {#snippet panelScopeStatus()} + {#if sdrSource}{@render sdrToolbarStatus()} + {:else if scopeStatusIndicator}{@render scopeStatusIndicator()}{/if} + {#if scopeError} + + + + {/if} + {/snippet} {#if traceCandidate}
@@ -1492,8 +1503,9 @@ tab instead. An audio-FFT source keeps its original no-toolbar branch. --> {:else if !hideToolbar} - + {/if} + {#if (audioFft || hideToolbar) && scopeError}{@render panelScopeStatus()}{/if}
+ .scope-refusal { min-width: 0; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .waterfall-history { position: absolute; inset: 0; } .scope-hatch { position: absolute; diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index a66e0b7c2..109b354c9 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -116,6 +116,7 @@ "core.overlay.scopeSwitchToCtr": "Switch to CTR", "core.scope.webglRequired": "WebGL required for the waterfall", "core.scope.presets": "Scope presets", + "core.scope.viewRefused": "Scope view refused: {reason}", "core.scope.preset.follow": "Follow VFO", "core.scope.zoomIn": "Zoom in the panorama view", "core.scope.zoomOut": "Zoom out the panorama view", diff --git a/frontend/src/lib/i18n/locales/ja-JP.json b/frontend/src/lib/i18n/locales/ja-JP.json index e06563766..c972df083 100644 --- a/frontend/src/lib/i18n/locales/ja-JP.json +++ b/frontend/src/lib/i18n/locales/ja-JP.json @@ -140,6 +140,7 @@ "core.overlay.scopeSwitchToCtr": "CTR に切り替え", "core.scope.webglRequired": "ウォーターフォールには WebGL が必要です", "core.scope.presets": "スコープのプリセット", + "core.scope.viewRefused": "スコープ表示の要求が拒否されました: {reason}", "core.scope.preset.follow": "VFO に追従", "core.scope.zoomIn": "パノラマ表示を拡大", "core.scope.zoomOut": "パノラマ表示を縮小", diff --git a/frontend/src/lib/i18n/locales/ru-RU.json b/frontend/src/lib/i18n/locales/ru-RU.json index ce73eddba..cdab1ee5f 100644 --- a/frontend/src/lib/i18n/locales/ru-RU.json +++ b/frontend/src/lib/i18n/locales/ru-RU.json @@ -116,6 +116,7 @@ "core.overlay.scopeSwitchToCtr": "Переключить в CTR", "core.scope.webglRequired": "Для водопада требуется WebGL", "core.scope.presets": "Наблюдение панорамы", + "core.scope.viewRefused": "Запрос вида панорамы отклонён: {reason}", "core.scope.preset.follow": "Следовать за VFO", "core.scope.zoomIn": "Сузить обзор панорамы", "core.scope.zoomOut": "Расширить обзор панорамы", diff --git a/frontend/tests/e2e/journey/operator-journey.spec.ts b/frontend/tests/e2e/journey/operator-journey.spec.ts index 8bcba85ce..d6d0ec26d 100644 --- a/frontend/tests/e2e/journey/operator-journey.spec.ts +++ b/frontend/tests/e2e/journey/operator-journey.spec.ts @@ -768,10 +768,13 @@ panoramaTest.describe('simulated mixed receiver panorama with real PCM', () => { await expect.poll(async () => (await panoramaWitness(page)).baselineSignal, { message: 'Actual MAIN signal must render while mixed scope, PCM and controls are healthy' }).toBeGreaterThanOrEqual(20); await expect.poll(async () => (await panoramaWitness(page)).subChecks).toBeGreaterThanOrEqual(20); + await expect.poll(async () => (await panoramaWitness(page)).advancingSpectrum, + { message: 'Mounted MAIN spectrum must advance after refusal across SUB-only intervals' }).toBeGreaterThan(2); const rendered = await panoramaWitness(page); expect(rendered.changingMain, 'MAIN wire pixels must change after negotiation refusal').toBeGreaterThan(2); expect.soft(rendered.advancingSpectrum, 'Mounted MAIN spectrum must advance after refusal across SUB-only intervals').toBeGreaterThan(2); await expect.soft(page.locator('[data-testid="toolbar-scope-status"]')).toContainText('Scope view refused'); + await expect(page.locator('.spectrum-panel').getByRole('status').filter({ hasText: 'Scope view refused' })).toBeVisible(); expect(rendered.pixelLosses, 'Actual MAIN signal pixels cleared during a SUB-only interval').toBe(0); expect(rendered.losses, 'MAIN canvas disappeared or changed geometry after valid SUB before next MAIN').toBe(0); } finally {