From a316e12323d2b862a92cc00fec2c6620710badea Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:15:45 +0530 Subject: [PATCH 1/5] fix(overpass): read failure phrases from the remark, not element tags overpassLooksRuntimeError and overpassLooksRateLimited searched the whole 200 body, element tags included. A result containing a place named "Timed Out Escape Rooms" (or any tag with "runtime error", "out of memory", "too many requests" or "rate_limited") was refused as an upstream failure, and the endpoint was put on cooldown for every other query. Parse the body once. A data reply (one with an elements array) reports failure in its remark, so the phrase checks run on the remark. Any other body, an error page or JSON without elements, is matched whole as before. --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 2 +- server/providers/overpass/transport.js | 22 ++++++++++++++++++---- src/overpassProxy.test.mjs | 26 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..c2b02f0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..3424560eb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -525,7 +525,7 @@ are protected from fallback writes. Version 6 is the export format, with zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); see [Director](DIRECTOR.md) for camera, pack, action and sharing support. -Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. +Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct; a data reply is refused only for its `remark`, never for text in element tags. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 38c31bb08..e3776e291 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -14,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js'; * Checks for known rate-limit phrases in the body text regardless of * HTTP status code, since some mirrors return 200 with an error payload. * - * @param {string} bodyText - Upstream response body. + * @param {string} bodyText - The remark of a data reply, or a non-data body. * @returns {boolean} True if the body looks rate-limited. */ function overpassLooksRateLimited(bodyText) { @@ -41,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) { ); } +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + /** * True only for an upstream response that is actually Overpass data. * @@ -151,15 +159,22 @@ async function fetchOverpassPayload( signal: controller.signal, }); const responseBody = await readBody(upstream, maxResponseBytes); + const parsed = parseJson(responseBody); + // An Overpass data reply reports failure in its remark; its element + // tags are data and can contain any phrase. Any other body (an error + // page, or JSON without elements) is matched whole. + const failureText = Array.isArray(parsed?.elements) + ? String(parsed.remark || '') + : responseBody; const rateLimited = upstream.status === 429 || upstream.status === 406 || - overpassLooksRateLimited(responseBody); + overpassLooksRateLimited(failureText); if ( rateLimited || upstream.status < 200 || upstream.status >= 300 || - overpassLooksRuntimeError(responseBody) + overpassLooksRuntimeError(failureText) ) { const failures = (previous?.failures || 0) + 1; const delay = retryDelay( @@ -178,7 +193,6 @@ async function fetchOverpassPayload( failure = refusal(status, delay); continue; } - const parsed = JSON.parse(responseBody); if (!Array.isArray(parsed?.elements) || parsed.remark) throw new Error('Malformed Overpass response'); cooldowns.delete(endpoint); diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index ed4539fda..788846db7 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -126,6 +126,32 @@ test('production reader rejects oversized, malformed and runtime-failure bodies' } }); +test('element data that mentions a failure phrase is data, and sets no cooldown', async () => { + const names = ['Timed Out Escape Rooms', 'Runtime Error Bar', 'Out of Memory Lane', 'Too Many Requests Cafe', 'rate_limited']; + for (const [i, name] of names.entries()) { + const body = JSON.stringify({ elements: [{ type: 'node', id: 1, lat: 1, lon: 2, tags: { name } }] }); + let count = 0; + const options = { endpoints: [`https://phrase-${i}.example/`], fetchImpl: async () => { + count++; return new Response(body); + } }; + const payload = await fetchOverpassPayload('first', 1024, options); + assert.equal(payload.status, 200, name); + assert.ok(overpassPayloadIsData(payload), name); + assert.ok(payload.body.includes(name), name); + await fetchOverpassPayload('different', 1024, options); + assert.equal(count, 2, name); + } + const remark = JSON.stringify({ elements: [], remark: 'runtime error: rate_limited' }); + const limited = await fetchOverpassPayload('x', 1024, { endpoints: ['https://remark.example/'], + fetchImpl: async () => new Response(remark, { headers: { 'Retry-After': '90' } }) }); + assert.equal(limited.status, 429); + assert.equal(limited.retryAfterMs, 90_000); + const error = await fetchOverpassPayload('x', 1024, { endpoints: ['https://error.example/'], + fetchImpl: async () => new Response('{"error":"rate_limited"}', { headers: { 'Retry-After': '120' } }) }); + assert.equal(error.status, 429); + assert.equal(error.retryAfterMs, 120_000); +}); + function proxyHandler() { const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy'); const routes = new Map(); From 557d3bb41e7cc706d030177ac89d54f864f3ab00 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:24:09 +0530 Subject: [PATCH 2/5] fix(military): hold the last track and speed when a poll omits them MilitaryFlightRecords coerced a missing course to 0 (`courseDeg || 0`) and a missing speed to 0 (`speedMps ?? 0`) before stickyNumber, so the hold-last-known-good merge never ran. When an adsb.lol row arrived without `track` or `gs`, the aircraft's icon swung to north and the position history got a 0 kt / 0 degree fix, so dead reckoning stopped or turned it north. The civil FlightRecords holds the last value for the same input. Pass the raw values to stickyNumber, with 0 as the fallback only until a value is known, as before. A grounded row without a speed still reads 0, so a parked aircraft doesn't coast at its landing speed. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 4 +++- src/layers/military/records.js | 13 ++++++---- src/layers/military/records.test.mjs | 36 ++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..df39cef89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..b4a531482 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -584,7 +584,9 @@ pinning, first-position grace, source time and sea-surface placement are unchang Military flights also separate portable records and source acquisition from Cesium snapshot application. The metadata owner retains aviation feet, source -fix times, sticky fields and partial-feed retention. Rendering retains ground +fix times, sticky fields and partial-feed retention. A row without track or +speed holds the last known value, as civil records do; a grounded row without +speed reads 0. Rendering retains ground model ownership, motion history, military registration and tracking cleanup. Civil flights separate source acquisition, portable record reconciliation and diff --git a/src/layers/military/records.js b/src/layers/military/records.js index 9cd6754e6..05d91ccf0 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -39,8 +39,6 @@ export class MilitaryFlightRecords { ? prevMeta.altitudeFt * 0.3048 : 0 : 3048); - const track = aircraft.courseDeg || 0; - const speedMps = aircraft.speedMps ?? 0; const verticalRateMps = aircraft.verticalRateMps; const callsign = aircraft.callsign; const type = aircraft.typeCode; @@ -148,8 +146,15 @@ export class MilitaryFlightRecords { // altitudeFt/baro). geoAltitudeM, renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), + // A poll that omits track/speed holds the last values, like the civil + // records; 0 only until one is known. A grounded row without a speed + // reads 0, so a parked plane never coasts at its landing speed. + speedMps: stickyNumber( + aircraft.speedMps, + onGround ? 0 : prevMeta?.speedMps, + 0, + ), + track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0), // Analyst seam (additive): sticky like the other kinematics. verticalRateMps: stickyNumber( verticalRateMps, diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index 5054fb28a..ce00c7761 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', () ); assert.equal(next.meta.emergency, null); }); + +test('military records hold the last track and speed when a poll omits them', () => { + const records = store(); + records.receive(observation({ courseDeg: 270, speedMps: 200 }), context()); + const missing = records.receive( + observation({ courseDeg: null, speedMps: null }), + context(), + ); + assert.equal(missing.meta.track, 270); + assert.equal(missing.meta.speedMps, 200); + const north = records.receive( + observation({ courseDeg: 0, speedMps: 0 }), + context(), + ); + assert.equal(north.meta.track, 0); + assert.equal(north.meta.speedMps, 0); + const unseen = store().receive( + observation({ courseDeg: undefined, speedMps: undefined }), + context(), + ); + assert.equal(unseen.meta.track, 0); + assert.equal(unseen.meta.speedMps, 0); + const landing = store(); + landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context()); + const parked = landing.receive( + observation({ + onGround: true, + baroAltitudeM: null, + courseDeg: null, + speedMps: null, + }), + context(), + ); + assert.equal(parked.meta.speedMps, 0); + assert.equal(parked.meta.track, 90); +}); From 1d62cdd805ea6cc9d479dbd7ef756e27a5867f44 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 14:16:32 -0700 Subject: [PATCH 3/5] Move the changelog entry into changelog.d Main now takes release notes as one fragment per pull request (#787). --- CHANGELOG.md | 4 ---- changelog.d/1096-military-hold-track-speed.md | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1096-military-hold-track-speed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e689a9d40..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -- Military aircraft keep their last heading and speed when an adsb.lol row omits - `track` or `gs`, instead of turning north and stopping; a grounded row without - a speed still reads 0. - - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md new file mode 100644 index 000000000..adc1793c4 --- /dev/null +++ b/changelog.d/1096-military-hold-track-speed.md @@ -0,0 +1,3 @@ +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). From aa7943a07bfbce6fae9fd8325712e09346583f48 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 14:16:34 -0700 Subject: [PATCH 4/5] Move the changelog entry into changelog.d Main now takes release notes as one fragment per pull request (#787). --- CHANGELOG.md | 5 ----- changelog.d/1097-overpass-remark-failure-phrases.md | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1097-overpass-remark-failure-phrases.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 873522b98..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -- Overpass data whose element tags contain a failure phrase ("timed out", - "runtime error", "too many requests" and similar, e.g. a place named "Timed - Out Escape Rooms") is returned as data instead of being refused and putting - the endpoint on cooldown; upstream failures are read from the remark. - - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md new file mode 100644 index 000000000..4543710da --- /dev/null +++ b/changelog.d/1097-overpass-remark-failure-phrases.md @@ -0,0 +1,5 @@ +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). From fa8e3e989ff2c28e13c883c8570ea994e645b6ae Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:53:50 -0400 Subject: [PATCH 5/5] fix(tools): exclude unknown altitude from requested aircraft bands (#1115) aircraft_in_area compared a nullable altitude, so a contact with neither barometric nor geometric altitude matched max_altitude_m as though it were at zero. Require a reported finite altitude only when a band is requested; unfiltered queries keep the contact. --- .../1115-aircraft-altitude-band-unknown.md | 2 + docs/CURRENT-STATE.md | 3 + src/tools/queries/aviation.js | 2 + src/tools/queries/aviation.test.mjs | 59 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 changelog.d/1115-aircraft-altitude-band-unknown.md diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md new file mode 100644 index 000000000..a6b69472d --- /dev/null +++ b/changelog.d/1115-aircraft-altitude-band-unknown.md @@ -0,0 +1,2 @@ +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df92c0d50..1bdca7670 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,6 +100,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Aircraft altitude-band queries require a reported barometric or geometric altitude +for each match, so a grounded contact that reports none is left out of a band too. +Queries without an altitude band retain unknown-altitude contacts. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..235f542e1 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({ (record) => areaContains(area, { lat: record.latitude, lon: record.longitude }) && !(args.airborne_only && record.onGround) && + ((args.min_altitude_m == null && args.max_altitude_m == null) || + Number.isFinite(altitude(record))) && (args.min_altitude_m == null || altitude(record) >= args.min_altitude_m) && (args.max_altitude_m == null || diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..d0e979ed6 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('altitude bands require a reported altitude from normalized OpenSky records', async () => { + const { normalizeOpenSkyAircraft } = + await import('../../sources/live/aircraft.js'); + const state = (id, baro, geometric) => + normalizeOpenSkyAircraft([ + id, + 'TEST', + 'Test', + 1767225600, + 1767225600, + 0, + 0, + baro, + false, + 10, + 90, + 0, + [], + geometric, + ]); + const records = [ + state('abc001', null, null), + state('abc002', 0, null), + state('abc003', null, 50), + state('abc004', 500, null), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: { getSnapshot: async () => ({ records }) }, + }, + }); + const area = { bbox: [-1, -1, 1, 1] }; + assert.equal( + (await catalog.call('aircraft_in_area', { area })).data.total, + 4, + 'unfiltered answers retain unknown altitude', + ); + for (const band of [ + { max_altitude_m: 100 }, + { min_altitude_m: -500, max_altitude_m: 100 }, + ]) { + const result = await catalog.call('aircraft_in_area', { area, ...band }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['abc002', 'abc003'], + ); + assert.deepEqual( + result.data.rows.map((row) => row.altitude_m), + [0, 50], + ); + } + const minimum = await catalog.call('aircraft_in_area', { + area, + min_altitude_m: -500, + }); + assert.equal(minimum.data.total, 3); +});