diff --git a/docs/api/scope.md b/docs/api/scope.md index 22da891ef..7cc563157 100644 --- a/docs/api/scope.md +++ b/docs/api/scope.md @@ -280,3 +280,27 @@ 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. 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/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/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/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/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/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(); 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); } } diff --git a/frontend/tests/e2e/journey/operator-journey.spec.ts b/frontend/tests/e2e/journey/operator-journey.spec.ts index ab5d2c947..d6d0ec26d 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'); @@ -749,7 +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 { 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 new file mode 100644 index 000000000..3c77641cd --- /dev/null +++ b/rigplane_testkit/icom/scope_control.py @@ -0,0 +1,80 @@ +"""Scope-control simulator using Core's CI-V response parsers.""" + +from rigplane.commands import ( + 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 import resolve_radio_profile +from rigplane_testkit.icom import MockIcomRadio +from typing import Any + + +class ScopeControlSimulator(MockIcomRadio): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._scope_10 = 0 + 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: bytes, from_addr: int) -> bytes | None: + 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]) + 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: + fixed_value = self.fixed.get( + key, + ScopeFixedEdge(int(f"{key[0]:02x}"), key[1], 14000000, 14350000), + ) + 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 + ) + 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/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..e5811f027 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 ( @@ -613,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. @@ -822,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() @@ -1378,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. @@ -1632,19 +1634,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..bfe3cce13 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 ( @@ -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, @@ -96,6 +98,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 +1189,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() @@ -3431,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: @@ -3468,8 +3486,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 +4023,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 +4143,54 @@ 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 +4300,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 @@ -4283,6 +4319,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) @@ -4333,7 +4370,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 @@ -4352,6 +4389,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 @@ -4366,12 +4408,16 @@ 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) 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/_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..3be7728aa 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,139 @@ 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, + validate=self._scope_setting_frame, + read_dual=self.get_scope_dual, + ) + 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) + _, 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 + ) + + def _scope_setting_frame( + self, name: ScopeSetting, value: ScopeValue, receiver: int + ) -> bytes: + 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 == "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( + 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) + 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}") + controls = self._scope_controls() + 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: + setattr(controls, name, value) + async def enable_scope( self, *, @@ -123,40 +263,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 pol == ScopeCompletionPolicy.VERIFY or 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 +304,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 +366,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.""" @@ -559,7 +609,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( @@ -570,28 +619,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.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 808cd56b4..1b4d5f489 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,8 @@ 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: ... + 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 +41,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 +79,51 @@ 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 + 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: + 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 +136,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) diff --git a/src/rigplane/runtime/scope_session.py b/src/rigplane/runtime/scope_session.py new file mode 100644 index 000000000..92c8e0eb6 --- /dev/null +++ b/src/rigplane/runtime/scope_session.py @@ -0,0 +1,503 @@ +"""One acknowledged scope-control session shared by SDK and web consumers.""" + +from __future__ import annotations + +import asyncio +import logging +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 + +logger = logging.getLogger(__name__) + +ScopeSetting: TypeAlias = Literal[ + "scope_on", "output_on", "mode", "span", "edge", "fixed_edge", "dual" +] +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 + dual: bool = False + + +@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, + 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, + silence_seconds: float = 5, + 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 + 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 = bool(tx_probe and tx_probe()) + 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 + self._event_tasks: set[asyncio.Task[None]] = set() + + @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)), + **({"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, + **({"dual": True} if receiver else {}), + ) + + 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() + + 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: + 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._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: + 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 ( + 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: + for name, value in settings.items(): + if name not in ( + "scope_on", + "output_on", + "mode", + "span", + "edge", + "fixed_edge", + "dual", + ): + 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 ( + 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 + self.observe(name, value, receiver) + + 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._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._applied.clear() + 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: ( + "dual", + "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._applied.get((receiver, name)) == value + and (receiver, name) not in self._uncertain + ): + 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 + and (receiver, name) not in self._uncertain + ): + self._applied[receiver, name] = value + continue + self._fixed[slot] = captured_fixed + elif name == "dual" and self._original is not None: + 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: + if receiver not in self._views: + self._views[receiver] = ( + await self._read_receiver(receiver) + 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 self._applied.get(key) == 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 + or (receiver, name) in self._uncertain + ): + 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: ... 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..d12b13f2d 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,20 @@ 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: + 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). @@ -1894,87 +1904,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 +2764,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..9fd10fba6 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,33 @@ 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 - ): - continue - 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: + session = self._radio_scope_session + if session is None or self._radio 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), + 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), + 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 +3774,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 +3785,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 diff --git a/tests/_scope_session_fake.py b/tests/_scope_session_fake.py new file mode 100644 index 000000000..9643af8fb --- /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, *, initial=None, **kwargs): + current = initial or 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/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( diff --git a/tests/runtime/test_scope_session.py b/tests/runtime/test_scope_session.py new file mode 100644 index 000000000..b9fa3b5e4 --- /dev/null +++ b/tests/runtime/test_scope_session.py @@ -0,0 +1,402 @@ +import pytest +import asyncio + +from rigplane.core.types import ScopeFixedEdge +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: + def __init__(self): + self.state = ScopeState(False, False) + self.views = [] + self.writes = [] + self.fail = False + + async def read(self): + return self.state + + 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") + if name != "fixed_edge": + self.state = replace(self.state, **{name: value}) + + 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.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)) + 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.read, writer.write, writer.read_fixed) + 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.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)) + 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.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)) + 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.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 writer.views == [("fixed_edge", fixed, 0), ("mode", 1, 0)] + await session.set_demand(ScopeDemand(0, 0)) + 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 + ) + 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 + + +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, + ) + 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)] + + +@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(): + + 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 + + +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) + + +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)] + + +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)] + + +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] + + +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 + + +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 + + +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 + + +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 + + +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 == [] + + +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 new file mode 100644 index 000000000..1ce5d1d84 --- /dev/null +++ b/tests/runtime/test_scope_session_review.py @@ -0,0 +1,293 @@ +"""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 +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, dual_receiver=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"] + 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, control + 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") + 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 + 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): + 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.enable_scope(policy="fast") + await radio.set_scope_span(3) + 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}, + } + ) + 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) + + +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 + + +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) + + +@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 + + +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 + + +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 diff --git a/tests/runtime/test_scope_session_wire.py b/tests/runtime/test_scope_session_wire.py new file mode 100644 index 000000000..6c16cd8f8 --- /dev/null +++ b/tests/runtime/test_scope_session_wire.py @@ -0,0 +1,224 @@ +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.scope_control import ScopeControlSimulator + from test_web_server import _addr + + 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() + 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} + ) + 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: + 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") + 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() + 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 + monkeypatch.setattr(radio, "_check_connected", lambda: None) + send = AsyncMock() + 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, + ) diff --git a/tests/scope/test_audio_scope_source.py b/tests/scope/test_audio_scope_source.py index fc2e555a5..d71e8f1e9 100644 --- a/tests/scope/test_audio_scope_source.py +++ b/tests/scope/test_audio_scope_source.py @@ -273,8 +273,11 @@ 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") available = True diff --git a/tests/scope/test_hub.py b/tests/scope/test_hub.py index 44ac600aa..547d80a22 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): @@ -639,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(source="radio")) + assert not source.callbacks + assert not hub._sources["radio"].clients + assert source.stops == 1 diff --git a/tests/scope/test_radio_scope_source.py b/tests/scope/test_radio_scope_source.py index a4847ef8b..109320c87 100644 --- a/tests/scope/test_radio_scope_source.py +++ b/tests/scope/test_radio_scope_source.py @@ -13,10 +13,13 @@ def __init__(self): self.writes = [] self.frequency = 14_100_000 + def on_frame(self, frame): + pass + 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 +119,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 +139,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 @@ -172,3 +175,66 @@ 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) + + +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) + + +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), + ] + + +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_civ_rx_coverage.py b/tests/test_civ_rx_coverage.py index 904d09291..2376f1dcc 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( @@ -7496,3 +7503,23 @@ 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) + ) diff --git a/tests/test_icom7610_serial_radio.py b/tests/test_icom7610_serial_radio.py index 1b92ae273..866377b8d 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"), ] @@ -2245,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 = [ @@ -2289,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() @@ -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 ( @@ -3718,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() diff --git a/tests/test_profile_command_binding.py b/tests/test_profile_command_binding.py index 8efdc7882..1f26f540c 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( 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..db6671d2c 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 @@ -3993,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 True + 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 @@ -4013,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(): @@ -4031,10 +4035,10 @@ 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 == 2 + assert radio.enable_scope.await_count == 1 radio.get_scope_session_state.assert_awaited_once() @@ -4047,13 +4051,16 @@ 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 == 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 @@ -4063,10 +4070,11 @@ 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 - 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 @@ -5835,34 +5844,52 @@ 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, _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 +5905,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 +5920,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 +5933,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 +5945,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 +5969,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 @@ -7021,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 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 3f68f6149..76deeb47a 100644 --- a/tests/test_rig_loader.py +++ b/tests/test_rig_loader.py @@ -3024,6 +3024,68 @@ 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_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.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 == () + 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}]", + ], + ) + 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" + ) + 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).""" diff --git a/tests/test_scope.py b/tests/test_scope.py index 298e90e24..58d2d20c2 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -928,7 +928,11 @@ 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 +941,76 @@ 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" 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_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() diff --git a/tests/test_supports_command.py b/tests/test_supports_command.py index 385b65aef..dfb85ba5d 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,14 @@ 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_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 @@ -416,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()', diff --git a/tests/test_web_managed_ptt_rejection.py b/tests/test_web_managed_ptt_rejection.py index bbdb43f37..48e1917ed 100644 --- a/tests/test_web_managed_ptt_rejection.py +++ b/tests/test_web_managed_ptt_rejection.py @@ -66,6 +66,9 @@ 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/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..666bb606e 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,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 0.0 <= virtual_clock() - srv._scope_started_at <= 10.0 # 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( @@ -3742,6 +3751,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 +3770,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 +3791,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 +3939,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 +3976,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( @@ -5485,3 +5493,52 @@ 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 = 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) + 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 + + +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) + 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.enable_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) diff --git a/tests/testkit/test_simulator_blank_scope_mor3690.py b/tests/testkit/test_simulator_blank_scope_mor3690.py index 1187ab352..51bac8142 100644 --- a/tests/testkit/test_simulator_blank_scope_mor3690.py +++ b/tests/testkit/test_simulator_blank_scope_mor3690.py @@ -118,10 +118,15 @@ 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() diff --git a/tests/web/test_scope_hub_channel.py b/tests/web/test_scope_hub_channel.py index 6bc09504c..a90ab5144 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,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 import MockIcomRadio + from rigplane_testkit.icom.scope_control import ( + ScopeControlSimulator as MockIcomRadio, + ) server = WebServer(None) writer = Writer() @@ -173,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 import 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)) @@ -192,7 +202,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 +306,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 rigplane_testkit.icom.scope_control import ScopeControlSimulator + + sim = ScopeControlSimulator(scope_stream=True) assembler = ScopeAssembler() native = None for raw in sim._scope_templates[0]: @@ -331,6 +346,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 +356,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 +424,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: @@ -424,35 +442,50 @@ 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 - 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 = SimpleNamespace(last_valid_frame_monotonic=virtual_clock()) + server._radio_scope_session = session + server._radio = radio + radio.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) 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()) assert decoded.out_of_range is out_of_range assert decoded.pixels is None assert decoded.coverage_start_hz == decoded.coverage_end_hz - await virtual_clock.elapse(0.1) + assert session.health(civ_flowing=True).last_frame_at == virtual_clock() + server._radio.last_valid_frame_monotonic = virtual_clock() + 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() + await virtual_clock.elapse(0.05) + assert server._scope_hidden_published is True finally: task.cancel() await asyncio.gather(task, return_exceptions=True) @@ -464,36 +497,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 @@ -570,8 +600,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 @@ -680,7 +713,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") @@ -703,8 +736,11 @@ async def set_output(self, enabled): 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) @@ -721,46 +757,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 +811,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 +836,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 +899,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 import 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 @@ -993,7 +1026,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..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 import MockIcomRadio + from rigplane_testkit.icom.scope_control import ( + ScopeControlSimulator as MockIcomRadio, + ) sim = MockIcomRadio(scope_stream=True) await sim.start() @@ -206,9 +208,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 +223,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,10 +233,9 @@ 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 server._scope_enabled + 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. for future in futures: @@ -253,18 +258,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 +276,29 @@ 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 + + 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 + + async def fixed(**kwargs): + await step("fixed_edge") - 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) - - monkeypatch.setattr(server.command_queue, "put_ordered", enqueue) + 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 +309,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 +322,16 @@ 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 +348,9 @@ 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 +362,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._scope_enabled - assert not server._scope_enable_failed + assert not server._radio_scope_session.enabled assert server._server is None listener.close.assert_called_once() listener.wait_closed.assert_awaited_once()