From 05514fdc188fe3beb3bee14f4cb52bbfd9fea309 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 7 Mar 2026 03:23:52 +0000 Subject: [PATCH] Add owner auth DB, website refresh, and coverage rollback --- Dockerfile.backend | 1 + backend/src/api/routes.ts | 181 ++++- backend/src/db/owner-auth.sql | 16 + backend/src/db/ownerAuth.ts | 96 +++ backend/src/db/schema.sql | 4 + backend/src/index.ts | 11 +- docker-compose.yml | 4 + frontend/src/components/LiveStatsSection.tsx | 5 +- frontend/src/components/Map/MapView.tsx | 28 +- frontend/src/components/Map/NodeMarker.tsx | 72 +- .../src/components/app/DisclaimerModal.tsx | 10 +- frontend/src/hooks/useAppMessageHandler.ts | 12 +- frontend/src/hooks/useCoverage.ts | 5 +- frontend/src/pages/HealthPage.tsx | 25 +- frontend/src/pages/HomePage.tsx | 221 +++--- frontend/src/pages/OwnerPortalPage.tsx | 366 +++++++--- frontend/src/pages/shared/SiteLayout.tsx | 44 +- frontend/src/pages/ukmesh/UKHomePage.tsx | 217 +++--- frontend/src/styles/globals.css | 657 +++++++++++++----- viewshed-worker/worker.py | 327 ++++++--- 20 files changed, 1638 insertions(+), 664 deletions(-) create mode 100644 backend/src/db/owner-auth.sql create mode 100644 backend/src/db/ownerAuth.ts diff --git a/Dockerfile.backend b/Dockerfile.backend index 1d6b2b6..6ba308e 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -12,5 +12,6 @@ COPY backend/package.json ./ RUN npm install --omit=dev && npm cache clean --force COPY --from=backend-builder /build/backend/dist ./dist COPY --from=backend-builder /build/backend/src/db/schema.sql ./dist/db/schema.sql +COPY --from=backend-builder /build/backend/src/db/owner-auth.sql ./dist/db/owner-auth.sql EXPOSE 3000 CMD ["node", "dist/index.js"] diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 44cd514..abb829e 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -3,6 +3,7 @@ import { rateLimit } from 'express-rate-limit'; import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import mqtt from 'mqtt'; import { getNodes, getNodeHistory, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js'; +import { getOwnerNodeIdsForUsername } from '../db/ownerAuth.js'; import { getWorkerHealthOverview } from '../health/status.js'; import { resolveBetaPathForPacketHash } from '../path-beta/resolver.js'; @@ -20,6 +21,7 @@ const OWNER_LOGIN_LIMITER = rateLimit({ type OwnerSession = { nodeIds: string[]; exp: number; + mqttUsername?: string; }; function getOwnerCookieKey(): Buffer { @@ -60,7 +62,8 @@ function decryptOwnerSession(token: string): OwnerSession | null { .map((value) => String(value).trim().toLowerCase()) .filter((value) => /^[0-9a-f]{64}$/.test(value)); if (nodeIds.length < 1) return null; - return { nodeIds, exp: parsed.exp }; + const mqttUsername = typeof parsed.mqttUsername === 'string' ? parsed.mqttUsername.trim() : undefined; + return { nodeIds, exp: parsed.exp, mqttUsername: mqttUsername || undefined }; } catch { return null; } @@ -100,6 +103,13 @@ function parseOwnerMqttUsernameMap(): Map { return map; } +async function resolveOwnerNodeIds(mqttUsername: string): Promise { + const databaseNodeIds = await getOwnerNodeIdsForUsername(mqttUsername); + if (databaseNodeIds.length > 0) return databaseNodeIds; + const legacyMap = parseOwnerMqttUsernameMap(); + return legacyMap.get(mqttUsername) ?? []; +} + function verifyMqttCredentials(mqttUsername: string, mqttPassword: string): Promise { const brokerUrl = String(process.env['MQTT_BROKER_URL'] ?? 'ws://mosquitto:9001'); const clientId = `owner-auth-${randomBytes(6).toString('hex')}`; @@ -379,7 +389,7 @@ router.get('/coverage', async (req, res) => { const network = req.query['network'] as string | undefined; const filters = networkFilters(network); const result = await query( - `SELECT nc.node_id, nc.geom, nc.antenna_height_m, nc.radius_m, nc.calculated_at + `SELECT nc.node_id, nc.geom, nc.strength_geoms, nc.antenna_height_m, nc.radius_m, nc.calculated_at FROM node_coverage nc JOIN nodes n ON n.node_id = nc.node_id WHERE (n.name IS NULL OR n.name NOT LIKE '%🚫%') @@ -618,13 +628,8 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => { res.status(400).json({ error: 'Missing MQTT username or password' }); return; } - const usernameMap = parseOwnerMqttUsernameMap(); - if (usernameMap.size < 1) { - res.status(503).json({ error: 'Owner login is not configured on the server' }); - return; - } - const mappedNodeIds = usernameMap.get(mqttUsername); - if (!mappedNodeIds || mappedNodeIds.length < 1) { + const mappedNodeIds = await resolveOwnerNodeIds(mqttUsername); + if (mappedNodeIds.length < 1) { res.status(403).json({ error: 'Invalid MQTT credentials' }); return; } @@ -644,6 +649,7 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => { const token = encryptOwnerSession({ nodeIds: mappedNodeIds, exp: Date.now() + OWNER_SESSION_TTL_MS, + mqttUsername, }); res.cookie(OWNER_COOKIE_NAME, token, { httpOnly: true, @@ -662,7 +668,13 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => { // GET /api/owner/session β€” resolve dashboard from encrypted cookie router.get('/owner/session', async (req, res) => { try { - const sessionNodeIds = await requireOwnerSession(req, res); + const session = getOwnerSession(req); + if (!session) { + res.clearCookie(OWNER_COOKIE_NAME, { path: '/' }); + res.status(401).json({ error: 'Not logged in' }); + return; + } + const sessionNodeIds = session.nodeIds; if (!sessionNodeIds) return; const dashboard = await buildOwnerDashboard(sessionNodeIds); @@ -672,7 +684,7 @@ router.get('/owner/session', async (req, res) => { return; } - res.json({ ok: true, dashboard }); + res.json({ ok: true, dashboard, mqttUsername: session.mqttUsername ?? null }); } catch (err) { console.error('[api] GET /owner/session', (err as Error).message); res.status(500).json({ error: 'Internal server error' }); @@ -698,7 +710,14 @@ router.get('/owner/live', async (req, res) => { return; } - const [ownerNodeResult, incomingResult, packetResult] = await Promise.all([ + const [ + ownerNodeResult, + incomingResult, + packetResult, + heardByResult, + linkHealthResult, + advertTrendResult, + ] = await Promise.all([ query<{ node_id: string; name: string | null; @@ -711,7 +730,7 @@ router.get('/owner/live', async (req, res) => { }>( `SELECT node_id, name, network, iata, advert_count, last_seen, lat, lon FROM nodes - WHERE node_id = $1 + WHERE LOWER(node_id) = LOWER($1) LIMIT 1`, [selectedNodeId], ), @@ -807,6 +826,90 @@ router.get('/owner/live', async (req, res) => { LIMIT 5`, [selectedNodeId], ), + query<{ + node_id: string; + name: string | null; + network: string | null; + iata: string | null; + lat: number | null; + lon: number | null; + packets_24h: number; + packets_7d: number; + last_seen: string | null; + best_hops: number | null; + }>( + `SELECT + p.rx_node_id AS node_id, + n.name, + n.network, + n.iata, + n.lat, + n.lon, + COUNT(DISTINCT CASE WHEN p.time > NOW() - INTERVAL '24 hours' THEN p.packet_hash END)::int AS packets_24h, + COUNT(DISTINCT p.packet_hash)::int AS packets_7d, + MAX(p.time)::text AS last_seen, + MIN(p.hop_count) AS best_hops + FROM packets p + LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id) + WHERE LOWER(p.src_node_id) = LOWER($1) + AND p.rx_node_id IS NOT NULL + AND LOWER(p.rx_node_id) <> LOWER($1) + AND p.time > NOW() - INTERVAL '7 days' + GROUP BY p.rx_node_id, n.name, n.network, n.iata, n.lat, n.lon + ORDER BY packets_24h DESC, packets_7d DESC, last_seen DESC + LIMIT 20`, + [selectedNodeId], + ), + query<{ + peer_node_id: string; + peer_name: string | null; + peer_network: string | null; + owner_to_peer: number; + peer_to_owner: number; + observed_count: number; + itm_path_loss_db: number | null; + itm_viable: boolean | null; + force_viable: boolean; + last_observed: string | null; + }>( + `SELECT + CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END AS peer_node_id, + peer.name AS peer_name, + peer.network AS peer_network, + CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_a_to_b ELSE nl.count_b_to_a END AS owner_to_peer, + CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_b_to_a ELSE nl.count_a_to_b END AS peer_to_owner, + nl.observed_count, + nl.itm_path_loss_db, + nl.itm_viable, + nl.force_viable, + nl.last_observed::text AS last_observed + FROM node_links nl + JOIN nodes peer ON LOWER(peer.node_id) = LOWER(CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END) + WHERE LOWER(nl.node_a_id) = LOWER($1) + OR LOWER(nl.node_b_id) = LOWER($1) + ORDER BY + COALESCE(nl.itm_viable, false) DESC, + nl.force_viable DESC, + nl.observed_count DESC, + nl.itm_path_loss_db ASC NULLS LAST + LIMIT 12`, + [selectedNodeId], + ), + query<{ + bucket: string; + adverts: number; + }>( + `SELECT + time_bucket('1 hour', time)::text AS bucket, + COUNT(DISTINCT packet_hash)::int AS adverts + FROM packets + WHERE LOWER(src_node_id) = LOWER($1) + AND packet_type = 4 + AND time > NOW() - INTERVAL '24 hours' + GROUP BY bucket + ORDER BY bucket`, + [selectedNodeId], + ), ]); const ownerNode = ownerNodeResult.rows[0]; @@ -815,6 +918,54 @@ router.get('/owner/live', async (req, res) => { return; } + const heardBy = heardByResult.rows.map((row) => ({ + ...row, + packets_24h: Number(row.packets_24h ?? 0), + packets_7d: Number(row.packets_7d ?? 0), + best_hops: row.best_hops == null ? null : Number(row.best_hops), + last_seen: row.last_seen ? new Date(row.last_seen).toISOString() : null, + })); + + const linkHealth = linkHealthResult.rows.map((row) => ({ + ...row, + owner_to_peer: Number(row.owner_to_peer ?? 0), + peer_to_owner: Number(row.peer_to_owner ?? 0), + observed_count: Number(row.observed_count ?? 0), + itm_path_loss_db: row.itm_path_loss_db == null ? null : Number(row.itm_path_loss_db), + itm_viable: row.itm_viable == null ? null : Boolean(row.itm_viable), + force_viable: Boolean(row.force_viable), + last_observed: row.last_observed ? new Date(row.last_observed).toISOString() : null, + })); + + const advertTrend24h = (() => { + const byHour = new Map(); + for (const row of advertTrendResult.rows) { + byHour.set(new Date(row.bucket).toISOString(), Number(row.adverts ?? 0)); + } + const series: Array<{ bucket: string; adverts: number }> = []; + const now = new Date(); + now.setUTCMinutes(0, 0, 0); + for (let i = 23; i >= 0; i--) { + const bucket = new Date(now.getTime() - i * 60 * 60 * 1000).toISOString(); + series.push({ bucket, adverts: byHour.get(bucket) ?? 0 }); + } + return series; + })(); + + const alerts: Array<{ level: 'info' | 'warn' | 'error'; message: string }> = []; + const ownerLastSeenMs = ownerNode.last_seen ? new Date(ownerNode.last_seen).getTime() : 0; + const minsSinceSeen = ownerLastSeenMs ? Math.max(0, Math.round((Date.now() - ownerLastSeenMs) / 60000)) : null; + const adverts24h = advertTrend24h.reduce((sum, point) => sum + point.adverts, 0); + const viableLinks = linkHealth.filter((link) => link.itm_viable || link.force_viable); + if (minsSinceSeen == null) alerts.push({ level: 'error', message: 'No last-seen timestamp is available for this repeater.' }); + else if (minsSinceSeen >= 120) alerts.push({ level: 'error', message: `Repeater has not been seen for ${minsSinceSeen} minutes.` }); + else if (minsSinceSeen >= 30) alerts.push({ level: 'warn', message: `Repeater has been quiet for ${minsSinceSeen} minutes.` }); + else alerts.push({ level: 'info', message: 'Repeater is active and has checked in recently.' }); + + if (adverts24h < 1) alerts.push({ level: 'warn', message: 'No advert packets from this repeater were recorded in the last 24 hours.' }); + if (heardBy.length < 1) alerts.push({ level: 'warn', message: 'No other nodes have heard this repeater in the last 7 days.' }); + if (viableLinks.length < 1) alerts.push({ level: 'warn', message: 'No viable RF links are currently stored for this repeater.' }); + res.json({ nodeId: selectedNodeId, ownerNode: { @@ -827,6 +978,10 @@ router.get('/owner/live', async (req, res) => { packets_24h: Number(row.packets_24h ?? 0), last_seen: row.last_seen ? new Date(row.last_seen).toISOString() : null, })), + heardBy, + linkHealth, + advertTrend24h, + alerts, recentPackets: packetResult.rows.map((row) => ({ ...row, time: new Date(row.time).toISOString(), diff --git a/backend/src/db/owner-auth.sql b/backend/src/db/owner-auth.sql new file mode 100644 index 0000000..31105f7 --- /dev/null +++ b/backend/src/db/owner-auth.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS owner_accounts ( + mqtt_username TEXT PRIMARY KEY, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS owner_account_nodes ( + mqtt_username TEXT NOT NULL REFERENCES owner_accounts(mqtt_username) ON DELETE CASCADE, + node_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (mqtt_username, node_id) +); + +CREATE INDEX IF NOT EXISTS owner_account_nodes_node_idx + ON owner_account_nodes(node_id); diff --git a/backend/src/db/ownerAuth.ts b/backend/src/db/ownerAuth.ts new file mode 100644 index 0000000..148c1a4 --- /dev/null +++ b/backend/src/db/ownerAuth.ts @@ -0,0 +1,96 @@ +import pg from 'pg'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const { Pool } = pg; +const OWNER_DB_NAME = process.env['OWNER_POSTGRES_DB'] ?? 'meshcore_owner_auth'; + +function getPrimaryDatabaseUrl(): string { + const raw = String(process.env['DATABASE_URL'] ?? '').trim(); + if (!raw) throw new Error('DATABASE_URL is required'); + return raw; +} + +function withDatabaseName(connectionString: string, databaseName: string): string { + const url = new URL(connectionString); + url.pathname = `/${databaseName}`; + return url.toString(); +} + +function getOwnerDatabaseUrl(): string { + return String(process.env['OWNER_DATABASE_URL'] ?? '').trim() + || withDatabaseName(getPrimaryDatabaseUrl(), OWNER_DB_NAME); +} + +function getAdminDatabaseUrl(): string { + return withDatabaseName(getPrimaryDatabaseUrl(), 'postgres'); +} + +const ownerPool = new Pool({ + connectionString: getOwnerDatabaseUrl(), + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, +}); + +ownerPool.on('error', (err) => { + console.error('[owner-auth] unexpected pool error', err.message); +}); + +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +async function ensureOwnerDatabase(): Promise { + const ownerUrl = new URL(getOwnerDatabaseUrl()); + const databaseName = ownerUrl.pathname.replace(/^\//, '').trim(); + if (!databaseName) throw new Error('OWNER_DATABASE_URL is missing a database name'); + + const adminPool = new Pool({ + connectionString: getAdminDatabaseUrl(), + max: 1, + idleTimeoutMillis: 5_000, + connectionTimeoutMillis: 5_000, + }); + + try { + const exists = await adminPool.query<{ exists: number }>( + 'SELECT 1 AS exists FROM pg_database WHERE datname = $1', + [databaseName], + ); + if (exists.rowCount && exists.rows[0]?.exists === 1) return; + await adminPool.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`); + console.log(`[owner-auth] created database ${databaseName}`); + } finally { + await adminPool.end().catch(() => undefined); + } +} + +export async function initOwnerAuthDb(): Promise { + await ensureOwnerDatabase(); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const schemaPath = path.join(__dirname, 'owner-auth.sql'); + const sql = fs.readFileSync(schemaPath, 'utf8'); + await ownerPool.query(sql); + console.log('[owner-auth] schema initialised'); +} + +export async function getOwnerNodeIdsForUsername(mqttUsername: string): Promise { + const normalized = mqttUsername.trim(); + if (!normalized) return []; + const res = await ownerPool.query<{ node_id: string }>( + `SELECT node_id + FROM owner_account_nodes oan + JOIN owner_accounts oa ON oa.mqtt_username = oan.mqtt_username + WHERE oa.is_active = true + AND oan.mqtt_username = $1 + ORDER BY oan.created_at ASC`, + [normalized], + ); + return Array.from(new Set( + res.rows + .map((row) => row.node_id.trim().toLowerCase()) + .filter((nodeId) => /^[0-9a-f]{64}$/.test(nodeId)), + )); +} diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 47e8275..d74a8e8 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -143,10 +143,14 @@ ALTER TABLE node_links ADD COLUMN IF NOT EXISTS force_viable BOOLEAN NOT NULL CREATE TABLE IF NOT EXISTS node_coverage ( node_id TEXT PRIMARY KEY, geom JSONB NOT NULL, -- GeoJSON Polygon or MultiPolygon + strength_geoms JSONB, antenna_height_m DOUBLE PRECISION DEFAULT 10, radius_m DOUBLE PRECISION DEFAULT 30000, + model_version INTEGER NOT NULL DEFAULT 1, calculated_at TIMESTAMPTZ DEFAULT NOW() ); +ALTER TABLE node_coverage ADD COLUMN IF NOT EXISTS strength_geoms JSONB; +ALTER TABLE node_coverage ADD COLUMN IF NOT EXISTS model_version INTEGER NOT NULL DEFAULT 1; -- ─── Learned path priors from historical packets ───────────────────────────── diff --git a/backend/src/index.ts b/backend/src/index.ts index 2ca5e7d..57467f3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import express from 'express'; import cors from 'cors'; import { rateLimit } from 'express-rate-limit'; import { initDb, query } from './db/index.js'; +import { initOwnerAuthDb } from './db/ownerAuth.js'; import { startMqttClient, onPacket, onNodeSeen, onNodeUpsert } from './mqtt/client.js'; import { initWebSocketServer, broadcastPacket, broadcastNodeUpdate, broadcastNodeUpsert } from './ws/server.js'; import apiRoutes from './api/routes.js'; @@ -15,10 +16,12 @@ const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '') .filter(Boolean); const PORT = Number(process.env['PORT'] ?? 3000); +const COVERAGE_MODEL_VERSION = Number(process.env['COVERAGE_MODEL_VERSION'] ?? 3); async function main() { // 1. Initialise DB schema + retention policy await initDb(); + await initOwnerAuthDb(); // Queue viewshed jobs for any node with a position but no coverage yet // (catches nodes that existed before the worker was added) @@ -26,12 +29,14 @@ async function main() { const uncovered = await query<{ node_id: string; lat: number; lon: number }>( `SELECT n.node_id, n.lat, n.lon FROM nodes n LEFT JOIN node_coverage nc ON n.node_id = nc.node_id - WHERE n.lat IS NOT NULL AND n.lon IS NOT NULL AND nc.node_id IS NULL + WHERE n.lat IS NOT NULL AND n.lon IS NOT NULL + AND (nc.node_id IS NULL OR nc.model_version < $1) AND (n.name IS NULL OR n.name NOT LIKE '%🚫%') - AND (n.role IS NULL OR n.role = 2)` + AND (n.role IS NULL OR n.role = 2)`, + [COVERAGE_MODEL_VERSION], ); if (uncovered.rows.length > 0) { - console.log(`[app] queuing ${uncovered.rows.length} node(s) for viewshed`); + console.log(`[app] queuing ${uncovered.rows.length} node(s) for viewshed (model v${COVERAGE_MODEL_VERSION})`); // Jobs are pushed here but the Redis pub client isn't ready yet β€” // defer until after initWebSocketServer wires up the Redis client. process.nextTick(() => { diff --git a/docker-compose.yml b/docker-compose.yml index 3ab7cd0..1a76c74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,9 +50,11 @@ services: OPENTOPODATA_API: ${OPENTOPODATA_API:-https://api.opentopodata.org} PORT: ${PORT:-3000} MESHCORE_CHANNEL_SECRETS: ${MESHCORE_CHANNEL_SECRETS:-} + OWNER_DATABASE_URL: ${OWNER_DATABASE_URL:-postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${OWNER_POSTGRES_DB:-meshcore_owner_auth}} OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-} OWNER_COOKIE_SECRET: ${OWNER_COOKIE_SECRET:-} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:3002} + COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-2} NODE_ENV: production depends_on: timescaledb: @@ -451,6 +453,8 @@ services: SRTM_DIR: /data/srtm WORKER_MODE: viewshed NUM_WORKERS: '1' + COVERAGE_MODEL: ${COVERAGE_MODEL:-terrain_los} + COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-2} volumes: - srtm_data:/data/srtm deploy: diff --git a/frontend/src/components/LiveStatsSection.tsx b/frontend/src/components/LiveStatsSection.tsx index 15c35c1..c555f6f 100644 --- a/frontend/src/components/LiveStatsSection.tsx +++ b/frontend/src/components/LiveStatsSection.tsx @@ -62,7 +62,10 @@ export const LiveStatsSection: React.FC = ({ network }) = return (
-

Live network stats Β· updates every 30s

+
+

Live network stats

+

Updates every 30 seconds from the shared packet feed.

+
diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index 53bf2d7..c7dc6c7 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -48,11 +48,17 @@ const LeafletDeckSyncer: React.FC = ({ onViewStateChange }) => { return null; }; -// GeoJSON rings are [lon, lat]; Leaflet wants [lat, lon]. function ringToLatLng(ring: number[][]): LatLngExpression[] { return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression); } +function geomToRings(geom: { type: string; coordinates: unknown } | null | undefined): LatLngExpression[][] { + if (!geom) return []; + if (geom.type === 'Polygon') return [ringToLatLng((geom.coordinates as number[][][])[0])]; + if (geom.type === 'MultiPolygon') return (geom.coordinates as number[][][][]).map((poly) => ringToLatLng(poly[0])); + return []; +} + function hasCoords(node: MeshNode | null | undefined): node is MeshNode & { lat: number; lon: number } { return typeof node?.lat === 'number' && typeof node?.lon === 'number'; } @@ -67,13 +73,11 @@ function isHiddenMapNode(node: MeshNode | null | undefined): boolean { // - no opacity stacking: single SVG element, one fill pass βœ“ function useCoverageDisplayRings(coverage: NodeCoverage[]): LatLngExpression[][] { return useMemo(() => { - return coverage.flatMap((c) => { - if (c.geom.type === 'Polygon') - return [ringToLatLng((c.geom.coordinates as number[][][])[0])]; - if (c.geom.type === 'MultiPolygon') - return (c.geom.coordinates as number[][][][]).map((poly) => ringToLatLng(poly[0])); - return []; - }); + const rings: LatLngExpression[][] = []; + for (const c of coverage) { + rings.push(...geomToRings(c.geom)); + } + return rings; // eslint-disable-next-line react-hooks/exhaustive-deps }, [coverage]); } @@ -519,10 +523,10 @@ export const MapView: React.FC = ({ diff --git a/frontend/src/components/Map/NodeMarker.tsx b/frontend/src/components/Map/NodeMarker.tsx index 7f26655..d49fbc2 100644 --- a/frontend/src/components/Map/NodeMarker.tsx +++ b/frontend/src/components/Map/NodeMarker.tsx @@ -74,12 +74,16 @@ function ringToLatLng(ring: number[][]): LatLngExpression[] { return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression); } -function coverageToRings(cov: NodeCoverage): LatLngExpression[][] { - const geom = cov.geom; - if (geom.type === 'Polygon') - return [(geom.coordinates as number[][][])[0]!].map(ringToLatLng); - if (geom.type === 'MultiPolygon') - return (geom.coordinates as number[][][][]).map((poly) => ringToLatLng(poly[0]!)); +function coverageToPolygons(geom: { type: string; coordinates: unknown } | null | undefined): LatLngExpression[][][] { + if (!geom) return []; + if (geom.type === 'Polygon') { + const polygon = geom.coordinates as number[][][]; + return [polygon.map((ring) => ringToLatLng(ring))]; + } + if (geom.type === 'MultiPolygon') { + const multiPolygon = geom.coordinates as number[][][][]; + return multiPolygon.map((polygon) => polygon.map((ring) => ringToLatLng(ring))); + } return []; } @@ -141,7 +145,11 @@ export const NodeMarker: React.FC = React.memo(({ ? 'var(--danger)' : node.is_online ? 'var(--online)' : 'var(--offline)'; - const previewRings = showPreview && nodeCoverage ? coverageToRings(nodeCoverage) : []; + const previewBands = showPreview && nodeCoverage ? { + red: coverageToPolygons(nodeCoverage.strength_geoms?.red ?? nodeCoverage.geom), + amber: coverageToPolygons(nodeCoverage.strength_geoms?.amber), + green: coverageToPolygons(nodeCoverage.strength_geoms?.green), + } : { red: [], amber: [], green: [] }; const showSamePrefixRow = (node.role === undefined || node.role === 2) && typeof samePrefixRepeaterCount === 'number'; return ( @@ -243,20 +251,44 @@ export const NodeMarker: React.FC = React.memo(({ - {previewRings.length > 0 && ( + {(previewBands.red.length > 0 || previewBands.amber.length > 0 || previewBands.green.length > 0) && ( - + {previewBands.red.length > 0 && ( + + )} + {previewBands.amber.length > 0 && ( + + )} + {previewBands.green.length > 0 && ( + + )} )} diff --git a/frontend/src/components/app/DisclaimerModal.tsx b/frontend/src/components/app/DisclaimerModal.tsx index 242e7dc..9c01baa 100644 --- a/frontend/src/components/app/DisclaimerModal.tsx +++ b/frontend/src/components/app/DisclaimerModal.tsx @@ -22,10 +22,11 @@ export const DisclaimerModal: React.FC = ({ onClose }) =>

Coverage map

- The green coverage layer is a radio horizon estimate computed from SRTM terrain data. - It assumes each repeater antenna is mounted 5 metres above ground level. - Actual coverage will vary with antenna height, local obstacles, foliage, and radio - conditions. Treat it as a rough guide, not a guarantee of connectivity. + The green coverage layer is a precomputed RF estimate built from terrain data and a + simplified diffraction/path-loss model. It assumes the source repeater and the receiving + repeater are both mounted 5 metres above ground level. Actual coverage + will still vary with local obstacles, foliage, antenna placement, and radio settings, so + treat it as a guide rather than a guarantee of connectivity.

@@ -33,4 +34,3 @@ export const DisclaimerModal: React.FC = ({ onClose }) =>
); - diff --git a/frontend/src/hooks/useAppMessageHandler.ts b/frontend/src/hooks/useAppMessageHandler.ts index db98520..8366cf8 100644 --- a/frontend/src/hooks/useAppMessageHandler.ts +++ b/frontend/src/hooks/useAppMessageHandler.ts @@ -21,7 +21,11 @@ type UseAppMessageHandlerParams = { handlePacket: (data: LivePacketData) => void; handleNodeUpdate: (data: { nodeId: string; ts: number }) => void; handleNodeUpsert: (data: Partial & { node_id: string }) => void; - handleCoverageUpdate: (data: { node_id: string; geom: { type: string; coordinates: unknown } }) => void; + handleCoverageUpdate: (data: { + node_id: string; + geom: { type: string; coordinates: unknown }; + strength_geoms?: Partial>; + }) => void; applyInitialViablePairs: (pairs?: [string, string][]) => void; applyInitialViableLinks: (links?: ViableLinkSnapshot[]) => void; applyLinkUpdate: (update: { @@ -76,7 +80,11 @@ export function useAppMessageHandler({ } if (msg.type === 'coverage_update') { - handleCoverageUpdate(msg.data as { node_id: string; geom: { type: string; coordinates: unknown } }); + handleCoverageUpdate(msg.data as { + node_id: string; + geom: { type: string; coordinates: unknown }; + strength_geoms?: Partial>; + }); return; } diff --git a/frontend/src/hooks/useCoverage.ts b/frontend/src/hooks/useCoverage.ts index f487f8d..9d8398f 100644 --- a/frontend/src/hooks/useCoverage.ts +++ b/frontend/src/hooks/useCoverage.ts @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react'; export interface NodeCoverage { node_id: string; geom: { type: string; coordinates: unknown }; + strength_geoms?: Partial>; antenna_height_m?: number; radius_m?: number; calculated_at?: string; @@ -21,10 +22,10 @@ export function useCoverage(network?: string) { }, [network]); // Called when a coverage_update WS message arrives - const handleCoverageUpdate = useCallback((update: { node_id: string; geom: NodeCoverage['geom'] }) => { + const handleCoverageUpdate = useCallback((update: { node_id: string; geom: NodeCoverage['geom']; strength_geoms?: NodeCoverage['strength_geoms'] }) => { setCoverage((prev) => { const filtered = prev.filter((c) => c.node_id !== update.node_id); - return [...filtered, { node_id: update.node_id, geom: update.geom }]; + return [...filtered, { node_id: update.node_id, geom: update.geom, strength_geoms: update.strength_geoms }]; }); }, []); diff --git a/frontend/src/pages/HealthPage.tsx b/frontend/src/pages/HealthPage.tsx index d1a337a..b3e2b76 100644 --- a/frontend/src/pages/HealthPage.tsx +++ b/frontend/src/pages/HealthPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; type HealthPayload = { system: { @@ -109,16 +109,6 @@ export const HealthPage: React.FC = () => { }; }, []); - const historyByWorker = useMemo(() => { - const grouped = new Map(); - for (const row of data?.history ?? []) { - const list = grouped.get(row.worker_name) ?? []; - list.push(row); - grouped.set(row.worker_name, list); - } - return grouped; - }, [data]); - return ( <>
@@ -147,9 +137,6 @@ export const HealthPage: React.FC = () => {
{(data?.workers ?? []).map((worker) => { const statusClass = worker.status === 'running' ? 'health-pill health-pill--ok' : 'health-pill'; - const hist = historyByWorker.get(worker.worker_name) ?? []; - const peakQueue = Math.max(1, ...hist.map((h) => h.queue_depth)); - const queueBars = hist.slice(0, 48).reverse(); const isExpanded = expandedWorker === worker.worker_name; const description = workerDescriptions[worker.worker_name] ?? 'No description available for this worker.'; @@ -177,16 +164,6 @@ export const HealthPage: React.FC = () => {
Processed 1h{worker.processed_1h}
Last Activity{timeAgo(worker.last_activity_at)}
-
- {queueBars.map((row, idx) => ( - - ))} -
{isExpanded && (

{description}

)} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d80b51f..1680cd8 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -8,117 +8,146 @@ export const HomePage: React.FC = () => { return ( <> - {/* ── Hero ─────────────────────────────────────────────────────── */} -
-
-
-
North East England Β· LoRa 868 MHz
-

- Teesside
- Mesh Network -

-

- An experimental off-grid communications network built on{' '} - MeshCore,{' '} - a free, open-source LoRa mesh platform. No internet. No infrastructure. Just radio. -

-
- Open Live Map β†’ - Learn more +
+
+
+

Teesside Mesh Network

+

+ A regional MeshCore deployment for Teesside and the wider North East. The website documents the + network, the live map shows what the observer hears, and the MQTT path lets repeater owners feed + packet data into the shared view. +

+
+ Open live map + Install MeshCore + MQTT observer setup +
+ +
+

Network overview

+
+
+ Coverage + Teesside and North East England +
+
+ Band + LoRa 868 MHz +
+
+ Channel + Public +
+
+ Observer ingest + MQTT via UK Mesh broker +
+
+

+ Teesside views are filtered from the shared ingest path. Observers using IATA MME{' '} + show up here and in the wider UK stack. +

+
- {/* ── Radio config ─────────────────────────────────────────────── */} -
-
-

Network radio configuration

-
-
- EU/UK Narrow - Profile -
-
- 869.618 - Frequency (MHz) -
-
- 62.5kHz - Bandwidth -
-
- SF8 - Spreading Factor -
-
- CR8 - Coding Rate -
-
-
-
- - {/* ── About cards ─────────────────────────────────────────────── */}
-
- -
-
πŸ“‘
-

What is MeshCore?

-

- MeshCore is open-source firmware for ESP32 LoRa hardware. Each node acts as - both a radio and a relay. Packets hop between nodes automatically, extending - range far beyond what a single radio can achieve. -

- Learn more β†’ +
+
+

Use the network

+

Everything on the public site should answer one of three questions: what MeshCore is, how to join, and how to contribute useful coverage.

+
+
+

What MeshCore is

+

+ MeshCore is open-source firmware for ESP32 LoRa hardware. Nodes relay packets between each other, + so a message can keep moving after it has left the original transmitter. +

+ Read the overview +
-
-
πŸ”§
-

Get on the network

-

- All you need is a supported LoRa board and about 10 minutes. Flash the firmware - in your browser, configure your node, and you're on the air. In the UK, 868 MHz - operation is licence-exempt when run within Ofcom IR2030 limits. -

- Install guide β†’ +
+

Get on the air

+

+ A handheld node is the fastest way in. Flash the firmware in a browser, pair it to your phone, + set the UK profile, and use the default Public channel. +

+ Open the install guide +
+ +
+

Feed the dashboards

+

+ Repeater owners can run `meshcoretomqtt` on a Pi or other Linux host, publish packets over MQTT, + and contribute live telemetry back into the map and stats pages. +

+ MQTT setup +
+ +
+

Inspect live traffic

+

+ The map shows repeater locations, live packets, path predictions, coverage modelling, and the + stats pages that sit behind the public network view. +

+ Open the live map +
- -
-
πŸ—ΊοΈ
-

Live map & analytics

-

- The live dashboard shows every packet we hear in real time: node positions, - relay paths, RF coverage, and a decoded packet feed. Built entirely on open - source tools. -

- Open map β†’ -
-
- {/* ── Discord CTA ─────────────────────────────────────────────── */}
-
-
-

Join the conversation

-

- We hang out in the North East England regional channel on the - MeshCore Discord. Come say hello, ask questions, or arrange a test contact. - DM ibengr to find out more. -

+
+
+

Radio profile

+

These are the settings used on the Teesside side of the network.

+
+
+
+ Profile + EU/UK Narrow +
+
+ Frequency + 869.618 MHz +
+
+ Bandwidth + 62.5 kHz +
+
+ Spreading factor + SF8 +
+
+ Coding rate + CR8 +
+
+
+
+ +
+
+
+
+

Join the conversation

+

+ The network is community-run. If you want help with hardware, range testing, observer setup, or + repeater placement, the Discord is where that happens. +

+
+
+ + Join Discord + + View source +
- - Join Discord β†’ -
diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index 60b5e63..9bd151a 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -26,8 +26,15 @@ type OwnerDashboard = { type OwnerSessionResponse = { ok: boolean; dashboard: OwnerDashboard; + mqttUsername?: string | null; }; +const OWNER_SESSION_EVENT = 'meshcore-owner-session'; + +function publishOwnerSession(mqttUsername: string | null) { + window.dispatchEvent(new CustomEvent(OWNER_SESSION_EVENT, { detail: { mqttUsername } })); +} + type LivePeer = { node_id: string; name: string | null; @@ -55,6 +62,21 @@ type OwnerLiveResponse = { nodeId: string; ownerNode: OwnerNode; incomingPeers: LivePeer[]; + heardBy: Array; + linkHealth: Array<{ + peer_node_id: string; + peer_name: string | null; + peer_network: string | null; + owner_to_peer: number; + peer_to_owner: number; + observed_count: number; + itm_path_loss_db: number | null; + itm_viable: boolean | null; + force_viable: boolean; + last_observed: string | null; + }>; + advertTrend24h: Array<{ bucket: string; adverts: number }>; + alerts: Array<{ level: 'info' | 'warn' | 'error'; message: string }>; recentPackets: LivePacket[]; }; @@ -100,6 +122,53 @@ function cleanPacketBody(packet: LivePacket): string | null { return body; } +function formatCompactTs(ts: string | null): string { + if (!ts) return '-'; + return new Date(ts).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatPathLoss(value: number | null): string { + if (value == null || !Number.isFinite(value)) return '-'; + return `${value.toFixed(1)} dB`; +} + +function linkBadge(link: OwnerLiveResponse['linkHealth'][number]): string { + if (link.force_viable) return 'Forced'; + if (link.itm_viable) return 'Viable'; + if (link.itm_path_loss_db != null && link.itm_path_loss_db <= 137.88) return 'Weak'; + return 'Unproven'; +} + +const TrendBars: React.FC<{ points: Array<{ bucket: string; adverts: number }> }> = ({ points }) => { + const max = Math.max(1, ...points.map((point) => point.adverts)); + return ( +
+
+ {points.map((point) => { + const height = Math.max(10, Math.round((point.adverts / max) * 100)); + return ( +
+ ); + })} +
+
+ 24h advert trend + {points.reduce((sum, point) => sum + point.adverts, 0)} +
+
+ ); +}; + const MAP_CENTER: LatLngExpression = [54.6, -1.2]; const FitToNodes: React.FC<{ points: Array<{ lat: number; lon: number }> }> = ({ points }) => { @@ -139,6 +208,7 @@ export const OwnerPortalPage: React.FC = () => { .then((json) => { if (cancelled) return; setDashboard(json?.dashboard ?? null); + publishOwnerSession(json?.mqttUsername ?? null); if (json?.dashboard?.nodes?.[0]?.node_id) { setSelectedNodeId(json.dashboard.nodes[0].node_id); } @@ -147,6 +217,7 @@ export const OwnerPortalPage: React.FC = () => { .catch(() => { if (cancelled) return; setDashboard(null); + publishOwnerSession(null); setLoading(false); }); return () => { @@ -179,6 +250,7 @@ export const OwnerPortalPage: React.FC = () => { }) .then((json) => { setDashboard(json.dashboard); + publishOwnerSession(json.mqttUsername ?? mqttUsername.trim()); if (json.dashboard.nodes[0]?.node_id) { setSelectedNodeId(json.dashboard.nodes[0].node_id); } @@ -197,6 +269,7 @@ export const OwnerPortalPage: React.FC = () => { setDashboard(null); setLive(null); setError(null); + publishOwnerSession(null); }); }; @@ -258,6 +331,18 @@ export const OwnerPortalPage: React.FC = () => { [live], ); + const strongestLink = useMemo(() => { + const links = live?.linkHealth ?? []; + return links + .filter((link) => link.itm_path_loss_db != null) + .sort((a, b) => (a.itm_path_loss_db ?? Number.POSITIVE_INFINITY) - (b.itm_path_loss_db ?? Number.POSITIVE_INFINITY))[0] ?? null; + }, [live]); + + const viableLinkCount = useMemo( + () => (live?.linkHealth ?? []).filter((link) => link.itm_viable || link.force_viable).length, + [live], + ); + return ( <>
@@ -269,7 +354,7 @@ export const OwnerPortalPage: React.FC = () => {
-
+
{loading ?

Checking login session...

: null} {!loading && !dashboard ? (
@@ -341,121 +426,182 @@ export const OwnerPortalPage: React.FC = () => {
{live?.ownerNode.advert_count ?? 0}Adverts
{fmtTs(live?.ownerNode.last_seen ?? null)}Last Seen
{live?.incomingPeers.length ?? 0}Direct Senders (24h)
+
{live?.heardBy.length ?? 0}Heard By (7d)
+
{viableLinkCount}Viable Links
+
{strongestLink?.peer_name ?? '-'}Strongest Link
+
{formatPathLoss(strongestLink?.itm_path_loss_db ?? null)}Best Path Loss
+
{(live?.advertTrend24h ?? []).reduce((sum, point) => sum + point.adverts, 0)}Adverts (24h)
+
{dashboard.totals.packets24h}Packets Sent (24h)
{liveError ?

Live data error: {liveError}

: null}
-
-

Direct Sender Map

-

Fixed view for 0-hop direct senders in the last 24 hours. Nodes at 0,0 are hidden from this map.

-
- - - - {ownerCoord ? ( - - - {live?.ownerNode.name ?? 'Owner repeater'}
- {live?.ownerNode.network} Β· {live?.ownerNode.iata ?? '-'} -
-
- ) : null} - {mapPeers.map((peer) => ( - - - {peer.name ?? peer.node_id}
- {peer.network ?? 'Unknown'} Β· {peer.iata ?? '-'}
- Packets 24h: {peer.packets_24h} -
-
- ))} - {ownerCoord - ? mapPeers.map((peer) => ( - - )) - : null} -
-
-
- -
-

Direct Senders (24h)

-
- - - - - - - - - - - - {(live?.incomingPeers ?? []).map((peer) => ( - - - - - - - - ))} - {(live?.incomingPeers ?? []).length === 0 ? ( - - - +
+
+
+
+

Direct Sender Map

+

0-hop direct senders in the last 24 hours. Nodes at 0,0 are hidden.

+
+
+
+ + + + {ownerCoord ? ( + + + {live?.ownerNode.name ?? 'Owner repeater'}
+ {live?.ownerNode.network} Β· {live?.ownerNode.iata ?? '-'} +
+
) : null} -
-
NameNetworkIATAPackets 24hLast Seen
{peer.name ?? peer.node_id}{peer.network ?? '-'}{peer.iata ?? '-'}{peer.packets_24h}{fmtTs(peer.last_seen)}
No direct sender nodes found in the last 24 hours.
-
-
+ {mapPeers.map((peer) => ( + + + {peer.name ?? peer.node_id}
+ {peer.network ?? 'Unknown'} Β· {peer.iata ?? '-'}
+ Packets 24h: {peer.packets_24h} +
+
+ ))} + {ownerCoord + ? mapPeers.map((peer) => ( + + )) + : null} + + +
-
-

Live Packets Received By Repeater

-
- {(live?.recentPackets ?? []).map((packet, idx) => ( -
-
- {PACKET_LABELS[Number(packet.packet_type ?? -1)] ?? `Type ${packet.packet_type ?? '?'}`} - {fmtTs(packet.time)} -
-
- From Node: {packet.src_node_name ?? packet.src_node_id ?? '-'} - Sender: {packet.sender ?? '-'} - Hops: {packet.hop_count ?? '-'} - Route: {ROUTE_LABELS[Number(packet.route_type ?? -1)] ?? (packet.route_type ?? '-')} - Hash: {packet.packet_hash ?? '-'} -
- {cleanPacketBody(packet) ?

{cleanPacketBody(packet)}

: null} -
- ))} - {(live?.recentPackets ?? []).length === 0 ? ( -

No packets received by this repeater yet.

- ) : null} -
-
+
+

Alerts

+
+ {(live?.alerts ?? []).map((alert, idx) => ( +
+ {alert.level.toUpperCase()} + {alert.message} +
+ ))} +
+
+ +
+

Advert Trend

+ +
+ +
+

RF Link Health

+
+ {(live?.linkHealth ?? []).slice(0, 8).map((link) => ( +
+
+ {link.peer_name ?? link.peer_node_id} + {link.peer_network ?? '-'} +
+
+ {linkBadge(link)} + {formatPathLoss(link.itm_path_loss_db)} + {link.owner_to_peer}/{link.peer_to_owner} + {link.observed_count} obs +
+
+ ))} + {(live?.linkHealth ?? []).length === 0 ? ( +

No link health data has been calculated for this repeater yet.

+ ) : null} +
+
+ +
+

Heard By

+
+ {(live?.heardBy ?? []).slice(0, 8).map((peer) => ( +
+
+ {peer.name ?? peer.node_id} + {peer.network ?? '-'} Β· {peer.iata ?? '-'} +
+
+ {peer.packets_24h} / 24h + {peer.packets_7d} / 7d + {peer.best_hops == null ? '-' : `${peer.best_hops} hops`} +
+
+ ))} + {(live?.heardBy ?? []).length === 0 ? ( +

No nodes have heard this repeater in the last 7 days.

+ ) : null} +
+
+ +
+

Direct Senders

+
+ {(live?.incomingPeers ?? []).slice(0, 8).map((peer) => ( +
+
+ {peer.name ?? peer.node_id} + {peer.network ?? '-'} Β· {peer.iata ?? '-'} +
+
+ {peer.packets_24h} / 24h + {formatCompactTs(peer.last_seen)} +
+
+ ))} + {(live?.incomingPeers ?? []).length === 0 ? ( +

No direct sender nodes found in the last 24 hours.

+ ) : null} +
+
+ +
+

Live Packets Received By Repeater

+
+ {(live?.recentPackets ?? []).map((packet, idx) => ( +
+
+ {PACKET_LABELS[Number(packet.packet_type ?? -1)] ?? `Type ${packet.packet_type ?? '?'}`} + {fmtTs(packet.time)} +
+
+ From: {packet.src_node_name ?? packet.src_node_id ?? '-'} + Sender: {packet.sender ?? '-'} + Hops: {packet.hop_count ?? '-'} + Route: {ROUTE_LABELS[Number(packet.route_type ?? -1)] ?? (packet.route_type ?? '-')} +
+ {cleanPacketBody(packet) ?

{cleanPacketBody(packet)}

: null} +
+ ))} + {(live?.recentPackets ?? []).length === 0 ? ( +

No packets received by this repeater yet.

+ ) : null} +
+
+ ) : null} diff --git a/frontend/src/pages/shared/SiteLayout.tsx b/frontend/src/pages/shared/SiteLayout.tsx index c1c00ea..590ca68 100644 --- a/frontend/src/pages/shared/SiteLayout.tsx +++ b/frontend/src/pages/shared/SiteLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'; type SiteLayoutProps = { @@ -15,6 +15,13 @@ type NavItem = { enabled: boolean; }; +const OWNER_SESSION_EVENT = 'meshcore-owner-session'; + +type OwnerSessionSummary = { + ok: boolean; + mqttUsername?: string | null; +}; + function navClassName({ isActive }: { isActive: boolean }): string { return isActive ? 'site-nav__link site-nav__link--active' : 'site-nav__link'; } @@ -27,6 +34,7 @@ export const SiteLayout: React.FC = ({ showStats, }) => { const [menuOpen, setMenuOpen] = useState(false); + const [ownerLabel, setOwnerLabel] = useState(null); const navigate = useNavigate(); const navItems: NavItem[] = [ @@ -46,6 +54,38 @@ export const SiteLayout: React.FC = ({ navigate(to); }; + useEffect(() => { + let cancelled = false; + + const loadOwnerSession = () => { + fetch('/api/owner/session', { cache: 'no-store' }) + .then(async (res) => { + if (!res.ok) return null; + return (await res.json()) as OwnerSessionSummary; + }) + .then((json) => { + if (cancelled) return; + setOwnerLabel(json?.mqttUsername?.trim() || null); + }) + .catch(() => { + if (cancelled) return; + setOwnerLabel(null); + }); + }; + + const handleOwnerSession = (event: Event) => { + const detail = (event as CustomEvent<{ mqttUsername?: string | null }>).detail; + setOwnerLabel(detail?.mqttUsername?.trim() || null); + }; + + loadOwnerSession(); + window.addEventListener(OWNER_SESSION_EVENT, handleOwnerSession as EventListener); + return () => { + cancelled = true; + window.removeEventListener(OWNER_SESSION_EVENT, handleOwnerSession as EventListener); + }; + }, []); + return (
diff --git a/frontend/src/pages/ukmesh/UKHomePage.tsx b/frontend/src/pages/ukmesh/UKHomePage.tsx index ec2f6f3..334a65f 100644 --- a/frontend/src/pages/ukmesh/UKHomePage.tsx +++ b/frontend/src/pages/ukmesh/UKHomePage.tsx @@ -8,117 +8,142 @@ export const UKHomePage: React.FC = () => { return ( <> - {/* ── Hero ─────────────────────────────────────────────────────── */} -
-
-
-
United Kingdom Β· LoRa 868 MHz
-

- UK
- Mesh Network -

-

- A UK-wide off-grid communications network built on{' '} - MeshCore,{' '} - a free, open-source LoRa mesh platform. No internet. No infrastructure. Just radio. -

-
- Open Live Map β†’ - Learn more +
+
+
+

UK Mesh Network

+

+ The UK-wide public site for MeshCore traffic, repeater coverage, observer ingestion, and the + supporting documentation behind the live map. Teesside is part of this wider feed rather than a + separate stack. +

+
+ Open live map + Install MeshCore + Become an observer +
+ +
+

Network overview

+
+
+ Coverage + United Kingdom +
+
+ Band + LoRa 868 MHz +
+
+ Channel + Public +
+
+ Observer ingest + Shared MQTT broker +
+
+
- {/* ── Radio config ─────────────────────────────────────────────── */} -
-
-

Network radio configuration

-
-
- EU/UK Narrow - Profile -
-
- 869.618 - Frequency (MHz) -
-
- 62.5kHz - Bandwidth -
-
- SF8 - Spreading Factor -
-
- CR8 - Coding Rate -
-
-
-
- - {/* ── About cards ─────────────────────────────────────────────── */}
-
- -
-
πŸ“‘
-

What is MeshCore?

-

- MeshCore is open-source firmware for ESP32 LoRa hardware. Each node acts as - both a radio and a relay. Packets hop between nodes automatically, extending - range far beyond what a single radio can achieve. -

- Learn more β†’ +
+
+

Use the network

+

The public site covers the national network, the observer feed, and the operational pages that sit around the live map.

+
+
+

What MeshCore is

+

+ MeshCore is open-source firmware for LoRa hardware. Each node can forward packets, which is what + makes long regional chains and repeater coverage possible. +

+ Read the overview +
-
-
πŸ”§
-

Become an observer

-

- Connect your repeater node to the UK Mesh MQTT broker and contribute live - packet data from your area. All you need is a Linux device, a USB cable, - and about 15 minutes. -

- Observer setup β†’ +
+

Get on the air

+

+ Flash a supported device, pair it to your phone, and use the UK public profile. That gets you + onto the same channel used across the wider network. +

+ Open the install guide +
+ +
+

Become an observer

+

+ Connect a repeater or room server to the broker, publish packets over MQTT, and add another view + of the network from your own location. +

+ Observer setup +
+ +
+

Inspect live traffic

+

+ The live app shows repeater positions, path predictions, coverage layers, decoded packets, and + the supporting stats for the UK feed. +

+ Open the live map +
- -
-
πŸ—ΊοΈ
-

Live map and analytics

-

- The live dashboard shows every packet heard across the UK in real time: node - positions, relay paths, RF coverage, and a decoded packet feed. Built entirely - on open source tools. -

- Open map β†’ -
-
- {/* ── Discord CTA ─────────────────────────────────────────────── */}
-
-
-

Join the conversation

-

- We hang out on the MeshCore Discord. Come say hello, ask questions, or - coordinate coverage with other UK operators. DM ibengr to - get set up as an observer. -

+
+
+

Radio profile

+

These are the network settings used across the UK public deployment.

+
+
+
+ Profile + EU/UK Narrow +
+
+ Frequency + 869.618 MHz +
+
+ Bandwidth + 62.5 kHz +
+
+ Spreading factor + SF8 +
+
+ Coding rate + CR8 +
+
+
+
+ +
+
+
+
+

Join the conversation

+

+ Coverage, observer credentials, and repeater coordination all run through the MeshCore Discord. + That is where to go if you want to add another observer or compare notes with other UK operators. +

+
+
+ + Join Discord + + View source +
- - Join Discord β†’ -
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 846907b..6fac6f8 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -915,7 +915,7 @@ html, body, #root { } .site-main { flex: 1; } .site-content { - max-width: 1100px; + max-width: 1240px; margin: 0 auto; padding: 0 24px; } @@ -924,10 +924,10 @@ html, body, #root { .site-nav { display: flex; align-items: center; - gap: 32px; - height: 56px; - padding: 0 24px; - background: var(--bg-panel); + gap: 24px; + min-height: 60px; + padding: 10px 24px; + background: #09111a; border-bottom: 1px solid var(--border); position: sticky; top: 0; @@ -939,7 +939,6 @@ html, body, #root { gap: 8px; text-decoration: none; color: var(--text-primary); - font-family: var(--font-mono); font-size: 15px; font-weight: 600; flex-shrink: 0; @@ -952,39 +951,43 @@ html, body, #root { .site-nav__links { display: flex; align-items: center; - gap: 4px; + gap: 8px; flex: 1; + flex-wrap: wrap; } .site-nav__link { - padding: 6px 12px; + padding: 8px 10px; color: var(--text-secondary); text-decoration: none; font-size: 13px; - border-radius: var(--radius); + border-radius: 6px; transition: color var(--transition), background var(--transition); } .site-nav__link:hover { color: var(--text-primary); background: var(--bg-hover); } -.site-nav__link--active { color: var(--accent); } +.site-nav__link--active { + color: var(--text-primary); + background: rgba(0, 196, 255, 0.08); +} .site-nav__app-btn { - padding: 7px 16px; - background: var(--accent-dim); - border: 1px solid var(--accent-glow); - color: var(--accent); + padding: 8px 12px; + background: transparent; + border: 1px solid var(--border-bright); + color: var(--text-primary); text-decoration: none; font-size: 13px; font-family: var(--font-mono); - border-radius: var(--radius); + border-radius: 6px; flex-shrink: 0; margin-left: auto; transition: background var(--transition), border-color var(--transition); } .site-nav__app-btn:hover { - background: rgba(0, 196, 255, 0.25); - border-color: var(--accent); + background: var(--bg-hover); + border-color: var(--accent-glow); } .site-nav__app-btn--active { - background: rgba(0, 196, 255, 0.25); - border-color: var(--accent); + background: rgba(0, 196, 255, 0.08); + border-color: var(--border-bright); } /* ── Owner portal ─────────────────────────────────────────────────────── */ @@ -1028,7 +1031,7 @@ html, body, #root { gap: 12px; } .owner-summary-grid.site-stats-grid--6 { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .owner-summary-grid .site-stat { align-items: flex-start; @@ -1047,6 +1050,144 @@ html, body, #root { text-transform: uppercase; letter-spacing: 0.08em; } +.owner-dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 16px; + align-items: start; + grid-auto-rows: 320px; +} +.owner-panel { + display: flex; + flex-direction: column; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + min-width: 0; + margin-bottom: 0; + height: 100%; + overflow: hidden; +} +.owner-panel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.owner-panel__head h2 { + margin: 0; +} +.owner-alerts { + display: grid; + gap: 10px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} +.owner-alert { + display: grid; + gap: 4px; + border-radius: var(--radius); + padding: 12px; + border: 1px solid var(--border); + background: var(--bg-panel-alt); +} +.owner-alert strong { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; +} +.owner-alert span { + color: var(--text-primary); + font-size: 13px; + line-height: 1.45; +} +.owner-alert--info { + border-color: rgba(0,196,255,0.22); +} +.owner-alert--warn { + border-color: rgba(255,179,0,0.3); +} +.owner-alert--error { + border-color: rgba(255,100,100,0.32); +} +.owner-trend { + display: grid; + gap: 10px; + flex: 1; + min-height: 0; +} +.owner-trend__bars { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: repeat(24, minmax(0, 1fr)); + align-items: end; + gap: 4px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.owner-trend__bar { + min-height: 10px; + background: linear-gradient(180deg, rgba(0,196,255,0.95), rgba(0,196,255,0.45)); + border-radius: 3px 3px 0 0; +} +.owner-trend__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text-secondary); + font-size: 12px; +} +.owner-trend__meta strong { + color: var(--text-primary); + font-family: var(--font-mono); +} +.owner-list { + display: grid; + gap: 10px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} +.owner-list__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-panel-alt); +} +.owner-list__primary { + display: grid; + gap: 4px; + min-width: 0; +} +.owner-list__primary strong { + color: var(--text-primary); + font-size: 13px; + overflow-wrap: anywhere; +} +.owner-list__primary span { + color: var(--text-secondary); + font-size: 11px; +} +.owner-list__metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); +} .owner-table-wrap { width: 100%; overflow-x: auto; @@ -1106,16 +1247,20 @@ html, body, #root { border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; + flex: 1; + min-height: 0; } .owner-map { width: 100%; - height: 320px; + height: 100%; } .owner-packets { display: flex; flex-direction: column; gap: 10px; - height: 360px; + height: auto; + flex: 1; + min-height: 0; overflow-y: auto; padding-right: 4px; } @@ -1164,9 +1309,11 @@ html, body, #root { .site-btn { display: inline-flex; align-items: center; - padding: 10px 22px; - border-radius: var(--radius); - font-size: 14px; + justify-content: center; + min-height: 40px; + padding: 0 16px; + border-radius: 6px; + font-size: 13px; font-weight: 600; text-decoration: none; cursor: pointer; @@ -1174,113 +1321,133 @@ html, body, #root { border: 1px solid transparent; } .site-btn--primary { - background: var(--accent); - color: #000; - border-color: var(--accent); + background: #0d1e31; + color: var(--text-primary); + border-color: rgba(0, 196, 255, 0.28); } -.site-btn--primary:hover { background: #33cfff; border-color: #33cfff; } +.site-btn--primary:hover { background: #11263d; border-color: rgba(0, 196, 255, 0.4); } .site-btn--ghost { background: transparent; color: var(--text-primary); - border-color: var(--border-bright); + border-color: var(--border); } .site-btn--ghost:hover { background: var(--bg-hover); border-color: var(--accent-glow); } -/* ── Hero ──────────────────────────────────────────────────────────────── */ -.site-hero { - position: relative; - padding: 96px 0 72px; - overflow: hidden; +/* ── Home ──────────────────────────────────────────────────────────────── */ +.site-home { + padding: 40px 0 28px; + border-bottom: 1px solid var(--border); } -.site-hero__glow { - position: absolute; - top: -120px; - left: 50%; - transform: translateX(-50%); - width: 800px; - height: 500px; - background: radial-gradient(ellipse at center, rgba(0,196,255,0.07) 0%, transparent 70%); - pointer-events: none; +.site-home__grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr); + gap: 24px; + align-items: start; } -.site-hero__badge { - display: inline-block; - padding: 4px 12px; - background: var(--accent-dim); - border: 1px solid var(--accent-glow); - border-radius: 20px; - font-family: var(--font-mono); - font-size: 11px; - color: var(--accent); - letter-spacing: 0.05em; - text-transform: uppercase; - margin-bottom: 24px; +.site-home__intro { + display: grid; + gap: 18px; } -.site-hero__title { - font-family: var(--font-mono); - font-size: clamp(40px, 7vw, 72px); +.site-home__title { + font-size: clamp(34px, 5vw, 54px); line-height: 1.05; - font-weight: 700; - color: var(--text-primary); - margin: 0 0 24px; + letter-spacing: -0.03em; } -.site-hero__title--accent { color: var(--accent); } -.site-hero__sub { - font-size: 17px; +.site-home__body { + max-width: 760px; + font-size: 16px; line-height: 1.7; color: var(--text-secondary); - max-width: 560px; - margin: 0 0 36px; } -.site-hero__sub a { color: var(--accent); text-decoration: none; } -.site-hero__sub a:hover { text-decoration: underline; } -.site-hero__actions { display: flex; gap: 12px; flex-wrap: wrap; } +.site-home__actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.site-home__panel, +.site-home__card, +.site-home__join { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; +} +.site-home__panel { + padding: 20px; + display: grid; + gap: 16px; +} +.site-home__panel h2, +.site-home__join h2, +.site-section__head h2 { + font-size: 24px; + line-height: 1.2; + margin: 0; +} +.site-home__panel p, +.site-home__join p, +.site-section__head p { + color: var(--text-secondary); + line-height: 1.65; + margin: 0; +} +.site-home__meta { + display: grid; + gap: 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.site-home__meta-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid rgba(32, 80, 140, 0.18); +} +.site-home__meta-row:first-child { border-top: 0; } +.site-home__meta-row span { + color: var(--text-secondary); +} +.site-home__meta-row strong { + color: var(--text-primary); + text-align: right; +} /* ── Live stats ────────────────────────────────────────────────────────── */ .site-stats-section { - padding: 48px 0; - border-top: 1px solid var(--border); + padding: 28px 0; border-bottom: 1px solid var(--border); - background: var(--bg-panel); -} -.site-stats-section__eyebrow { - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.08em; - margin: 0 0 24px; + background: #09111a; } .site-stats-section--alt { - background: var(--bg-panel-alt); + background: var(--bg-panel); } .site-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1px; - background: var(--border); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - overflow: hidden; + gap: 12px; } .site-stats-grid--6 { - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } .site-stat { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - gap: 6px; - padding: 28px 24px; - background: var(--bg-panel-alt); + align-items: flex-start; + justify-content: flex-start; + text-align: left; + gap: 10px; + min-height: 132px; + padding: 18px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; } .site-stat__value { font-family: var(--font-mono); - font-size: clamp(28px, 4vw, 44px); + font-size: clamp(24px, 3vw, 38px); font-weight: 700; color: var(--text-primary); - line-height: 1; + line-height: 1.1; } .site-stat__suffix { font-size: 0.6em; @@ -1288,7 +1455,7 @@ html, body, #root { margin-left: 4px; } .site-stat__label { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); line-height: 1.4; } @@ -1302,81 +1469,132 @@ html, body, #root { } /* ── Sections ──────────────────────────────────────────────────────────── */ -.site-section { padding: 64px 0; } +.site-section { padding: 32px 0; } .site-section--dark { background: var(--bg-panel); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } -.site-cards-row { +.site-section__head { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 8px; + margin-bottom: 20px; + max-width: 760px; +} +.site-home__cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } -.site-card { +.site-card, +.site-home__card { background: var(--bg-panel); border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 28px; - display: flex; - flex-direction: column; + border-radius: 8px; + padding: 20px; + display: grid; gap: 12px; transition: border-color var(--transition), background var(--transition); } .site-card:hover { border-color: var(--border-bright); background: var(--bg-panel-alt); } -.site-card__icon { font-size: 28px; } -.site-card__title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; } -.site-card__body { font-size: 14px; line-height: 1.65; color: var(--text-secondary); flex: 1; } -.site-card__link { font-size: 13px; color: var(--accent); text-decoration: none; font-family: var(--font-mono); } -.site-card__link:hover { text-decoration: underline; } - -/* ── CTA row ───────────────────────────────────────────────────────────── */ -.site-cta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 32px; - flex-wrap: wrap; - padding: 12px 0; +.site-card__icon { display: none; } +.site-card__title, +.site-home__card h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} +.site-card__body, +.site-home__card p { + font-size: 14px; + line-height: 1.65; + color: var(--text-secondary); + margin: 0; +} +.site-card__link, +.site-home__card a { + font-size: 13px; + color: var(--accent); + text-decoration: none; + font-family: var(--font-mono); +} +.site-card__link:hover, +.site-home__card a:hover { text-decoration: underline; } +.site-home__specs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.site-home__spec-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 18px; + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; +} +.site-home__spec-row span { + color: var(--text-secondary); + font-size: 13px; +} +.site-home__spec-row strong { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 18px; +} +.site-home__join { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + flex-wrap: wrap; + padding: 20px; +} +.site-home__join > div:first-child { + max-width: 760px; +} +.site-home__join-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; } -.site-cta__title { font-size: 22px; font-weight: 700; margin: 0 0 8px; } -.site-cta__body { font-size: 14px; color: var(--text-secondary); line-height: 1.6; max-width: 560px; margin: 0; } /* ── Page hero (inner pages) ───────────────────────────────────────────── */ .site-page-hero { - padding: 64px 0 48px; + padding: 32px 0 20px; border-bottom: 1px solid var(--border); + background: #09111a; } .site-page-hero__title { - font-family: var(--font-mono); - font-size: clamp(28px, 5vw, 48px); + font-size: clamp(28px, 4vw, 42px); font-weight: 700; color: var(--text-primary); - margin: 0 0 16px; + margin: 0 0 10px; + letter-spacing: -0.02em; } .site-page-hero__sub { - font-size: 16px; + font-size: 15px; color: var(--text-secondary); - line-height: 1.65; - max-width: 620px; + line-height: 1.7; + max-width: 780px; margin: 0; } /* ── Prose content ─────────────────────────────────────────────────────── */ -.site-prose { padding: 48px 24px 80px; max-width: 800px; } -.prose-section { margin-bottom: 56px; } +.site-prose { padding: 32px 24px 56px; max-width: 980px; } +.site-prose--wide { max-width: 1280px; } +.prose-section { margin-bottom: 40px; } .prose-section h2 { - display: flex; - align-items: center; - gap: 12px; - font-family: var(--font-mono); - font-size: 20px; + display: block; + font-size: 24px; font-weight: 700; color: var(--text-primary); - margin: 0 0 20px; - padding-bottom: 12px; + margin: 0 0 16px; + padding-bottom: 10px; border-bottom: 1px solid var(--border); } -.prose-section h3 { font-size: 16px; color: var(--text-primary); margin: 24px 0 12px; } +.prose-section h3 { font-size: 16px; color: var(--text-primary); margin: 20px 0 10px; } .prose-section p { font-size: 15px; - line-height: 1.75; + line-height: 1.7; color: var(--text-secondary); margin: 0 0 16px; } @@ -1397,51 +1615,42 @@ html, body, #root { .prose-section code { font-family: var(--font-mono); font-size: 13px; - background: var(--bg-panel-alt); + background: #0b121c; border: 1px solid var(--border); padding: 1px 6px; border-radius: var(--radius); color: var(--accent); } .prose-step { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - background: var(--accent-dim); - border: 1px solid var(--accent-glow); - border-radius: 50%; - font-size: 12px; + display: inline-block; + min-width: 18px; + margin-right: 10px; + font-family: var(--font-mono); + font-size: 14px; color: var(--accent); - flex-shrink: 0; } .prose-note { - background: var(--bg-panel-alt); + background: #0b121c; border: 1px solid var(--border); - border-left: 3px solid var(--accent-glow); + border-left: 0; padding: 12px 16px; - border-radius: var(--radius); + border-radius: 8px; font-size: 14px !important; color: var(--text-secondary) !important; } .prose-section--muted { - background: var(--bg-panel); + background: #0b121c; border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 28px 32px; - margin-bottom: 56px; + border-radius: 8px; + padding: 20px 24px; + margin-bottom: 40px; } .prose-section--muted h2 { border-bottom-color: transparent; } .prose-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; } .prose-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 1px; - background: var(--border); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - overflow: hidden; + gap: 12px; margin: 24px 0; } .prose-facts--3x2 { @@ -1450,12 +1659,14 @@ html, body, #root { .prose-fact { display: flex; flex-direction: column; - gap: 4px; - padding: 16px 20px; - background: var(--bg-panel-alt); + gap: 6px; + padding: 16px 18px; + background: #0b121c; + border: 1px solid var(--border); + border-radius: 8px; } -.prose-fact__value { font-family: var(--font-mono); font-size: 22px; font-weight: 700; color: var(--accent); } -.prose-fact__label { font-size: 12px; color: var(--text-muted); } +.prose-fact__value { font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--accent); } +.prose-fact__label { font-size: 12px; color: var(--text-secondary); } .prose-steps { padding-left: 24px; margin: 0 0 16px; counter-reset: none; } .prose-steps li { margin-bottom: 12px; } @@ -1475,8 +1686,8 @@ html, body, #root { .code-block { background: #020608; border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 20px 24px; + border-radius: 8px; + padding: 16px 18px; margin: 16px 0; overflow-x: auto; } @@ -1497,20 +1708,18 @@ html, body, #root { margin: 20px 0; } .hw-card { - background: var(--bg-panel-alt); + background: #0b121c; border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 16px 20px; + border-radius: 8px; + padding: 16px 18px; position: relative; } -.hw-card--recommended { border-color: var(--accent-glow); } +.hw-card--recommended { border-color: rgba(0, 196, 255, 0.28); } .hw-card__badge { display: inline-block; font-family: var(--font-mono); font-size: 10px; color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.05em; margin-bottom: 8px; } .hw-card__name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; } @@ -1527,9 +1736,9 @@ html, body, #root { display: flex; flex-direction: column; gap: 4px; - background: var(--bg-panel-alt); + background: #0b121c; border: 1px solid var(--border); - border-radius: var(--radius); + border-radius: 8px; padding: 14px 16px; text-decoration: none; transition: border-color var(--transition), background var(--transition); @@ -1544,8 +1753,14 @@ html, body, #root { gap: 14px; } +.health-workers-grid > * { + min-width: 0; +} + .health-card { gap: 14px; + min-width: 0; + overflow: hidden; } .health-card--interactive { @@ -1565,13 +1780,20 @@ html, body, #root { } .health-card__head { - display: flex; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: 1fr; + align-items: start; gap: 8px; + min-width: 0; +} + +.health-card__head .site-card__title { + min-width: 0; + overflow-wrap: anywhere; } .health-pill { + justify-self: start; font-size: 10px; letter-spacing: 0.08em; font-family: var(--font-mono); @@ -1579,6 +1801,9 @@ html, body, #root { border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; + max-width: 100%; + overflow-wrap: anywhere; + text-align: center; } .health-pill--ok { @@ -1590,19 +1815,33 @@ html, body, #root { .health-card__stats { display: grid; gap: 6px; + min-width: 0; } .health-kv { - display: flex; - align-items: center; - justify-content: space-between; + display: grid; + grid-template-columns: 1fr; + align-items: start; font-size: 12px; color: var(--text-secondary); + gap: 4px; + min-width: 0; +} + +.health-kv span { + min-width: 0; +} + +.health-kv strong, +.health-kv span { + overflow-wrap: anywhere; } .health-kv strong { color: var(--text-primary); font-family: var(--font-mono); + text-align: left; + min-width: 0; } .health-spark { @@ -1629,6 +1868,10 @@ html, body, #root { color: var(--text-secondary); font-size: 12px; line-height: 1.55; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; } .health-system-grid .site-stat__value { @@ -1741,15 +1984,15 @@ html, body, #root { .site-footer { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; gap: 12px; - height: 48px; + min-height: 56px; font-size: 12px; color: var(--text-muted); border-top: 1px solid var(--border); - background: var(--bg-panel); + background: #09111a; flex-wrap: wrap; - padding: 0 24px; + padding: 12px 24px; } .site-footer a { color: var(--text-muted); text-decoration: none; } .site-footer a:hover { color: var(--text-secondary); } @@ -1901,19 +2144,49 @@ html, body, #root { /* ── Website content / sections ──────────────────────────────────────── */ .site-content { padding: 0 16px; } - .site-hero { padding: 56px 0 40px; } - .site-hero__sub { font-size: 15px; } - .site-section { padding: 40px 0; } - .site-page-hero { padding: 40px 0 32px; } - .site-cta { flex-direction: column; gap: 16px; align-items: flex-start; } + .site-home { padding: 28px 0 24px; } + .site-home__grid { grid-template-columns: 1fr; } + .site-home__meta-row { flex-direction: column; align-items: flex-start; } + .site-home__meta-row strong { text-align: left; } + .site-home__title { font-size: 34px; } + .site-section { padding: 24px 0; } + .site-page-hero { padding: 28px 0 18px; } + .site-home__join { align-items: flex-start; } .site-prose { padding: 24px 0 56px; } + .site-prose--wide { max-width: 100%; } .prose-section--muted { padding: 20px; } .owner-summary-grid { grid-template-columns: 1fr; } + .owner-dashboard-grid { grid-template-columns: 1fr; } + .owner-panel--map, + .owner-panel--packets { grid-row: auto; grid-column: auto; } + .owner-alerts, + .owner-list { grid-template-columns: 1fr; } + .owner-list__row { grid-template-columns: 1fr; } + .owner-list__metrics { justify-content: flex-start; } .owner-roadmap { grid-template-columns: 1fr; } .owner-head { flex-direction: column; align-items: flex-start; } .owner-map { height: 260px; } } +@media (max-width: 1180px) { + .owner-summary-grid.site-stats-grid--6 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .owner-dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 860px) { + .owner-summary-grid.site-stats-grid--6 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .owner-dashboard-grid { + grid-template-columns: 1fr; + grid-auto-rows: minmax(280px, auto); + } +} + /* ─── Stats page ─────────────────────────────────────────────────────────── */ .stats-page__summary { display: grid; diff --git a/viewshed-worker/worker.py b/viewshed-worker/worker.py index a3a504a..4b2dd72 100644 --- a/viewshed-worker/worker.py +++ b/viewshed-worker/worker.py @@ -40,18 +40,29 @@ SRTM_DIR = Path(os.environ.get('SRTM_DIR', '/data/srtm')) REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379') DATABASE_URL = os.environ.get('DATABASE_URL') WORKER_MODE = os.environ.get('WORKER_MODE', 'all').lower() +COVERAGE_MODEL = os.environ.get('COVERAGE_MODEL', 'rf_radial_100m').lower() JOB_QUEUE = 'meshcore:viewshed_jobs' LINK_JOB_QUEUE = 'meshcore:link_jobs' LIVE_CHANNEL = 'meshcore:live' -ANTENNA_HEIGHT_M = 5 # observer height above ground (m) β€” fixed 5 m antenna +ANTENNA_HEIGHT_M = 5 # source repeater antenna above ground (m) +COVERAGE_TARGET_HEIGHT_M = 5 # target repeater antenna above ground (m) for coverage polygons +COVERAGE_MODEL_VERSION = int(os.environ.get( + 'COVERAGE_MODEL_VERSION', + '3' if COVERAGE_MODEL == 'rf_radial_100m' else '2', +)) MIN_LINK_OBSERVATIONS = 5 # must match backend db/index.ts PREFIX_AMBIGUITY_RADIUS_KM = 45.0 # only penalize same-prefix ambiguity when nodes are realistically in range MAX_RADIUS_M = 100_000 # absolute cap on viewshed radius (m) SIMPLIFY_DEG = 0.001 # Douglas-Peucker tolerance (~100 m) N_RAYS = 720 # number of radial rays cast from the observer STEP_M = 50.0 # ray step size in metres +ANGLE_EPS = 1e-9 # numerical tolerance for horizon comparisons +RF_RADIAL_STEP_M = 100.0 # radial search precision for RF coverage mode +RF_N_RAYS = 360 # 1-degree azimuth resolution keeps RF mode tractable +RF_RADIUS_MULTIPLIER = 1.35 # search beyond geometric horizon to allow limited diffraction gain +RF_MIN_RADIUS_M = 20_000 # avoid under-searching low-elevation repeaters # Radio horizon parameters K_FACTOR = 4 / 3 # effective Earth radius multiplier (standard troposphere) @@ -64,6 +75,11 @@ LAMBDA_M = 3e8 / (FREQ_MHZ * 1e6) # wavelength ~0.345 m LINK_BUDGET_DB = 148.0 # 17 dBm TX + ~130 dBm RX sensitivity (LoRa SF10 BW125) +1 dB SRTM DSM correction FADE_MARGIN_DB = 10.0 # safety / link margin PROFILE_STEP_M = 250.0 # terrain profile sample spacing (m) +SIGNAL_THRESHOLDS_DB = { + 'green': 120.0, + 'amber': 135.0, + 'red': LINK_BUDGET_DB - FADE_MARGIN_DB, +} def compute_path_loss(lat1: float, lon1: float, elev1: float, lat2: float, lon2: float, elev2: float, @@ -82,7 +98,6 @@ def compute_path_loss(lat1: float, lon1: float, elev1: float, if d_total < 1.0: return 0.0, True - # Free-space path loss (dB) fspl = 20 * math.log10(4 * math.pi * d_total / LAMBDA_M) # Terrain profile: N evenly-spaced samples along the path @@ -128,6 +143,46 @@ def compute_path_loss(lat1: float, lon1: float, elev1: float, v = excess_h * math.sqrt(2 * (d1 + d2) / (LAMBDA_M * d1 * d2)) max_v = max(max_v, v) + return compute_path_loss_from_profile( + np.asarray(dists, dtype=np.float32), + np.asarray(heights, dtype=np.float32), + h_tx, + h_rx, + ) + + +def compute_path_loss_from_profile(dists: np.ndarray, + heights: np.ndarray, + h_tx: float, + h_rx: float) -> tuple[float, bool]: + d_total = float(dists[-1]) if len(dists) else 0.0 + if d_total < 1.0: + return 0.0, True + + # Free-space path loss (dB) + fspl = 20 * math.log10(4 * math.pi * d_total / LAMBDA_M) + + if len(dists) <= 2: + viable = fspl < LINK_BUDGET_DB - FADE_MARGIN_DB + return fspl, viable + + d1 = dists[1:-1].astype(np.float64) + d2 = d_total - d1 + valid = (d1 > 0) & (d2 > 0) + if not np.any(valid): + viable = fspl < LINK_BUDGET_DB - FADE_MARGIN_DB + return fspl, viable + + d1 = d1[valid] + d2 = d2[valid] + profile_h = heights[1:-1].astype(np.float64)[valid] + los_h = h_tx + (h_rx - h_tx) * (d1 / d_total) + earth_bulge = (d1 * d2) / (2 * K_FACTOR * R_EARTH_M) + excess_h = profile_h + earth_bulge - los_h + with np.errstate(divide='ignore', invalid='ignore'): + vs = excess_h * np.sqrt(2 * (d1 + d2) / (LAMBDA_M * d1 * d2)) + max_v = float(np.max(vs)) if vs.size else -999.0 + # ITU-R P.526 knife-edge diffraction loss (dB) if max_v <= -0.78: diff_loss = 0.0 @@ -141,6 +196,112 @@ def compute_path_loss(lat1: float, lon1: float, elev1: float, return total_loss, viable +def resolve_rf_radial_boundaries(lat: float, + lon: float, + elev: np.ndarray, + gt: tuple[float, float, float, float, float, float], + observer_h: float, + base_radius_m: float) -> tuple[dict[str, list[tuple[float, float]]], float]: + search_radius_m = min(MAX_RADIUS_M, max(base_radius_m * RF_RADIUS_MULTIPLIER, RF_MIN_RADIUS_M)) + n_rows, n_cols = elev.shape + dpmlat = 1.0 / 111_320.0 + dpmlon = 1.0 / (111_320.0 * math.cos(math.radians(lat))) + ds_arr = np.arange(RF_RADIAL_STEP_M, search_radius_m + RF_RADIAL_STEP_M, RF_RADIAL_STEP_M, dtype=np.float32) + thetas = np.linspace(0.0, 2.0 * math.pi, RF_N_RAYS, endpoint=False, dtype=np.float32) + cos_t = np.cos(thetas) + sin_t = np.sin(thetas) + + boundaries: dict[str, list[tuple[float, float]]] = {key: [] for key in SIGNAL_THRESHOLDS_DB} + max_reached = 0.0 + + for theta_idx in range(RF_N_RAYS): + pt_lats = lat + sin_t[theta_idx] * ds_arr * dpmlat + pt_lons = lon + cos_t[theta_idx] * ds_arr * dpmlon + pxs = np.clip(((pt_lons - gt[0]) / gt[1]).astype(np.int32), 0, n_cols - 1) + pys = np.clip(((pt_lats - gt[3]) / gt[5]).astype(np.int32), 0, n_rows - 1) + hs = elev[pys, pxs].astype(np.float32) + + losses: list[float] = [] + for idx in range(len(ds_arr)): + dists = ds_arr[:idx + 1] + heights = hs[:idx + 1] + h_rx = float(heights[-1]) + COVERAGE_TARGET_HEIGHT_M + loss, _viable = compute_path_loss_from_profile(dists, heights, observer_h, h_rx) + losses.append(loss) + losses_arr = np.asarray(losses, dtype=np.float32) + + for band, threshold in SIGNAL_THRESHOLDS_DB.items(): + passing = np.where(losses_arr <= threshold)[0] + if passing.size < 1: + end_dist = float(ds_arr[0]) + else: + end_dist = float(ds_arr[int(passing[-1])]) + if band == 'red': + max_reached = max(max_reached, end_dist) + boundaries[band].append(( + lon + float(cos_t[theta_idx]) * end_dist * dpmlon, + lat + float(sin_t[theta_idx]) * end_dist * dpmlat, + )) + + for band_boundary in boundaries.values(): + if band_boundary: + band_boundary.append(band_boundary[0]) + return boundaries, max_reached + + +def clip_and_simplify_polygon(poly) -> Optional[dict]: + if poly.is_empty: + return None + if not poly.is_valid: + poly = poly.buffer(0) + if UK_MAINLAND is not None: + poly = poly.intersection(UK_MAINLAND) + if poly.is_empty: + return None + result = poly.simplify(SIMPLIFY_DEG, preserve_topology=True) + if result.is_empty or result.geom_type not in ('Polygon', 'MultiPolygon'): + return None + return mapping(result) + + +def build_exclusive_strength_geoms(band_polys: dict[str, ShapelyPolygon]) -> dict[str, dict]: + """Convert nested strength polygons into exclusive green/amber/red areas. + + The strongest band should own the fill for a location. Without this, the + frontend ends up stacking green over amber over red and the center reads as + muddy yellow instead of a clean strength gradient. + """ + exclusive: dict[str, dict] = {} + + green_poly = band_polys.get('green') + if green_poly is not None and not green_poly.is_empty: + clipped_green = clip_and_simplify_polygon(green_poly) + if clipped_green is not None: + exclusive['green'] = clipped_green + + amber_poly = band_polys.get('amber') + if amber_poly is not None and not amber_poly.is_empty: + amber_only = amber_poly + if green_poly is not None and not green_poly.is_empty: + amber_only = amber_only.difference(green_poly) + clipped_amber = clip_and_simplify_polygon(amber_only) + if clipped_amber is not None: + exclusive['amber'] = clipped_amber + + red_poly = band_polys.get('red') + if red_poly is not None and not red_poly.is_empty: + red_only = red_poly + if amber_poly is not None and not amber_poly.is_empty: + red_only = red_only.difference(amber_poly) + elif green_poly is not None and not green_poly.is_empty: + red_only = red_only.difference(green_poly) + clipped_red = clip_and_simplify_polygon(red_only) + if clipped_red is not None: + exclusive['red'] = clipped_red + + return exclusive + + def build_link_vrt(lat1: float, lon1: float, lat2: float, lon2: float, tmp_dir: str) -> Optional[str]: """Build a GDAL VRT from already-cached SRTM tiles covering the path. @@ -260,7 +421,7 @@ def sample_elevation(vrt_path: str, lat: float, lon: float) -> float: # ── Viewshed calculation ────────────────────────────────────────────────────── -def calculate_viewshed(node_id: str, lat: float, lon: float) -> Optional[tuple[dict, float]]: +def calculate_viewshed(node_id: str, lat: float, lon: float) -> Optional[tuple[dict, dict[str, dict], float, float]]: with tempfile.TemporaryDirectory() as tmp: # 1. Download the observer's own tile and sample terrain elevation. # This single tile is sufficient to determine node height; we need @@ -338,106 +499,97 @@ def calculate_viewshed(node_id: str, lat: float, lon: float) -> Optional[tuple[d f'horizon={radius_m / 1000:.1f} km' ) - # 6. Vectorised raycasting viewshed. - # - # For each of N_RAYS directions, walk outward in STEP_M increments tracking - # the maximum "elevation angle" seen so far (corrected for Earth curvature). - # When a step's angle falls below the running maximum, the terrain at that - # step is in the shadow of an earlier ridge β†’ the ray terminates. - # The stop-point of each ray becomes a vertex of the coverage boundary. - # - # Elevation angle formula: - # angle(d) = (terrain_h - observer_h - dΒ² / (2Β·kΒ·R)) / d - # where the dΒ²/(2kR) term accounts for Earth's curvature under the ray. - # A ray is blocked when angle(d) < running_max β€” no wrap-around artefacts. observer_h = elevation_m + ANTENNA_HEIGHT_M - dpmlat = 1.0 / 111_320.0 # deg/m northward - dpmlon = 1.0 / (111_320.0 * math.cos(math.radians(lat))) # deg/m eastward - R_eff_2 = 2.0 * K_FACTOR * R_EARTH_M # 2kR curvature denom + strength_geoms: dict[str, dict] = {} + if COVERAGE_MODEL == 'terrain_los': + # Vectorised raycasting terrain line-of-sight model. + dpmlat = 1.0 / 111_320.0 # deg/m northward + dpmlon = 1.0 / (111_320.0 * math.cos(math.radians(lat))) # deg/m eastward + R_eff_2 = 2.0 * K_FACTOR * R_EARTH_M # 2kR curvature denom - n_steps = max(1, int(radius_m / STEP_M)) - ds_arr = np.linspace(STEP_M, radius_m, n_steps) # (M,) distances in metres - thetas = np.linspace(0.0, 2.0 * math.pi, N_RAYS, endpoint=False) # (N,) angles + n_steps = max(1, int(radius_m / STEP_M)) + ds_arr = np.linspace(STEP_M, radius_m, n_steps) # (M,) distances in metres + thetas = np.linspace(0.0, 2.0 * math.pi, N_RAYS, endpoint=False) # (N,) angles - # Ray sample coordinates: (N, M) - sin_t = np.sin(thetas)[:, None] # (N, 1) - cos_t = np.cos(thetas)[:, None] # (N, 1) - pt_lats = lat + sin_t * ds_arr[None, :] * dpmlat # (N, M) - pt_lons = lon + cos_t * ds_arr[None, :] * dpmlon # (N, M) + # Ray sample coordinates: (N, M) + sin_t = np.sin(thetas)[:, None] # (N, 1) + cos_t = np.cos(thetas)[:, None] # (N, 1) + pt_lats = lat + sin_t * ds_arr[None, :] * dpmlat # (N, M) + pt_lons = lon + cos_t * ds_arr[None, :] * dpmlon # (N, M) - # Pixel indices β€” clamped to raster bounds (N, M) - # gt[0]=x_origin (lon), gt[1]=px_width (deg/px), gt[3]=y_origin (lat), gt[5]=px_height (<0) - pxs = np.clip(((pt_lons - gt[0]) / gt[1]).astype(np.int32), 0, n_cols - 1) - pys = np.clip(((pt_lats - gt[3]) / gt[5]).astype(np.int32), 0, n_rows - 1) + # Pixel indices β€” clamped to raster bounds (N, M) + pxs = np.clip(((pt_lons - gt[0]) / gt[1]).astype(np.int32), 0, n_cols - 1) + pys = np.clip(((pt_lats - gt[3]) / gt[5]).astype(np.int32), 0, n_rows - 1) - # Terrain heights at each ray step: (N, M) - hs = elev[pys, pxs] + # Terrain heights at each ray step: (N, M) + hs = elev[pys, pxs] - # Elevation angles with Earth-curvature correction: (N, M) - angles = (hs - observer_h - ds_arr[None, :] ** 2 / R_eff_2) / ds_arr[None, :] + # Angles with Earth-curvature correction: (N, M) + curvature = ds_arr[None, :] ** 2 / R_eff_2 + terrain_angles = (hs - observer_h - curvature) / ds_arr[None, :] + target_angles = ((hs + COVERAGE_TARGET_HEIGHT_M) - observer_h - curvature) / ds_arr[None, :] - # Running max along each ray β€” only terrain AT OR ABOVE the observer - # height can establish a blocking horizon. Terrain below the observer - # always lets the ray "see past" it; coverage is only cut off when - # something taller than the node rises into the line of sight. - blocking = np.where(hs >= observer_h, angles, -np.inf) # (N, M) - running_max = np.maximum.accumulate(blocking, axis=1) # (N, M) + running_max = np.maximum.accumulate(terrain_angles, axis=1) + prev_max = np.concatenate([np.full((N_RAYS, 1), -np.inf), running_max[:, :-1]], axis=1) + in_shadow = target_angles + ANGLE_EPS < prev_max - # "Previous" running max β€” shift one step so we compare current angle with - # the max established BEFORE this step. First column = -inf (never blocked). - prev_max = np.concatenate([np.full((N_RAYS, 1), -np.inf), running_max[:, :-1]], axis=1) - in_shadow = angles < prev_max # (N, M): True where ray is terrain-blocked + has_shadow = in_shadow.any(axis=1) + first_shad = np.where(has_shadow, in_shadow.argmax(axis=1), n_steps) + last_js = np.clip(first_shad - 1, 0, n_steps - 1) + last_ds = ds_arr[last_js] - # Index of first shadow step per ray; n_steps if never shadowed. - has_shadow = in_shadow.any(axis=1) # (N,) - first_shad = np.where(has_shadow, in_shadow.argmax(axis=1), n_steps) # (N,) - last_js = np.clip(first_shad - 1, 0, n_steps - 1) # (N,) - last_ds = ds_arr[last_js] # (N,) - - # Build boundary ring in (lon, lat) GeoJSON order. - lons_b = lon + np.cos(thetas) * last_ds * dpmlon # (N,) - lats_b = lat + np.sin(thetas) * last_ds * dpmlat # (N,) - boundary = list(zip(lons_b.tolist(), lats_b.tolist())) - boundary.append(boundary[0]) # close ring - - poly = ShapelyPolygon(boundary) - if not poly.is_valid: - poly = poly.buffer(0) - - # 7. Clip to UK mainland β€” removes coverage that extends into the sea. - if UK_MAINLAND is not None: - poly = poly.intersection(UK_MAINLAND) - if poly.is_empty: - log.warning(f'{node_id}: viewshed entirely at sea β€” skipping') + lons_b = lon + np.cos(thetas) * last_ds * dpmlon + lats_b = lat + np.sin(thetas) * last_ds * dpmlat + boundary = list(zip(lons_b.tolist(), lats_b.tolist())) + boundary.append(boundary[0]) + poly = ShapelyPolygon(boundary) + clipped = clip_and_simplify_polygon(poly) + if clipped is None: + log.warning(f'{node_id}: degenerate geometry after clipping β€” skipping') return None - - # 8. Simplify (~100 m tolerance) to reduce stored polygon size. - result = poly.simplify(SIMPLIFY_DEG, preserve_topology=True) - - if result.is_empty or result.geom_type not in ('Polygon', 'MultiPolygon'): - log.warning(f'{node_id}: degenerate geometry after clipping β€” skipping') - return None - - return mapping(result), radius_m, elevation_m + geom = clipped + strength_geoms = {'green': geom} + elif COVERAGE_MODEL == 'rf_radial_100m': + band_boundaries, radius_m = resolve_rf_radial_boundaries(lat, lon, elev, gt, observer_h, radius_m) + raw_band_polys: dict[str, ShapelyPolygon] = {} + for band, band_boundary in band_boundaries.items(): + if len(band_boundary) < 4: + continue + band_poly = ShapelyPolygon(band_boundary) + if not band_poly.is_empty: + raw_band_polys[band] = band_poly + strength_geoms = build_exclusive_strength_geoms(raw_band_polys) + geom = clip_and_simplify_polygon(raw_band_polys.get('red')) if raw_band_polys.get('red') is not None else None + if geom is None: + log.warning(f'{node_id}: degenerate RF coverage geometry after clipping β€” skipping') + return None + else: + raise ValueError(f'Unknown COVERAGE_MODEL={COVERAGE_MODEL}') + return geom, strength_geoms, radius_m, elevation_m # ── DB helpers ──────────────────────────────────────────────────────────────── def already_calculated(db, node_id: str) -> bool: with db.cursor() as cur: - cur.execute('SELECT 1 FROM node_coverage WHERE node_id = %s', (node_id,)) + cur.execute( + 'SELECT 1 FROM node_coverage WHERE node_id = %s AND model_version >= %s', + (node_id, COVERAGE_MODEL_VERSION), + ) return cur.fetchone() is not None -def store_coverage(db, node_id: str, geom: dict, radius_m: float, elevation_m: float): +def store_coverage(db, node_id: str, geom: dict, strength_geoms: dict[str, dict], radius_m: float, elevation_m: float): with db.cursor() as cur: cur.execute( - '''INSERT INTO node_coverage (node_id, geom, antenna_height_m, radius_m) - VALUES (%s, %s::jsonb, %s, %s) + '''INSERT INTO node_coverage (node_id, geom, strength_geoms, antenna_height_m, radius_m, model_version) + VALUES (%s, %s::jsonb, %s::jsonb, %s, %s, %s) ON CONFLICT (node_id) DO UPDATE SET geom = EXCLUDED.geom, + strength_geoms = EXCLUDED.strength_geoms, antenna_height_m = EXCLUDED.antenna_height_m, radius_m = EXCLUDED.radius_m, + model_version = EXCLUDED.model_version, calculated_at = NOW()''', - (node_id, json.dumps(geom), ANTENNA_HEIGHT_M, radius_m), + (node_id, json.dumps(geom), json.dumps(strength_geoms), ANTENNA_HEIGHT_M, radius_m, COVERAGE_MODEL_VERSION), ) cur.execute( 'UPDATE nodes SET elevation_m = %s WHERE node_id = %s', @@ -695,13 +847,13 @@ def enqueue_uncovered(db, r_client): FROM nodes n LEFT JOIN node_coverage nc ON n.node_id = nc.node_id WHERE n.lat IS NOT NULL AND n.lon IS NOT NULL - AND nc.node_id IS NULL + AND (nc.node_id IS NULL OR nc.model_version < %s) AND (n.name IS NULL OR n.name NOT LIKE %s) AND (n.role IS NULL OR n.role = 2) - ''', ('%🚫%',)) + ''', (COVERAGE_MODEL_VERSION, '%🚫%',)) rows = cur.fetchall() if rows: - log.info(f'Queuing {len(rows)} existing node(s) for viewshed calculation') + log.info(f'Queuing {len(rows)} existing node(s) for viewshed calculation (model v{COVERAGE_MODEL_VERSION})') for node_id, lat, lon in rows: r_client.lpush(JOB_QUEUE, json.dumps({'node_id': node_id, 'lat': lat, 'lon': lon})) @@ -735,13 +887,13 @@ def process_job(db, r_client, job: dict): if result is None: return - geom, radius_m, elevation_m = result - store_coverage(db, node_id, geom, radius_m, elevation_m) + geom, strength_geoms, radius_m, elevation_m = result + store_coverage(db, node_id, geom, strength_geoms, radius_m, elevation_m) log.info(f'Done in {time.time() - t0:.1f}s β€” notifying frontend') r_client.publish(LIVE_CHANNEL, json.dumps({ 'type': 'coverage_update', - 'data': {'node_id': node_id, 'geom': geom}, + 'data': {'node_id': node_id, 'geom': geom, 'strength_geoms': strength_geoms}, 'ts': int(time.time() * 1000), })) r_client.publish(LIVE_CHANNEL, json.dumps({ @@ -805,7 +957,10 @@ def worker_loop(): log.error(f'{name}: job error: {exc}', exc_info=True) def main(): - log.info(f'Viewshed worker starting (mode={WORKER_MODE})') + log.info( + f'Viewshed worker starting (mode={WORKER_MODE}, ' + f'coverage_model={COVERAGE_MODEL}, model_version={COVERAGE_MODEL_VERSION})' + ) SRTM_DIR.mkdir(parents=True, exist_ok=True) # Connect once just to enqueue any nodes that lack coverage, then hand off