From 0a454815cbe2958184e3b1093917932eb1b4bad1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 13:31:31 -0700 Subject: [PATCH] feat: add a license plate reader camera tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add find_alpr_cameras over the layer's OpenStreetMap camera extracts: cameras in a US or Canadian area of at most 3° per side, nearest first, with operator, manufacturer and facing direction, and the source's attribution. Areas outside coverage or too wide are reported. --- docs/CURRENT-STATE.md | 2 +- docs/TOOLS.md | 1 + scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 9 ++- src/tools/index.js | 2 + src/tools/queries/surveillance.js | 91 +++++++++++++++++++++++ src/tools/queries/surveillance.test.mjs | 97 +++++++++++++++++++++++++ src/tools/services.js | 2 + 8 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/surveillance.js create mode 100644 src/tools/queries/surveillance.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 29d956cfb..3a330eb6c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -12,7 +12,7 @@ Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), ships (in an area, by identifier, and tracks), satellites (next pass over a point, and those overhead now), public cameras (including a -camera's current image), radio stations, place search, routing, bike-share +camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share stations, transit vehicles, road traffic flow, weather, weather map images (radar, satellite, lightning), wind, the most recent satellite image of an area, submarine cables, regional briefs, tropical cyclones, fire diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 383f6417c..403b37513 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -119,6 +119,7 @@ public feeds the sources already use. | `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a 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 | +| `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) | | `places_nearby` | `placeSearch` | Notable places around a point (Google Places) | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 206e5f739..2352e3df4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -882,5 +882,6 @@ "src/tools/localRoute.test.mjs", "src/tools/queries/atmosphere.test.mjs", "src/tools/queries/imagery.test.mjs", - "src/tools/queries/cables.test.mjs" + "src/tools/queries/cables.test.mjs", + "src/tools/queries/surveillance.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f97195d31..158624b8a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3112,7 +3112,8 @@ "src/layers/wind/model.js", "src/layers/wind/fields.js", "src/tools/queries/imagery.js", - "src/tools/queries/cables.js" + "src/tools/queries/cables.js", + "src/tools/queries/surveillance.js" ], "external": ["satellite.js"] }, @@ -3183,7 +3184,11 @@ "src/sources/windBody.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/model.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/alpr/source.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/records.js", + "src/layers/alpr/tileRecords.js" ], "external": ["@mapbox/vector-tile", "pbf"] } diff --git a/src/tools/index.js b/src/tools/index.js index d7ca50b4e..d1fff6f35 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -35,6 +35,7 @@ import { } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; import { openInGodsEyeView } from './queries/share.js'; +import { findAlprCameras } from './queries/surveillance.js'; import { findCctvCameras, findRadioStations, @@ -83,6 +84,7 @@ export const coreTools = Object.freeze([ satellitesOverhead, findCctvCameras, getCctvSnapshot, + findAlprCameras, findRadioStations, searchPlaces, placesNearby, diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js new file mode 100644 index 000000000..d6fbd346a --- /dev/null +++ b/src/tools/queries/surveillance.js @@ -0,0 +1,91 @@ +/** Automated license plate reader cameras mapped in OpenStreetMap. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; + +const MAX_SIDE_DEGREES = 3; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +export const findAlprCameras = defineTool({ + name: 'find_alpr_cameras', + title: 'License plate reader cameras', + description: + 'Automated license plate reader (ALPR) cameras mapped in OpenStreetMap ' + + 'in a US or Canadian area of at most 3° per side, nearest the center ' + + 'first, with operator, manufacturer and facing direction.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['alpr'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if ( + area.west > area.east || + area.north - area.south > MAX_SIDE_DEGREES || + area.east - area.west > MAX_SIDE_DEGREES + ) + throw new ToolError( + 'invalid_arguments', + `The area must be at most ${MAX_SIDE_DEGREES}° on each side and not cross the antimeridian`, + ); + const box = { + west: area.west, + south: area.south, + east: area.east, + north: area.north, + }; + const result = await services.alpr.fetch(box, signal); + if (result.noCoverage) + return { + summary: `${area.label} is outside the US and Canadian camera coverage.`, + data: { ...capRows([], args.limit), coverage: 'US and Canada' }, + }; + if (result.zoomIn) + throw new ToolError( + 'invalid_arguments', + 'Use a smaller, city-sized area', + ); + const center = areaCenter(area); + const rows = result.records + .map((camera) => ({ + camera, + point: { lat: camera.latitude, lon: camera.longitude }, + })) + .filter(({ point }) => areaContains(area, point)) + .map(({ camera, point }) => ({ + id: camera.id, + lat: round(point.lat, 6), + lon: round(point.lon, 6), + operator: camera.operator || null, + manufacturer: camera.manufacturer || null, + camera_type: camera.cameraType || null, + zone: camera.zone || null, + direction_deg: camera.directionDeg ?? null, + last_verified: camera.lastVerified || null, + distance_km: round(distanceKm(center, point), 2), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: + `${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` + + (result.stale ? ' (data may be stale).' : '.'), + data: { + ...capRows(rows, args.limit), + source: services.alpr.label ?? null, + attribution: services.alpr.attribution ?? null, + }, + }; + }, +}); diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs new file mode 100644 index 000000000..8b4a151bc --- /dev/null +++ b/src/tools/queries/surveillance.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; + +const camera = (id, latitude, longitude, extra = {}) => ({ + id, + osmId: id, + latitude, + longitude, + operator: 'City PD', + manufacturer: 'Flock Safety', + cameraType: 'fixed', + zone: 'traffic', + directionDeg: 270, + ref: null, + lastVerified: '2026-05-01', + source: null, + ...extra, +}); +const alpr = (result) => ({ + label: 'OpenStreetMap · community mapped', + attribution: { text: 'OpenStreetMap contributors' }, + boxes: [], + async fetch(box) { + this.boxes.push(box); + return result; + }, +}); + +test('mapped cameras are listed nearest first with their details', async () => { + const source = alpr({ + records: [ + camera('alpr:1', 30.3, -97.7), + camera('alpr:2', 30.27, -97.74, { directionDeg: null }), + camera('alpr:far', 31.5, -97.7), + ], + stale: false, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { alpr: source }, + }); + const result = await catalog.call('find_alpr_cameras', { + area: { lat: 30.27, lon: -97.74, radius_km: 10 }, + }); + assert.equal( + result.summary, + '2 license plate reader cameras mapped in 10 km around 30.270, -97.740.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['alpr:2', 'alpr:1'], + ); + assert.deepEqual(result.data.rows[1], { + id: 'alpr:1', + lat: 30.3, + lon: -97.7, + operator: 'City PD', + manufacturer: 'Flock Safety', + camera_type: 'fixed', + zone: 'traffic', + direction_deg: 270, + last_verified: '2026-05-01', + distance_km: 5.09, + }); + assert.equal(result.data.source, 'OpenStreetMap · community mapped'); + assert.ok(source.boxes[0].north - source.boxes[0].south < 1); +}); + +test('coverage, size and zoom limits are reported', async () => { + const outside = composeCatalog({ + tools: coreTools, + services: { alpr: alpr({ records: [], noCoverage: true }) }, + }); + assert.equal( + ( + await outside.call('find_alpr_cameras', { + area: { lat: 51.5, lon: -0.1, radius_km: 5 }, + }) + ).summary, + '5 km around 51.500, -0.100 is outside the US and Canadian camera coverage.', + ); + await assert.rejects( + outside.call('find_alpr_cameras', { area: { bbox: [-100, 30, -95, 35] } }), + /at most 3° on each side/, + ); + const zoom = composeCatalog({ + tools: coreTools, + services: { alpr: alpr({ records: [], zoomIn: true }) }, + }); + await assert.rejects( + zoom.call('find_alpr_cameras', { + area: { lat: 30, lon: -97, radius_km: 100 }, + }), + /smaller, city-sized area/, + ); +}); diff --git a/src/tools/services.js b/src/tools/services.js index a09e84bb1..a77494fc7 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -6,6 +6,7 @@ * address the app is served from. */ +import { createAlprTileSource } from '../layers/alpr/source.js'; import { GBFS_CITY_REGISTRY } from '../layers/bikeshare/registry.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js'; import { createCctvSource } from '../layers/cctv/source.js'; @@ -68,6 +69,7 @@ export function createToolServices({ fetchImpl, appUrl }) { wind: createWindSource({ fetchImpl }), imagery: createImageryService({ fetchImpl }), cables: createBundledCableSource({ fetchImpl }), + alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }), regional: requests.regional, terrain: requests.terrain, summary: requests.summary,