mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(cctv): add Lithuania Eismoinfo road cameras
This commit is contained in:
1 parent
5728dec24d
commit
976efed6dc
10 files changed
+308
No files matched your search
@@ -171,6 +171,8 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
|
||||
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
|
||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
||||
# CCTV_LITHUANIA_ENABLED=1 # set 0 to disable Via Lietuva / Eismoinfo road cameras
|
||||
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
@@ -1,5 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required.
|
||||
|
||||
- Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up.
|
||||
|
||||
- Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport
|
||||
|
||||
@@ -59,6 +59,8 @@ How to read this:
|
||||
|
||||
### Notes on the live sources
|
||||
|
||||
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. The provider's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44) describes reuse of its public data; no specific Creative Commons licence is asserted here. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. Disable with `CCTV_LITHUANIA_ENABLED=0`; `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
|
||||
@@ -391,6 +391,20 @@ owners. Tuner calculations have a pure entry, with existing layer exports
|
||||
preserved. Destruction removes listeners and state subscriptions before ending
|
||||
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||
|
||||
## Lithuania CCTV source pack
|
||||
|
||||
The CCTV catalog includes Via Lietuva / Eismoinfo road cameras by default.
|
||||
Two keyless public endpoints provide current snapshot URLs and WGS84 camera
|
||||
locations; records are joined by ID, with invalid coordinates and off-origin
|
||||
image URLs rejected. Catalog requests refuse redirects, have a 15-second
|
||||
deadline and a 4 MiB response cap, and use the existing shared 15-minute cache
|
||||
and fair catalog allocation. An unavailable source contributes an empty pack.
|
||||
Frames use the registered-image proxy and retain Via Lietuva attribution.
|
||||
The images are periodically updated road-condition photographs, not continuous
|
||||
video; camera poses use the existing low-confidence heading prior.
|
||||
`CCTV_LITHUANIA_ENABLED=0` disables the pack, and
|
||||
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
|
||||
@@ -85,6 +85,8 @@
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"src/data/cctvLithuania.test.mjs",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { normalizeSourceItem } from './normalize.js';
|
||||
import { loadLithuaniaSources } from './lithuania.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -30,6 +31,11 @@ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
|
||||
* kill switch.
|
||||
*/
|
||||
const LIVE_PACKS = [
|
||||
{
|
||||
name: 'lithuania',
|
||||
enabled: () => envEnabled('CCTV_LITHUANIA_ENABLED'),
|
||||
load: loadLithuaniaSources,
|
||||
},
|
||||
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
|
||||
{
|
||||
name: 'caltrans',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
cameraDisplayCode,
|
||||
fallbackHeadingFromId,
|
||||
prioritizeSources,
|
||||
} from './normalize.js';
|
||||
import { readResponseJsonCapped } from '../common/http.js';
|
||||
|
||||
const BASE = 'https://eismoinfo.lt/eismoinfo-backend';
|
||||
export const LITHUANIA_CAMERAS_URL = `${BASE}/camera-info-table`;
|
||||
// Without lks=true this endpoint returns [latitude, longitude]. The camera
|
||||
// table's x/y fields use LKS-94 metres and must never be treated as degrees.
|
||||
export const LITHUANIA_LOCATIONS_URL = `${BASE}/layer-static-features/VKR`;
|
||||
export const LITHUANIA_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
const FRAME_PATH = '/eismoinfo-backend/image-provider/camera/last';
|
||||
const ANCHORS = [
|
||||
{ lat: 54.6872, lon: 25.2797 }, // Vilnius
|
||||
{ lat: 54.8985, lon: 23.9036 }, // Kaunas
|
||||
{ lat: 55.7033, lon: 21.1443 }, // Klaipeda
|
||||
{ lat: 55.9349, lon: 23.3137 }, // Siauliai
|
||||
{ lat: 55.7348, lon: 24.3575 }, // Panevezys
|
||||
];
|
||||
|
||||
/** Join public catalogs by ID; register only the official still endpoint. */
|
||||
export function lithuaniaCameraToSource(row, feature) {
|
||||
const id = String(row?.id ?? '');
|
||||
if (!/^[1-9]\d{0,8}$/.test(id) || String(feature?.id) !== id) return null;
|
||||
const point = feature?.points?.[0]?.point;
|
||||
if (!Array.isArray(point) || point.length !== 2) return null;
|
||||
const [lat, lon] = point;
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
lat < 53.8 ||
|
||||
lat > 56.5 ||
|
||||
lon < 20.8 ||
|
||||
lon > 26.9
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const url = new URL(row.image);
|
||||
if (
|
||||
url.origin !== 'https://eismoinfo.lt' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== FRAME_PATH ||
|
||||
url.searchParams.get('id') !== id
|
||||
)
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Reconstruct so extra query parameters cannot alter the request.
|
||||
const imageUrl = `https://eismoinfo.lt${FRAME_PATH}?id=${id}`;
|
||||
const cameraId = `lithuania-${id}`;
|
||||
const name = String(row.name || feature.name || `Lietuva ${id}`)
|
||||
.trim()
|
||||
.slice(0, 160);
|
||||
return {
|
||||
id: cameraId,
|
||||
name,
|
||||
city: 'Lithuania / Lietuva',
|
||||
cityId: 'lithuania',
|
||||
provider: 'Via Lietuva / Eismoinfo',
|
||||
lat,
|
||||
lon,
|
||||
// No calibrated pose is published; use the app's low-confidence prior.
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'eismoinfo-public',
|
||||
credit:
|
||||
'Via Lietuva / eismoinfo.lt — periodically updated road-camera stills',
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCatalog(url) {
|
||||
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Catalog HTTP ${response.status}`);
|
||||
}
|
||||
return readResponseJsonCapped(response, LITHUANIA_MAX_CATALOG_BYTES, signal);
|
||||
}
|
||||
|
||||
/** Two bounded requests per shared catalog refresh, no API key required. */
|
||||
export async function loadLithuaniaSources() {
|
||||
try {
|
||||
const [rows, layers] = await Promise.all([
|
||||
readCatalog(LITHUANIA_CAMERAS_URL),
|
||||
readCatalog(LITHUANIA_LOCATIONS_URL),
|
||||
]);
|
||||
if (!Array.isArray(rows) || !Array.isArray(layers)) return [];
|
||||
const features = layers.find((layer) => layer?.layer === 'VKR')?.features;
|
||||
if (!Array.isArray(features)) return [];
|
||||
const locations = new Map(
|
||||
features.filter(Boolean).map((feature) => [String(feature.id), feature]),
|
||||
);
|
||||
const cameras = new Map();
|
||||
for (const row of rows) {
|
||||
const camera = lithuaniaCameraToSource(
|
||||
row,
|
||||
locations.get(String(row?.id)),
|
||||
);
|
||||
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
||||
}
|
||||
const rawCap = Number(process.env.CCTV_LITHUANIA_MAX_SOURCES || 400);
|
||||
const cap = Number.isFinite(rawCap)
|
||||
? Math.max(1, Math.min(1000, Math.floor(rawCap)))
|
||||
: 400;
|
||||
const sources = prioritizeSources([...cameras.values()], cap, ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Lithuania camera sources: ${cameras.size} (using ${sources.length})`,
|
||||
);
|
||||
return sources;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Lithuania camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lithuaniaCameraToSource,
|
||||
loadLithuaniaSources,
|
||||
LITHUANIA_CAMERAS_URL,
|
||||
LITHUANIA_LOCATIONS_URL,
|
||||
LITHUANIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/lithuania.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const row = {
|
||||
id: 72,
|
||||
name: 'Vilnius A1 10,04',
|
||||
x: 576154,
|
||||
y: 6056867,
|
||||
image:
|
||||
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
};
|
||||
const feature = {
|
||||
id: '72',
|
||||
points: [{ point: [54.642550940833566, 25.17981587877114] }],
|
||||
};
|
||||
const layers = [{ layer: 'VKR', features: [feature] }];
|
||||
|
||||
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
|
||||
const camera = lithuaniaCameraToSource(row, feature);
|
||||
assert.equal(camera.id, 'lithuania-72');
|
||||
assert.equal(camera.lat, 54.642550940833566);
|
||||
assert.equal(camera.lon, 25.17981587877114);
|
||||
assert.equal(camera.name, 'Vilnius A1 10,04');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.url, row.image);
|
||||
assert.match(camera.credit, /Via Lietuva/);
|
||||
});
|
||||
|
||||
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...feature, id: '8' },
|
||||
{ ...feature, points: [] },
|
||||
{ ...feature, points: [{ point: [25.18, 54.64] }] },
|
||||
{ ...feature, points: [{ point: [576154, 6056867] }] },
|
||||
{ ...feature, points: [{ point: [null, 25] }] },
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource(row, invalid), null);
|
||||
}
|
||||
for (const image of [
|
||||
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
|
||||
row.image.replace('https://', 'https://user:pass@'),
|
||||
row.image.replace('id=72', 'id=8'),
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
|
||||
}
|
||||
assert.equal(
|
||||
lithuaniaCameraToSource(
|
||||
{ ...row, image: row.image + '&extra=ignored' },
|
||||
feature,
|
||||
).url,
|
||||
row.image,
|
||||
);
|
||||
assert.equal(lithuaniaCameraToSource(null, feature), null);
|
||||
});
|
||||
|
||||
function mockCatalogs(t) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return Response.json([row, row, { ...row, id: 999 }]);
|
||||
}
|
||||
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
|
||||
const requested = mockCatalogs(t);
|
||||
assert.deepEqual(
|
||||
(await loadLithuaniaSources()).map((c) => c.id),
|
||||
['lithuania-72'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requested.sort(),
|
||||
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('catalog includes Lithuania by default and respects the disable switch', async (t) => {
|
||||
const requested = mockCatalogs(t);
|
||||
const saved = process.env.CCTV_LITHUANIA_ENABLED;
|
||||
try {
|
||||
delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'lithuania-72')?.cityId,
|
||||
'lithuania',
|
||||
);
|
||||
requested.length = 0;
|
||||
process.env.CCTV_LITHUANIA_ENABLED = '0';
|
||||
const disabled = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
assert.ok(!disabled.some((c) => c.cityId === 'lithuania'));
|
||||
assert.ok(!requested.includes(LITHUANIA_CAMERAS_URL));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const makeResponse of [
|
||||
() => new Response('', { status: 503 }),
|
||||
() =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
() => new Response('invalid json'),
|
||||
() => Response.json({ unexpected: true }),
|
||||
() =>
|
||||
new Response('[]', {
|
||||
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
|
||||
}),
|
||||
]) {
|
||||
t.mock.method(globalThis, 'fetch', async () => makeResponse());
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
}
|
||||
});
|
||||
@@ -163,6 +163,12 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'lithuania-cctv',
|
||||
html:
|
||||
'Road-camera stills (Lithuania): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
|
||||
Reference in new issue
Block a user