diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3484a1c11..45a4f22e8 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -140,14 +140,14 @@ public feeds the sources already use. | `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | | `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | | `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | -| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility | -| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | +| `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 | | `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) | +| `places_nearby` | `placeSearch` | Notable places around a place or point (Google Places) | | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | | `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 110024841..f5bc416ba 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -200,9 +200,13 @@ export const getAircraftTrack = defineTool({ requires: ['aircraft'], async run(args, { services, signal }) { const icao24 = args.icao24.toLowerCase(); - const track = await feedFor(services, args.military).getTrack(icao24, { - signal, - }); + // A track server answers 404 for an aircraft it has no positions for. + const track = await feedFor(services, args.military) + .getTrack(icao24, { signal }) + .catch((error) => { + if (error?.status === 404) return { records: [] }; + throw error; + }); const points = [...track.records].sort( (a, b) => a.observedAtMs - b.observedAtMs, ); @@ -284,13 +288,13 @@ export const getAircraftInfo = defineTool({ if (icao24) parts.push( aircraft - ? `${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}` + ? `aircraft ${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}` : `no type is known for ${icao24}`, ); if (callsign) parts.push( flight - ? `${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}` + ? `flight ${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}` : `no route is known for ${callsign}`, ); const summary = parts.join('; '); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 671226e0d..811a066c4 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -215,6 +215,30 @@ test('tracks are ordered and thinned to 200 points', async () => { (await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary, 'No recent track is available for aaa111.', ); + const failing = (status) => ({ + getSnapshot: async () => snapshot([]), + getTrack: async () => { + throw new LiveSourceError('unavailable', `OpenSky HTTP ${status}`, { + status, + }); + }, + }); + const unknown = composeCatalog({ + tools: coreTools, + services: { aircraft: failing(404) }, + }); + assert.equal( + (await unknown.call('get_aircraft_track', { icao24: 'abcdef' })).summary, + 'No recent track is available for abcdef.', + ); + const down = composeCatalog({ + tools: coreTools, + services: { aircraft: failing(502) }, + }); + await assert.rejects( + down.call('get_aircraft_track', { icao24: 'abcdef' }), + (error) => error.code === 'unavailable', + ); }); test('aircraft info combines type and route lookups', async () => { @@ -241,7 +265,7 @@ test('aircraft info combines type and route lookups', async () => { }); assert.equal( both.summary, - 'Aaa111 is Boeing 737-800 (N12345); UAL123 flies SFO to EWR (United Airlines).', + 'Aircraft aaa111 is Boeing 737-800 (N12345); flight UAL123 flies SFO to EWR (United Airlines).', ); assert.equal(both.data.route.destination.name, 'Newark'); const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' }); diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js index 44dcbaf38..917f8d15b 100644 --- a/src/tools/queries/brief.js +++ b/src/tools/queries/brief.js @@ -25,6 +25,7 @@ const AWARENESS_RADIUS_KM = 250; const SECTIONS = [ { key: 'weather', + label: 'Weather', tool: getWeather, args: (area, center) => ({ location: { lat: center.lat, lon: center.lon }, @@ -32,26 +33,31 @@ const SECTIONS = [ }, { key: 'earthquakes', + label: 'Earthquakes', tool: getEarthquakes, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'fires', + label: 'Active fires', tool: getActiveFires, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'aircraft', + label: 'Aircraft', tool: aircraftInArea, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'vessels', + label: 'Ships', tool: vesselsInArea, args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), }, { key: 'cyclones', + label: 'Tropical cyclones', tool: getCyclones, args: (area) => ({ area: area.argument }), }, @@ -181,9 +187,30 @@ export const getHudCaption = defineTool({ }, requires: ['weather', 'summary'], async run(args, context) { - const { area, center, lines } = await buildBrief(args, context); + const { area, center, brief, lines } = await buildBrief(args, context); + // The app's caption context: place labels and the layers that answered, + // with their feed state. + const answered = SECTIONS.filter( + ({ key }) => brief[key] && !brief[key].unavailable, + ); + const layers = answered.map(({ key, label }) => ({ + id: key, + name: label, + feedState: 'nominal', + })); const response = await context.services.summary.summarize( - { location: area.label, center, observations: lines }, + { + placeLabels: [area.label], + location: area.label, + center, + observations: lines, + enabledLayerLabels: layers.map((layer) => layer.name), + enabledLayers: layers, + feedProvenance: { + overall: layers.length ? 'nominal' : null, + layers, + }, + }, { signal: context.signal }, ); const caption = response?.data?.summary; diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index bd4f48bd3..3635ff207 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -438,6 +438,9 @@ test('the HUD caption summarizes the brief through the summary service', async ( ); assert.equal(result.summary, 'Overcast skies over Austin'); assert.equal(sent[0].location, 'Austin, Texas'); + assert.deepEqual(sent[0].placeLabels, ['Austin, Texas']); + assert.deepEqual(sent[0].enabledLayerLabels, ['Weather']); + assert.equal(sent[0].feedProvenance.overall, 'nominal'); assert.match(sent[0].observations[0], /^Weather at /); const failing = { summarize: async () => ({ diff --git a/src/tools/queries/places.js b/src/tools/queries/places.js index 1e82237e1..c6532832b 100644 --- a/src/tools/queries/places.js +++ b/src/tools/queries/places.js @@ -82,25 +82,25 @@ export const placesNearby = defineTool({ inputSchema: { type: 'object', properties: { - lat: { type: 'number', minimum: -90, maximum: 90 }, - lon: { type: 'number', minimum: -180, maximum: 180 }, + location: POINT_SCHEMA, radius_m: { type: 'number', minimum: 10, maximum: 5000 }, limit: LIMIT_SCHEMA, }, - required: ['lat', 'lon'], + required: ['location'], additionalProperties: false, }, requires: ['placeSearch'], async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); const radiusM = Math.round(args.radius_m ?? 250); const result = await services.placeSearch.nearby( - { latitude: args.lat, longitude: args.lon, radiusM }, + { latitude: point.lat, longitude: point.lon, radiusM }, { signal }, ); if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED); const rows = result.places.map(placeRow); return { - summary: `${countNoun(rows.length, 'place')} within ${radiusM} m.`, + summary: `${countNoun(rows.length, 'place')} within ${radiusM} m of ${point.label}.`, data: capRows(rows, args.limit), }; }, diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 29755d517..ab4a0a0e2 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -93,11 +93,10 @@ test('nearby places use the given radius', async () => { services: { placeSearch: search }, }); const result = await catalog.call('places_nearby', { - lat: 30.27, - lon: -97.74, + location: { lat: 30.27, lon: -97.74 }, radius_m: 400, }); - assert.equal(result.summary, '1 place within 400 m.'); + assert.equal(result.summary, '1 place within 400 m of 30.2700, -97.7400.'); assert.deepEqual(search.calls[0], { latitude: 30.27, longitude: -97.74, diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index 298ec104a..d02796429 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -6,6 +6,7 @@ import { lookAnglesAt, } from '../../data/satellitePass.js'; import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; +import { POINT_SCHEMA, resolvePoint } from '../area.js'; import { defineTool, ToolError } from '../catalog.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; @@ -83,20 +84,6 @@ const COMPASS = [ 'NW', 'NNW', ]; -const OBSERVER = { - lat: { - type: 'number', - minimum: -90, - maximum: 90, - description: 'Observer latitude.', - }, - lon: { - type: 'number', - minimum: -180, - maximum: 180, - description: 'Observer longitude.', - }, -}; const GROUP_SCHEMA = { type: 'string', enum: GROUPS, @@ -154,7 +141,7 @@ export const nextSatellitePass = defineTool({ inputSchema: { type: 'object', properties: { - ...OBSERVER, + location: POINT_SCHEMA, satellite: { type: 'string', minLength: 1, @@ -170,11 +157,12 @@ export const nextSatellitePass = defineTool({ }, hours: { type: 'number', minimum: 1, maximum: 72 }, }, - required: ['lat', 'lon'], + required: ['location'], additionalProperties: false, }, requires: ['satellites'], async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); const group = args.group ?? 'stations'; const entries = await readCatalog(services, group, signal); const satellite = matchSatellite(entries, args.satellite); @@ -186,8 +174,8 @@ export const nextSatellitePass = defineTool({ const hours = args.hours ?? 24; const pass = findNextSatellitePass({ satrec: satellite.satrec, - latDeg: args.lat, - lonDeg: args.lon, + latDeg: point.lat, + lonDeg: point.lon, fromMs: nowMs(services), minElevDeg: args.min_elevation_deg ?? 10, horizonHours: hours, @@ -196,15 +184,21 @@ export const nextSatellitePass = defineTool({ const name = satellite.name; if (!pass) return { - summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over this location in the next ${hours} hours.`, - data: { satellite: name, norad: satellite.norad, pass: null }, + summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over ${point.label} in the next ${hours} hours.`, + data: { + location: point, + satellite: name, + norad: satellite.norad, + pass: null, + }, }; return { summary: - `${name} next rises ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + + `${name} next rises over ${point.label} at ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + `peaking at ${Math.round(pass.maxElevDeg)}°` + `${pass.visible ? '; visible to the naked eye' : ''}.`, data: { + location: point, satellite: name, norad: satellite.norad, pass: { @@ -230,22 +224,23 @@ export const satellitesOverhead = defineTool({ inputSchema: { type: 'object', properties: { - ...OBSERVER, + location: POINT_SCHEMA, group: GROUP_SCHEMA, min_elevation_deg: { type: 'number', minimum: 0, maximum: 90 }, limit: LIMIT_SCHEMA, }, - required: ['lat', 'lon'], + required: ['location'], additionalProperties: false, }, requires: ['satellites'], async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); const group = args.group ?? 'stations'; const minimum = args.min_elevation_deg ?? 10; const at = nowMs(services); const rows = (await readCatalog(services, group, signal)) .flatMap((entry) => { - const look = lookAnglesAt(entry.satrec, at, args.lat, args.lon); + const look = lookAnglesAt(entry.satrec, at, point.lat, point.lon); return look && look.elevDeg >= minimum ? [ { @@ -260,8 +255,13 @@ export const satellitesOverhead = defineTool({ }) .sort((a, b) => b.elevation_deg - a.elevation_deg); return { - summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above this location at ${isoTime(at)}.`, - data: { ...capRows(rows, args.limit), group, at: isoTime(at) }, + summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above ${point.label} at ${isoTime(at)}.`, + data: { + ...capRows(rows, args.limit), + location: point, + group, + at: isoTime(at), + }, }; }, }); diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 460da8319..8f77fb041 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -36,8 +36,7 @@ test('the next pass defaults to the ISS and reports times, peak and direction', services: { satellites: satellites(), clock: CLOCK }, }); const result = await catalog.call('next_satellite_pass', { - lat: 40.7, - lon: -74, + location: { lat: 40.7, lon: -74 }, }); const { pass } = result.data; assert.equal(result.data.satellite, 'ISS (ZARYA)'); @@ -50,7 +49,7 @@ test('the next pass defaults to the ISS and reports times, peak and direction', assert.match( result.summary, new RegExp( - `^ISS \\(ZARYA\\) next rises ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`, + `^ISS \\(ZARYA\\) next rises over 40.7000, -74.0000 at ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`, ), ); }); @@ -62,14 +61,12 @@ test('satellites are matched by name or catalog number within the group', async services: { satellites: source, clock: CLOCK }, }); const byName = await catalog.call('next_satellite_pass', { - lat: 0, - lon: 0, + location: { lat: 0, lon: 0 }, satellite: 'tianhe', }); assert.equal(byName.data.norad, 48274); const byNumber = await catalog.call('next_satellite_pass', { - lat: 0, - lon: 0, + location: { lat: 0, lon: 0 }, satellite: '48274', group: 'visual', }); @@ -77,14 +74,16 @@ test('satellites are matched by name or catalog number within the group', async assert.deepEqual(source.calls, ['stations', 'visual']); await assert.rejects( catalog.call('next_satellite_pass', { - lat: 0, - lon: 0, + location: { lat: 0, lon: 0 }, satellite: 'Hubble', }), /No satellite matching "Hubble" is in the stations group/, ); await assert.rejects( - catalog.call('next_satellite_pass', { lat: 0, lon: 0, group: 'weather' }), + catalog.call('next_satellite_pass', { + location: { lat: 0, lon: 0 }, + group: 'weather', + }), /must be one of/, ); }); @@ -96,21 +95,20 @@ test('a pass that never comes is a normal answer, and catalog failures are unava }); // The ISS orbit is inclined 51.6°, so it never rises 10° above the pole. const none = await catalog.call('next_satellite_pass', { - lat: 89.9, - lon: 0, + location: { lat: 89.9, lon: 0 }, hours: 6, }); assert.equal(none.data.pass, null); assert.equal( none.summary, - 'ISS (ZARYA) has no pass over this location in the next 6 hours.', + 'ISS (ZARYA) has no pass over 89.9000, 0.0000 in the next 6 hours.', ); const down = composeCatalog({ tools: coreTools, services: { satellites: satellites('', false), clock: CLOCK }, }); await assert.rejects( - down.call('next_satellite_pass', { lat: 0, lon: 0 }), + down.call('next_satellite_pass', { location: { lat: 0, lon: 0 } }), (error) => error.code === 'unavailable' && /HTTP 503/.test(error.message), ); }); @@ -120,7 +118,9 @@ test('satellites overhead are those above the elevation, highest first', async ( const services = { satellites: source, clock: CLOCK }; const catalog = composeCatalog({ tools: coreTools, services }); const pass = ( - await catalog.call('next_satellite_pass', { lat: 40.7, lon: -74 }) + await catalog.call('next_satellite_pass', { + location: { lat: 40.7, lon: -74 }, + }) ).data.pass; const atPeak = { satellites: source, @@ -130,8 +130,7 @@ test('satellites overhead are those above the elevation, highest first', async ( tools: coreTools, services: atPeak, }).call('satellites_overhead', { - lat: 40.7, - lon: -74, + location: { lat: 40.7, lon: -74 }, }); assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); assert.ok( @@ -142,8 +141,7 @@ test('satellites overhead are those above the elevation, highest first', async ( tools: coreTools, services: atPeak, }).call('satellites_overhead', { - lat: 40.7, - lon: -74, + location: { lat: 40.7, lon: -74 }, min_elevation_deg: 90, }); assert.equal(strict.data.total, 0);