diff --git a/changelog.d/1118-cctv-snapshot-fallback-provenance.md b/changelog.d/1118-cctv-snapshot-fallback-provenance.md new file mode 100644 index 000000000..648891785 --- /dev/null +++ b/changelog.d/1118-cctv-snapshot-fallback-provenance.md @@ -0,0 +1,3 @@ +- Label camera-tool Street View fallbacks as historical Google imagery rather + than the current camera view; keep unknown frame provenance explicit + (rudycelekli, #1118). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5671f41ec..9163a5a21 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1370,6 +1370,14 @@ outside its promise, so an unusual mirror status cannot crash the server. Node-only package entries expose both provider factories. Routes, payloads, fallback behavior and existing dev/preview hook registration are preserved; browser rendering is unchanged. +The portable frame source retains the proxy's known `X-CCTV-Source` kind. Camera +snapshot tools label Street View fallback imagery as historical and credit Google, +while upstream frames retain their current-view label and catalog attribution. +Synthetic placeholders are unavailable as snapshots. Injected frame sources may +supply `sourceKind`; missing or unknown provenance keeps `current_view` null and +does not claim the image is current. The `current_view` field distinguishes camera-feed +provenance from historical fallback imagery; it does not measure capture time or +image freshness. ## CCTV missing numeric fields diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401058d91..3d31fe78b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -273,7 +273,7 @@ and the development server's internal routes. See SECURITY.md. | `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | | `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first | | `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first | -| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content | +| `get_cctv_snapshot` | `cctv` | An image from one camera, returned as image content; Street View fallbacks are labelled historical | | `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° | | `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs | | `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) | diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js index 0dddce1b4..3b628954b 100644 --- a/src/layers/cctv/source.js +++ b/src/layers/cctv/source.js @@ -3,6 +3,12 @@ import { FRAME_ENDPOINT, MEDIA_ENDPOINT, } from './sourcePolicy.js'; +const FRAME_SOURCE_KINDS = new Set([ + 'upstream-image', + 'hls-pull', + 'streetview', + 'synthetic', +]); function safeNumber(value, fallback = NaN) { const n = Number(value); return Number.isFinite(n) ? n : fallback; @@ -51,7 +57,7 @@ export function createCctvSource({ }, getFrameUrl: frameUrlFor, getMediaUrl: mediaUrlFor, - /** Read one current frame through the registered frame endpoint. */ + /** Read a registered frame and its provenance, including fallback frames. */ async getFrame(camera, { signal } = {}) { signal?.throwIfAborted(); const response = await fetchImpl(frameUrlFor(camera), { @@ -62,7 +68,12 @@ export function createCctvSource({ const bytes = new Uint8Array(await response.arrayBuffer()); signal?.throwIfAborted(); const type = response.headers.get('content-type') || ''; - return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + const sourceKind = response.headers.get('x-cctv-source'); + return { + contentType: type.split(';')[0].trim().toLowerCase(), + bytes, + sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null, + }; }, }; } diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index 0103c03de..a4b276d21 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => { }); await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/); }); + +test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => { + for (const kind of [ + 'upstream-image', + 'hls-pull', + 'streetview', + 'synthetic', + 'other', + null, + ]) { + const source = createCctvSource({ + fetchImpl: async () => + new Response(new Uint8Array([1]), { + headers: { + 'Content-Type': 'image/png', + ...(kind ? { 'X-CCTV-Source': kind } : {}), + }, + }), + }); + const frame = await source.getFrame(camera); + assert.equal(frame.sourceKind, kind === 'other' ? null : kind); + } +}); diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index 4879d57c5..a03e88bb4 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({ export const getCctvSnapshot = defineTool({ name: 'get_cctv_snapshot', - title: 'Current camera view', + title: 'Camera snapshot', description: - 'The current image from one public camera, by the id from find_cctv_cameras.', + 'An image from one public camera, by the id from find_cctv_cameras. ' + + 'Street View fallback imagery is historical, not the current camera view; ' + + 'unknown frame provenance is explicit.', inputSchema: { type: 'object', properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } }, @@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({ `No camera has id ${args.camera_id}; use find_cctv_cameras first`, ); const frame = await services.cctv.getFrame(camera, { signal }); - if (!FRAME_TYPES.has(frame.contentType)) + if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType)) throw new ToolError( 'unavailable', `Camera ${camera.name || camera.id} has no still image right now`, @@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({ 'The camera image is too large to return', ); const name = text(camera.name) || camera.id; + const fallback = frame.sourceKind === 'streetview'; + const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind); + const credit = fallback ? 'Google Street View' : text(camera.credit); + const label = fallback + ? 'Street View fallback for' + : current + ? 'Current view from' + : 'Image from'; return { - summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`, + summary: + `${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` + + (fallback + ? ' Historical imagery; the current camera frame is unavailable.' + : current + ? '' + : ' Frame provenance is unknown.'), data: { view: suggestView(services, { point: { lat: camera.lat, lon: camera.lon }, @@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({ id: camera.id, name, city: text(camera.city), - provider: text(camera.provider), - credit: text(camera.credit), + provider: fallback ? 'Google Street View' : text(camera.provider), + credit, + source_kind: frame.sourceKind ?? null, + current_view: current ? true : fallback ? false : null, mime_type: frame.contentType, bytes: frame.bytes.byteLength, }, diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index a024191e7..28c037a57 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -30,7 +30,13 @@ const cameras = [ { id: 'no-position', name: 'Unplaced' }, ]; const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({ +const cctv = ( + frame = { + contentType: 'image/png', + bytes: PNG, + sourceKind: 'upstream-image', + }, +) => ({ frames: [], async getCatalog() { return { sources: cameras }; @@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => { }); }); +test('mounted camera fallback provenance survives the source and MCP image result', async (t) => { + const { cctvProxy } = await import('../../../server/providers/cctv.js'); + const { createCctvSource } = await import('../../layers/cctv/source.js'); + const camera = { + id: 'fixture-camera', + name: 'Fixture crossing', + city: 'Fixture City', + provider: 'Fixture transport agency', + credit: 'Fixture transport agency', + lat: 30.2685, + lon: -97.7425, + headingDeg: 180, + fovDeg: 60, + url: 'https://camera.example/current.png', + feedType: 'image', + }; + const environment = { + CCTV_SOURCES_JSON: JSON.stringify([camera]), + CCTV_PREFER_AUSTIN: '0', + CCTV_FORCE_AUSTIN: '0', + CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json', + GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key', + GOOGLE_MAPS_API_KEY: '', + }; + for (const [name, value] of Object.entries(environment)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + process.env[name] = value; + } + t.mock.method(console, 'warn', () => {}); + let upstreamAvailable = false; + const calls = []; + t.mock.method(globalThis, 'fetch', async (raw) => { + const url = new URL(raw); + calls.push(url.origin); + if (url.origin === 'https://camera.example') + return upstreamAvailable + ? new Response(PNG, { headers: { 'Content-Type': 'image/png' } }) + : new Response(null, { status: 503 }); + assert.equal( + url.origin, + 'https://maps.googleapis.com', + 'no actual upstream is contacted', + ); + assert.equal(url.searchParams.get('key'), 'fixture-key'); + return new Response(PNG, { headers: { 'Content-Type': 'image/png' } }); + }); + let middleware; + cctvProxy().configureServer({ + middlewares: { + use(route, handler) { + assert.equal(route, '/api/cctv'); + middleware = handler; + }, + }, + }); + const source = createCctvSource({ + fetchImpl: async (path) => { + assert.ok(path.startsWith('/api/cctv/')); + const response = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = body; + }, + }; + await middleware( + { url: path.slice('/api/cctv'.length), method: 'GET', headers: {} }, + response, + ); + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + }, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const fallback = await catalog.call('get_cctv_snapshot', { + camera_id: camera.id, + }); + assert.equal(fallback.data.source_kind, 'streetview'); + assert.equal(fallback.data.current_view, false); + assert.equal(fallback.data.credit, 'Google Street View'); + assert.match(fallback.summary, /Street View fallback/); + assert.match(fallback.summary, /Historical imagery/); + assert.doesNotMatch( + fallback.summary, + /Current view|courtesy of Fixture transport agency/, + ); + assert.equal( + fallback.images.length, + 1, + 'the historical image stays available with its caveat', + ); + assert.deepEqual(calls, [ + 'https://camera.example', + 'https://maps.googleapis.com', + ]); + upstreamAvailable = true; + const current = await catalog.call('get_cctv_snapshot', { + camera_id: camera.id, + }); + assert.equal(current.data.source_kind, 'upstream-image'); + assert.equal(current.data.current_view, true); + assert.equal(current.data.credit, camera.credit); + assert.match(current.summary, /^Current view from/); + const server = createMcpServer({ catalog, name: 'test', version: '1' }); + upstreamAvailable = false; + const result = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } }, + }); + assert.ok( + result.result.content.some( + (entry) => entry.type === 'text' && /Historical imagery/.test(entry.text), + ), + ); + assert.ok(result.result.content.some((entry) => entry.type === 'image')); + process.env.GOOGLE_MAPS_SERVER_API_KEY = ''; + const placeholder = await source.getFrame(camera); + assert.equal(placeholder.sourceKind, 'synthetic'); + assert.equal(placeholder.contentType, 'image/svg+xml'); + await assert.rejects( + catalog.call('get_cctv_snapshot', { camera_id: camera.id }), + (error) => error.code === 'unavailable', + ); + const unavailable = await server.handle({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } }, + }); + assert.equal(unavailable.result.isError, true); + assert.ok( + !unavailable.result.content.some((entry) => entry.type === 'image'), + ); +}); + +test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => { + const synthetic = composeCatalog({ + tools: coreTools, + services: { + cctv: cctv({ + contentType: 'image/png', + bytes: PNG, + sourceKind: 'synthetic', + }), + }, + }); + await assert.rejects( + synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }), + (error) => error.code === 'unavailable', + ); + const unknown = await composeCatalog({ + tools: coreTools, + services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) }, + }).call('get_cctv_snapshot', { camera_id: 'austin-1' }); + assert.equal(unknown.data.current_view, null); + assert.equal( + unknown.data.credit, + cameras[0].credit, + 'catalog attribution is preserved', + ); + assert.match(unknown.summary, /provenance is unknown/); + assert.doesNotMatch(unknown.summary, /Current view/); +}); + test('unknown cameras, video-only feeds and oversized frames are reported', async () => { const catalog = (frame) => composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });