diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md new file mode 100644 index 000000000..ed7552591 --- /dev/null +++ b/changelog.d/1205-cctv-tool-routing.md @@ -0,0 +1,3 @@ +- Report actual outcomes when voice enables or disables CCTV through the shared + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bdc20fad9..eacfa1147 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -633,6 +633,14 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. +`createGevActionRunner` routes `control_cctv` enable/disable through the shared +layer visibility lifecycle. Results distinguish successful changes from +manager-reported failures or cancellation and report enabled state and lifecycle +uncertainty. A caller-supplied abort signal reaches the manager; a late abort +preserves an already committed change. Missing-layer errors and other CCTV +actions retain their existing behavior. The exported `controlCctv` helper refuses direct +enable/disable requests and directs callers to `set_layer_visibility`. + `src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`. Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged. diff --git a/src/voice/cctvRouting.test.mjs b/src/voice/cctvRouting.test.mjs new file mode 100644 index 000000000..0df5dd12c --- /dev/null +++ b/src/voice/cctvRouting.test.mjs @@ -0,0 +1,496 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DataLayerManager } from '../data/manager.js'; +import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; +import { controlCctv, createGevActionRunner } from './gevActions.js'; + +const originalWindow = globalThis.window; +before(() => { + globalThis.window ??= { clearTimeout, setTimeout, requestIdleCallback: null }; +}); +after(() => { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; +}); + +function viewerFixture() { + return { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } }, + }; +} + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function runnerFixture(t, dataManager, options = {}) { + const runner = createGevActionRunner({ + viewer: viewerFixture(), + styleManager: {}, + dataManager, + ...options, + }); + t.after(() => runner.dispose()); + return runner; +} + +// Only the inert module's lifecycle completion is controlled. The manager's +// intent queue, cancellation, compensation, events, and runner are production. +async function lifecycleFixture(t, targetEnabled) { + const viewer = viewerFixture(); + const manager = new DataLayerManager(viewer); + const calls = []; + const gates = []; + const pending = []; + let nextGate = null; + let moduleEnabled = false; + const phase = targetEnabled ? 'enable' : 'disable'; + const lifecycle = async (name, options = {}) => { + calls.push(name); + if (name === phase && nextGate) { + const gate = nextGate; + nextGate = null; + gate.entered.resolve(options.signal); + await gate.release.promise; + if (gate.outcome instanceof Error) throw gate.outcome; + if (gate.outcome === false) return false; + } + moduleEnabled = name === 'enable'; + return true; + }; + manager.register({ + id: 'cctv', + name: 'CCTV', + source: 'test', + updateInterval: -1, + init: async () => true, + enable: (_viewer, options) => lifecycle('enable', options), + disable: (_viewer, options) => lifecycle('disable', options), + update: async () => true, + getStats: () => ({ count: 0, lastUpdate: 0 }), + }); + if (!targetEnabled) + assert.equal(await manager.setEnabled('cctv', true), true); + calls.length = 0; + const events = []; + const unsubscribe = manager.subscribe((event) => events.push(event)); + const runner = runnerFixture(t, manager, { viewer }); + t.after(async () => { + for (const gate of gates) gate.release.resolve(); + await Promise.allSettled(pending); + unsubscribe(); + await manager.destroyAll(); + }); + return { + manager, + events, + calls, + get moduleEnabled() { + return moduleEnabled; + }, + gate(outcome = true) { + const gate = { entered: deferred(), release: deferred(), outcome }; + gates.push(gate); + nextGate = gate; + return gate; + }, + run(action, options = {}) { + const work = runner('control_cctv', { action }, options); + pending.push(work); + return work; + }, + }; +} + +function assertLifecycle(result, manager, enabled, uncertain = false) { + assert.equal(result.action, 'control_cctv'); + assert.equal(result.layerId, 'cctv'); + assert.equal(result.enabled, enabled); + assert.equal(result.lifecycleState, enabled ? 'enabled' : 'disabled'); + assert.equal(result.lifecycleUncertain, uncertain); + assert.deepEqual(manager.getLayerLifecycleState('cctv'), { + enabled, + lifecycleState: result.lifecycleState, + uncertain, + }); +} + +const terminalTypes = new Set([ + 'visibility', + 'visibility-cancelled', + 'visibility-failed', + 'visibility-blocked', +]); + +for (const action of ['enable', 'disable']) { + const targetEnabled = action === 'enable'; + for (const outcome of [ + 'success', + 'false', + 'throw', + 'caller-abort', + 'resource-abort', + 'late-abort', + ]) { + test( + `CCTV ${action}: ${outcome} preserves the manager outcome`, + { timeout: 2000 }, + async (t) => { + const h = await lifecycleFixture(t, targetEnabled); + const controller = new AbortController(); + const failure = + outcome === 'resource-abort' + ? Object.assign(new Error('fixture resource ended'), { + name: 'AbortError', + }) + : outcome === 'throw' + ? new Error('fixture lifecycle failed') + : null; + const gate = h.gate(failure || (outcome === 'false' ? false : true)); + let lateAbortObserved = false; + if (outcome === 'late-abort') { + t.after( + h.manager.subscribe((event) => { + if (event.type === 'visibility' && event.origin === 'voice') { + lateAbortObserved = true; + controller.abort(); + } + }), + ); + } + let settled = false; + // Uppercase exercises the existing case-insensitive action contract. + const work = h.run( + outcome === 'success' ? action.toUpperCase() : action, + { + signal: controller.signal, + isCurrent: () => !controller.signal.aborted, + }, + ); + void work.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + const moduleSignal = await gate.entered.promise; + assert.equal( + settled, + false, + 'the tool must wait for lifecycle completion', + ); + if (outcome === 'caller-abort') { + controller.abort(); + assert.ok( + moduleSignal, + 'the manager must receive a lifecycle signal', + ); + assert.equal( + moduleSignal.aborted, + true, + 'caller abort must reach the manager lifecycle', + ); + } + gate.release.resolve(); + const result = await work; + const committed = outcome === 'success' || outcome === 'late-abort'; + const cancelled = + outcome === 'caller-abort' || outcome === 'resource-abort'; + assert.equal(result.ok, committed); + assert.equal(result.cancelled, cancelled ? true : undefined); + assertLifecycle( + result, + h.manager, + committed ? targetEnabled : !targetEnabled, + !targetEnabled && (outcome === 'false' || outcome === 'throw'), + ); + assert.equal( + h.moduleEnabled, + committed ? targetEnabled : !targetEnabled, + 'the module must match the committed or compensated manager state', + ); + if (cancelled) { + assert.equal(result.cancellationReason, outcome); + assert.equal(result.phase, action); + assert.equal(result.successorIntentEpoch, null); + assert.equal(result.successorEnabled, null); + assert.equal(result.successorOrigin, null); + } else if (!committed) { + assert.equal(result.error, `Could not ${action} the requested layer`); + } + const terminal = h.events.filter( + (event) => event.origin === 'voice' && terminalTypes.has(event.type), + ); + assert.equal( + terminal.length, + 1, + 'each voice intent has one terminal manager event', + ); + assert.equal( + terminal[0].type, + committed + ? 'visibility' + : cancelled + ? 'visibility-cancelled' + : 'visibility-failed', + ); + if (outcome === 'resource-abort') + assert.equal(controller.signal.aborted, false); + if (outcome === 'late-abort') assert.equal(lateAbortObserved, true); + }, + ); + } + + test( + `CCTV ${action}: already-aborted caller cannot enter lifecycle work`, + { timeout: 2000 }, + async (t) => { + const h = await lifecycleFixture(t, targetEnabled); + const controller = new AbortController(); + controller.abort(); + const result = await h.run(action, { signal: controller.signal }); + assert.equal(result.ok, false); + assert.equal(result.cancelled, true); + assert.equal(result.cancellationReason, 'caller-abort'); + assert.equal(result.phase, 'queued'); + assertLifecycle(result, h.manager, !targetEnabled); + assert.equal(h.moduleEnabled, !targetEnabled); + assert.deepEqual(h.calls, []); + assert.equal( + h.events.filter((event) => terminalTypes.has(event.type)).length, + 1, + ); + }, + ); +} + +test( + 'CCTV toggles retain missing-module errors without calling visibility', + { timeout: 2000 }, + async (t) => { + for (const layers of [new Map(), new Map([['cctv', {}]])]) { + const runner = runnerFixture(t, { + layers, + getAll: () => [], + setEnabled: () => + assert.fail('missing CCTV must not dispatch visibility'), + }); + for (const action of ['enable', 'disable']) { + assert.deepEqual(await runner('control_cctv', { action }), { + ok: false, + action: 'control_cctv', + error: 'CCTV layer unavailable', + }); + } + } + }, +); + +test('exported controlCctv refuses toggles without calling the manager', async () => { + const manager = { + layers: new Map([['cctv', { module: {} }]]), + setEnabled: () => assert.fail('direct helper must not change visibility'), + isEnabled: () => assert.fail('direct toggle must not read enabled state'), + }; + for (const action of ['ENABLE', 'disable']) { + const result = await controlCctv(manager, { action }); + assert.equal(result.ok, false); + assert.equal(result.action, 'control_cctv'); + assert.match(result.error, /set_layer_visibility/); + } +}); + +test( + 'CCTV fallback managers report rejection, transitional state, and uncertainty', + { timeout: 2000 }, + async (t) => { + for (const enabled of [true, false]) { + const action = enabled ? 'enable' : 'disable'; + for (const mode of [ + 'rejection', + 'mismatch', + 'transitional', + 'uncertain', + ]) { + const actualEnabled = + mode === 'rejection' || mode === 'mismatch' ? !enabled : enabled; + const lifecycleState = + mode === 'transitional' + ? enabled + ? 'enabling' + : 'disabling' + : actualEnabled + ? 'enabled' + : 'disabled'; + const manager = { + layers: new Map([['cctv', { module: {} }]]), + getAll: () => [{ id: 'cctv', name: 'CCTV' }], + isEnabled: () => actualEnabled, + getLayerLifecycleState: () => ({ + enabled: actualEnabled, + lifecycleState, + uncertain: mode === 'uncertain', + }), + async setEnabled() { + if (mode === 'rejection') throw new Error('adapter rejected'); + return true; + }, + }; + const runner = runnerFixture(t, manager); + const result = await runner('control_cctv', { action }); + assert.equal(result.ok, false, `${action}: ${mode}`); + assert.equal(result.action, 'control_cctv'); + assert.equal(result.enabled, actualEnabled); + assert.equal(result.lifecycleState, lifecycleState); + assert.equal(result.lifecycleUncertain, mode === 'uncertain'); + assert.equal( + result.error, + mode === 'rejection' + ? 'adapter rejected' + : `Could not ${action} the requested layer`, + ); + } + } + }, +); + +test( + 'CCTV visibility composes only its outer speech envelope', + { timeout: 2000 }, + async (t) => { + let enabled = false; + const calls = []; + const runner = runnerFixture( + t, + { + layers: new Map([['cctv', { module: {} }]]), + getAll: () => [{ id: 'cctv', name: 'CCTV' }], + isEnabled: () => enabled, + async setEnabled(_id, value) { + enabled = value; + return true; + }, + }, + { + speechBuilders: { + control_cctv(result, args) { + calls.push(['cctv', result.action, args.action]); + return { + say: 'CCTV response', + display: { title: 'CCTV', lines: [] }, + }; + }, + set_layer_visibility() { + calls.push(['generic']); + return { say: 'Layer response' }; + }, + }, + }, + ); + const result = await runner('control_cctv', { action: 'enable' }); + assert.equal(result.ok, true); + assert.equal(result.action, 'control_cctv'); + assert.equal(result.say, 'CCTV response'); + assert.deepEqual(result.display, { title: 'CCTV', lines: [] }); + assert.deepEqual(calls, [['cctv', 'control_cctv', 'enable']]); + }, +); + +test( + 'CCTV non-toggle actions retain camera navigation and durable coverage', + { timeout: 2000 }, + async (t) => { + const calls = []; + const cameras = [ + { id: 'a', name: 'Camera A' }, + { id: 'b', name: 'Camera B' }, + ]; + let activeCameraId = 'a'; + let coverageMode = 'viewshed'; + const module = { + getUIState: () => ({ + cameras, + activeCameraId, + activeCamera: cameras.find((camera) => camera.id === activeCameraId), + coverageMode, + showCoverage: coverageMode !== 'off', + }), + selectCamera(id) { + calls.push(['select', id]); + activeCameraId = id; + return true; + }, + cycleCamera(step) { + calls.push(['cycle', step]); + activeCameraId = step > 0 ? 'b' : 'a'; + return activeCameraId; + }, + focusNearest(options) { + calls.push(['nearest', options]); + activeCameraId = 'a'; + return 'a'; + }, + focusCamera(id, duration) { + calls.push(['focus', id, duration]); + return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW; + }, + }; + let superseded = 0; + const runner = runnerFixture( + t, + { + layers: new Map([['cctv', { module }]]), + isEnabled: () => true, + setEnabled: () => + assert.fail('non-toggle action must not dispatch visibility'), + setLayerParams(id, params, options) { + calls.push(['params', id, params, options]); + coverageMode = params.coverageMode; + }, + }, + { + styleManager: { + supersedeDeferredNavigation() { + superseded += 1; + }, + }, + }, + ); + for (const args of [ + { action: 'select', cameraQuery: 'Camera B' }, + { action: 'next' }, + { action: 'prev' }, + { action: 'nearest' }, + { action: 'focus' }, + ]) { + const result = await runner('control_cctv', args); + assert.equal(result.ok, false); + assert.equal(result.action, 'control_cctv'); + assert.match(result.error, /tracking holds the view/); + } + const coverage = await runner('control_cctv', { action: 'coverage' }); + assert.equal(coverage.ok, true); + assert.equal(coverage.coverageMode, 'off'); + assert.equal(superseded, 5, 'coverage must not acquire camera navigation'); + assert.deepEqual(calls, [ + ['select', 'b'], + ['focus', 'b', 1.8], + ['cycle', 1], + ['focus', 'b', 1.8], + ['cycle', -1], + ['focus', 'a', 1.8], + ['nearest', { focus: false }], + ['focus', 'a', 1.8], + ['focus', 'a', undefined], + ['params', 'cctv', { coverageMode: 'off' }, { origin: 'voice' }], + ]); + }, +); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index bf3e5b8b8..d8b63565f 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1092,6 +1092,18 @@ export function createGevActionRunner({ } if (name === 'control_cctv') { + const action = String(args.action || '').toLowerCase(); + if ( + (action === 'enable' || action === 'disable') && + dataManager.layers.get('cctv')?.module + ) { + const result = await runGevAction( + 'set_layer_visibility', + { layerId: 'cctv', enabled: action === 'enable' }, + runOptions, + ); + return { ...result, action: 'control_cctv' }; + } return controlCctv(dataManager, args, styleManager); } @@ -1432,13 +1444,10 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) { } if (action === 'enable' || action === 'disable') { - await dataManager.setEnabled('cctv', action === 'enable', { - origin: 'voice', - }); return { - ok: true, + ok: false, action: 'control_cctv', - enabled: dataManager.isEnabled('cctv'), + error: 'Enable or disable CCTV through set_layer_visibility', }; } if (!dataManager.isEnabled('cctv')) {