diff --git a/README.md b/README.md index df01437..b8b7225 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas ### Version 3.0.6 — March 2026 - Daily snapshots: added the `daily_snapshot` table, scheduled snapshot capture, and `/api/snapshots/daily` for historical node, packet, and gateway counts. + Each new snapshot stores packets imported during the previous complete UTC day, + labeled with that day's date. Node and gateway counts are database totals at capture + time. Existing historical snapshots retain their previously stored packet totals. - Stats/UI: expanded stats views with snapshot data and added channel filters to Chat and Firehose. - Map/node fixes: corrected node location handling, map popup behavior, packet page display, and node list sorting. - Ingestion/API reliability: improved duplicate `PacketSeen` handling, guarded invalid node info packets, and made traceroute/edge APIs more tolerant of decode failures. diff --git a/docs/API_Documentation.md b/docs/API_Documentation.md index 5531c26..fde9e39 100644 --- a/docs/API_Documentation.md +++ b/docs/API_Documentation.md @@ -227,9 +227,37 @@ Response Example ## 6. Config API +### GET `/api/snapshots/daily` + +Returns stored daily snapshots. For new snapshots, `packet_count` is the number of +packet records imported during `date` (00:00 UTC inclusive to the next midnight +exclusive). The scheduled capture records the previous complete UTC day. Repeated +observations of the same packet are not counted separately. `node_count` counts distinct +packet senders whose packets were imported or observed during that UTC day (including +observations of previously imported packets). `gateway_count` counts distinct MQTT +gateways with packet observations imported during that day. These are activity counts, +not database totals; recipients alone do not count as active nodes. Activity uses local +import timestamps, not device timestamps. Older snapshots retain their originally stored +counts and are not automatically converted. Counts depend on the packet and observation +history still retained at capture time. + ### GET `/api/config` Returns a safe subset of server configuration. +`site.map_channel_colors` contains channel color overrides from the optional +`[map_channel_colors]` INI section. Each entry is `channel = #RRGGBB`; invalid colors +are omitted. Channel matching on the map ignores case, spaces, hyphens, and underscores. +Unlisted channels use generated colors. For example: + +```ini +[map_channel_colors] +LongFast = #4363d8 +LocalChat = #18aa18 +``` + +Restart the web server and reload the map after changing these settings. Omitting +the section uses generated colors for every channel. + Response Example ```json { diff --git a/meshview/__version__.py b/meshview/__version__.py index 50d30b1..c2f2fda 100644 --- a/meshview/__version__.py +++ b/meshview/__version__.py @@ -3,7 +3,7 @@ import subprocess from pathlib import Path -__version__ = "3.0.8" +__version__ = "3.0.9" __release_date__ = "2026-5-12" diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 6eec50e..1f8c103 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -221,6 +221,8 @@ "air_channel": "Air & Channel Utilization", "environment": "Environment Metrics", "neighbors_chart": "Neighbors (Signal-to-Noise)", + "neighbors_chart_subtitle": "Last {days} days — {n} neighbors", + "neighbors_chart_subtitle_capped": "Last {days} days — the {n} neighbors with the most data points, of {total}", "expand": "Expand", "export_csv": "Export CSV", "time": "Time", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index e7561d9..c362ce5 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -207,6 +207,8 @@ "air_channel": "Utilización del aire y del canal", "environment": "Métricas Ambientales", "neighbors_chart": "Vecinos (Relación Señal/Ruido)", + "neighbors_chart_subtitle": "Últimos {days} días — {n} vecinos", + "neighbors_chart_subtitle_capped": "Últimos {days} días — los {n} vecinos con más datos, de {total}", "expand": "Ampliar", "export_csv": "Exportar CSV", "time": "Hora", diff --git a/meshview/lang/ru.json b/meshview/lang/ru.json index 902aa64..34c2f13 100644 --- a/meshview/lang/ru.json +++ b/meshview/lang/ru.json @@ -202,6 +202,8 @@ "air_channel": "Использование сети и каналов", "environment": "Метрики среды", "neighbors_chart": "Соседи (Соотношение сигнал/шум)", + "neighbors_chart_subtitle": "Последние {days} дн. — {n} соседей", + "neighbors_chart_subtitle_capped": "Последние {days} дн. — {n} соседей с наибольшим числом точек, из {total}", "expand": "Расширить", "export_csv": "Экспорт в CSV", "time": "Время", diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 68acd8e..1a2eec5 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -1,7 +1,7 @@ import logging import re import time -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -20,6 +20,39 @@ MQTT_GATEWAY_CACHE: set[int] = set() UNKNOWN_NODE_NAME = "unknown name" NODE_NAME_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") MQTT_DIRECT_NODE_ID = 1 +# Meshtastic node ids are "!" + up to 8 hex digits; be a little lenient on length but the +# result must still fit the BigInteger columns. +NODE_ID_PATTERN = re.compile(r"^!([0-9a-fA-F]{1,16})$") +MAX_NODE_ID = 2**63 - 1 + + +def parse_node_id(value: str | None) -> int | None: + """Turn a "!deadbeef" style id into an int, or None when it is not one. + + Gateway and user ids arrive over MQTT and are not trustworthy; a malformed one must + not raise, otherwise the whole ingest loop goes down with it. + """ + if not value: + return None + match = NODE_ID_PATTERN.match(value) + if not match: + return None + node_id = int(match.group(1), 16) + if node_id > MAX_NODE_ID: + return None + return node_id + + +def has_valid_location(lat: int | None, lon: int | None) -> bool: + return lat is not None and lon is not None and abs(lat) > 1 and abs(lon) > 1 + + +def node_user_id(node_id: int) -> str: + return f"!{node_id:0{8}x}" + + +def placeholder_short_name(node_id: int) -> str: + return node_user_id(node_id)[-4:] def normalize_node_name(name: str) -> str: @@ -47,22 +80,76 @@ def storage_to_node_id(packet) -> int: return packet.to +async def ensure_node_exists(session, node_id: int | None, channel: str | None) -> Node | None: + if node_id is None: + return None + + node = (await session.execute(select(Node).where(Node.node_id == node_id))).scalar_one_or_none() + now_us = int(time.time() * 1_000_000) + + if node: + node.last_seen_us = now_us + if node.first_seen_us is None: + node.first_seen_us = now_us + if channel and not node.channel: + node.channel = channel + return node + + display_name = str(node_id) + node = Node( + id=node_user_id(node_id), + node_id=node_id, + long_name=display_name, + short_name=placeholder_short_name(node_id), + channel=channel, + first_seen_us=now_us, + last_seen_us=now_us, + ) + session.add(node) + return node + + async def capture_daily_snapshot() -> None: - today = datetime.now(UTC).date() + """Record packet traffic and active nodes/gateways for the previous UTC day.""" + day_end = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) + day_start = day_end - timedelta(days=1) + start_us = int(day_start.timestamp() * 1_000_000) + end_us = int(day_end.timestamp() * 1_000_000) + snapshot_date = day_start.date() async with mqtt_database.async_session() as session: - node_count = (await session.execute(select(func.count()).select_from(Node))).scalar_one() + # Include repeated observations of older packets as activity, too. Node.last_seen_us + # cannot answer this after a node has already been seen on the following day. + active_nodes = ( + select(Packet.from_node_id) + .where(Packet.import_time_us >= start_us, Packet.import_time_us < end_us) + .union( + select(Packet.from_node_id) + .join(PacketSeen, PacketSeen.packet_id == Packet.id) + .where(PacketSeen.import_time_us >= start_us, PacketSeen.import_time_us < end_us) + ) + .subquery() + ) + node_count = ( + await session.execute(select(func.count(active_nodes.c.from_node_id))) + ).scalar_one() packet_count = ( - await session.execute(select(func.count()).select_from(Packet)) + await session.execute( + select(func.count()) + .select_from(Packet) + .where(Packet.import_time_us >= start_us, Packet.import_time_us < end_us) + ) ).scalar_one() gateway_count = ( await session.execute( - select(func.count()).select_from(Node).where(Node.is_mqtt_gateway.is_(True)) + select(func.count(func.distinct(PacketSeen.node_id))).where( + PacketSeen.import_time_us >= start_us, PacketSeen.import_time_us < end_us + ) ) ).scalar_one() captured_at_us = int(time.time() * 1_000_000) values = { - "snapshot_date": today, + "snapshot_date": snapshot_date, "node_count": node_count, "packet_count": packet_count, "gateway_count": gateway_count, @@ -89,7 +176,7 @@ async def capture_daily_snapshot() -> None: else: snapshot = ( await session.execute( - select(DailySnapshot).where(DailySnapshot.snapshot_date == today) + select(DailySnapshot).where(DailySnapshot.snapshot_date == snapshot_date) ) ).scalar_one_or_none() if snapshot is None: @@ -130,6 +217,7 @@ async def process_envelope(topic, env): ).scalar_one_or_none() now_us = int(time.time() * 1_000_000) + has_location = has_valid_location(map_report.latitude_i, map_report.longitude_i) if node: node.node_id = node_id @@ -139,8 +227,9 @@ async def process_envelope(topic, env): node.role = role if not node.channel: node.channel = env.channel_id - node.last_lat = map_report.latitude_i - node.last_long = map_report.longitude_i + if has_location: + node.last_lat = map_report.latitude_i + node.last_long = map_report.longitude_i node.firmware = map_report.firmware_version node.last_seen_us = now_us if node.first_seen_us is None: @@ -155,11 +244,12 @@ async def process_envelope(topic, env): role=role, channel=env.channel_id, firmware=map_report.firmware_version, - last_lat=map_report.latitude_i, - last_long=map_report.longitude_i, first_seen_us=now_us, last_seen_us=now_us, ) + if has_location: + node.last_lat = map_report.latitude_i + node.last_long = map_report.longitude_i session.add(node) except Exception as e: print(f"Error processing MAP_REPORT_APP: {e}") @@ -172,6 +262,8 @@ async def process_envelope(topic, env): async with mqtt_database.async_session() as session: # --- Packet insert with ON CONFLICT DO NOTHING + from_node_id = getattr(env.packet, "from", None) + result = await session.execute(select(Packet).where(Packet.id == packet_id)) packet = result.scalar_one_or_none() if not packet: @@ -179,7 +271,7 @@ async def process_envelope(topic, env): packet_values = { "id": packet_id, "portnum": env.packet.decoded.portnum, - "from_node_id": getattr(env.packet, "from"), + "from_node_id": from_node_id, "to_node_id": storage_to_node_id(env.packet), "payload": env.packet.SerializeToString(), "import_time_us": now_us, @@ -213,23 +305,25 @@ async def process_envelope(topic, env): # --- PacketSeen insert with conflict-safe handling - if not env.gateway_id: - print("WARNING: Missing gateway_id, skipping PacketSeen entry") + gateway_node_id = parse_node_id(env.gateway_id) + if gateway_node_id is None: # Most likely a misconfiguration of a mqtt publisher? + logger.warning( + "Skipping PacketSeen entry: missing or malformed gateway_id %r", env.gateway_id + ) + await session.commit() return - else: - node_id = int(env.gateway_id[1:], 16) - if node_id not in MQTT_GATEWAY_CACHE: - MQTT_GATEWAY_CACHE.add(node_id) + if gateway_node_id not in MQTT_GATEWAY_CACHE: + MQTT_GATEWAY_CACHE.add(gateway_node_id) await session.execute( - update(Node).where(Node.node_id == node_id).values(is_mqtt_gateway=True) + update(Node).where(Node.node_id == gateway_node_id).values(is_mqtt_gateway=True) ) now_us = int(time.time() * 1_000_000) seen_values = { "packet_id": packet_id, - "node_id": int(env.gateway_id[1:], 16), + "node_id": gateway_node_id, "channel": env.channel_id, "rx_time": env.packet.rx_time, "rx_snr": env.packet.rx_snr, @@ -271,9 +365,8 @@ async def process_envelope(topic, env): PortNum.NODEINFO_APP, env.packet.decoded.payload ) if user and user.id: - if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]): - node_id = int(user.id[1:], 16) - else: + node_id = parse_node_id(user.id) + if node_id is None: logger.warning( "Skipping NODEINFO_APP packet %s: unable to determine node_id", env.packet.id, @@ -295,6 +388,13 @@ async def process_envelope(topic, env): node = ( await session.execute(select(Node).where(Node.id == user.id)) ).scalar_one_or_none() + if node is None: + # The same node may already be stored under a differently + # spelled id (e.g. "!00abcd" vs "!abcd"); node_id is unique, so + # update that row instead of inserting a duplicate. + node = ( + await session.execute(select(Node).where(Node.node_id == node_id)) + ).scalar_one_or_none() now_us = int(time.time() * 1_000_000) long_name = normalize_node_name(user.long_name) @@ -332,11 +432,10 @@ async def process_envelope(topic, env): position = decode_payload.decode_payload( PortNum.POSITION_APP, env.packet.decoded.payload ) - if position and position.latitude_i and position.longitude_i: - from_node_id = getattr(env.packet, "from") - node = ( - await session.execute(select(Node).where(Node.node_id == from_node_id)) - ).scalar_one_or_none() + if position and has_valid_location(position.latitude_i, position.longitude_i): + node = await ensure_node_exists( + session, getattr(env.packet, "from", None), env.channel_id + ) if node: now_us = int(time.time() * 1_000_000) node.last_lat = position.latitude_i @@ -356,7 +455,7 @@ async def process_envelope(topic, env): packet_id=packet_id, route=env.packet.decoded.payload, done=not env.packet.decoded.want_response, - gateway_node_id=int(env.gateway_id[1:], 16), + gateway_node_id=gateway_node_id, import_time_us=now_us, ) ) diff --git a/meshview/radio/coverage.py b/meshview/radio/coverage.py index d14ed8c..524a3ac 100644 --- a/meshview/radio/coverage.py +++ b/meshview/radio/coverage.py @@ -42,7 +42,12 @@ def destination_point( return math.degrees(lat2), math.degrees(lon2) -@lru_cache(maxsize=512) +def _loop_is_safe(radius_km: float, step_km: float) -> bool: + """A non-positive step or a non-finite radius would make the distance loop endless.""" + return math.isfinite(radius_km) and math.isfinite(step_km) and step_km > 0 + + +@lru_cache(maxsize=64) def compute_coverage( lat: float, lon: float, @@ -54,7 +59,7 @@ def compute_coverage( step_km: float, reliability: float, ) -> list[tuple[float, float, float]]: - if not ITM_AVAILABLE: + if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km): return [] points = [] @@ -90,7 +95,7 @@ def compute_coverage( return points -@lru_cache(maxsize=512) +@lru_cache(maxsize=64) def compute_perimeter( lat: float, lon: float, @@ -103,7 +108,7 @@ def compute_perimeter( reliability: float, threshold_dbm: float, ) -> list[tuple[float, float]]: - if not ITM_AVAILABLE: + if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km): return [] perimeter = [] diff --git a/meshview/static/html-escape.js b/meshview/static/html-escape.js new file mode 100644 index 0000000..691fc4b --- /dev/null +++ b/meshview/static/html-escape.js @@ -0,0 +1,10 @@ +/* Shared HTML escaping for strings that come from the mesh (node names, channel ids, + firmware strings, message text). Anything a radio can send must go through this + before it is placed in innerHTML, insertAdjacentHTML or a Leaflet popup/tooltip. */ +(function () { + const MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + window.escapeHtml = function escapeHtml(value) { + if (value === null || value === undefined) return ""; + return String(value).replace(/[&<>"']/g, ch => MAP[ch]); + }; +})(); diff --git a/meshview/static/kiosk.html b/meshview/static/kiosk.html index 2c3943b..0077d68 100644 --- a/meshview/static/kiosk.html +++ b/meshview/static/kiosk.html @@ -45,6 +45,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; } + + {% block head %}{% endblock %} @@ -39,8 +40,9 @@ height: 500px; } - {% block css %}{% endblock %} + + {% block css %}{% endblock %} diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index d724afe..ca7cbb3 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index e109cc1..17309eb 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} @@ -199,9 +201,7 @@ function portLabel(portnum, payload, linksHtml) { const name = PORT_MAP[portnum] || "Unknown"; const color = PORT_COLORS[portnum] || "#6c757d"; - const safePayload = payload - ? payload.replace(/"/g, """) - : ""; + const safePayload = escapeHtml(payload || ""); return ` @@ -325,7 +325,7 @@ async function fetchUpdates() { ${firehoseTranslations.all_broadcast || "All"} ` : ` - ${nodeMap[pkt.from_node_id] || pkt.from_node_id} + ${escapeHtml(nodeMap[pkt.from_node_id] || pkt.from_node_id)} `; /* TO — includes translation */ @@ -339,7 +339,7 @@ async function fetchUpdates() { ${firehoseTranslations.all_broadcast || "All"} ` : ` - ${nodeMap[pkt.to_node_id] || pkt.to_node_id} + ${escapeHtml(nodeMap[pkt.to_node_id] || pkt.to_node_id)} `; let inlineLinks = ""; @@ -369,9 +369,7 @@ async function fetchUpdates() { >⮕`; } - const safePayload = (pkt.payload || "") - .replace(//g, ">"); + const safePayload = escapeHtml(pkt.payload || ""); const packetIdCell = pkt.packet_id ? ` @@ -395,7 +393,7 @@ async function fetchUpdates() { ${from} ${to} - ${pkt.channel || "—"} + ${escapeHtml(pkt.channel || "—")} ${portLabel(pkt.portnum, pkt.payload, inlineLinks)} diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 4844f4c..d92eab0 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + + {% endblock %} {% block body %} @@ -207,6 +236,7 @@ L.control.scale({ }).addTo(map); var nodeLayer = L.layerGroup().addTo(map); var edgeLayer = L.layerGroup().addTo(map); +var packetSeenLayer = L.layerGroup(); var trafficHeatLayer = L.heatLayer([], { radius: 34, blur: 18, @@ -221,6 +251,7 @@ var trafficHeatLayer = L.heatLayer([], { }); var overlayLayers = { "Nodes": nodeLayer, + "Packets Reach": packetSeenLayer, "Traffic HeatMap": trafficHeatLayer }; L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map); @@ -229,32 +260,26 @@ L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map); var nodes = [], markers = {}, markerById = {}, nodeMap = new Map(); var selectedNodeId = null; var activeBlinks = new Map(), lastImportTime = null; +var activePacketSeenHighlights = []; var mapInterval = 0; var unmappedPackets = []; var trafficHeatLoaded = false; var trafficHeatLoading = false; const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; +const PACKET_SEEN_DELAY_MS = 15000; +const PACKET_TRACE_MIN_DURATION_MS = 1500; +const PACKET_TRACE_MAX_DURATION_MS = 6500; +const PACKET_TRACE_MS_PER_PIXEL = 18; +const PACKET_LANDING_DURATION_MS = 1000; const portMap = window.PORT_LABEL_MAP; -// Fallback colors for channels that are not Meshtastic presets. -// Every entry clears 3:1 contrast against both the light map tiles and the dark -// filter panel, and stays perceptually far from every channelColorOverrides value. +// Generated colors for channels without a configured override. const palette = ["#18aa18","#976049","#a5508c","#1898dc","#3a8846","#dc1884", "#b8813d","#b64f35","#408b18","#4c719e","#be379c","#18aa52"]; -const channelColorOverrides = { - longfast: "#4363d8", - longslow: "#000075", - verylongslow: "#808000", - mediumslow: "#008080", - mediumfast: "#e6194b", - shortslow: "#911eb4", - shortfast: "#f58231", - longmoderate: "#46f0f0", - shortturbo: "#f032e6" -}; +const channelColorOverrides = new Map(); const colorMap = new Map(); const channelSet = new Set(); @@ -279,7 +304,7 @@ function hashToColor(str){ const channel = String(str || "Unknown"); if(colorMap.has(channel)) return colorMap.get(channel); - const override = channelColorOverrides[normalizeChannelName(channel)]; + const override = channelColorOverrides.get(normalizeChannelName(channel)); const c = override || palette[Math.floor(hashToUnit(channel) * palette.length) % palette.length]; colorMap.set(channel,c); return c; @@ -395,6 +420,7 @@ function fetchNewPackets(){ } else { addUnmappedPacket(pkt, nodeData); } + schedulePacketSeenNodes(pkt); }); lastImportTime = latest; @@ -430,6 +456,7 @@ map.on("overlayadd", e => { map.on("overlayremove", e => { if(e.layer === nodeLayer) clearActiveBlinks(); + if(e.layer === packetSeenLayer) clearPacketSeenHighlights(); }); async function waitForConfig() { @@ -438,6 +465,9 @@ async function waitForConfig() { } try { const cfg = await window._siteConfigPromise; + for(const [channel, color] of Object.entries(cfg.site?.map_channel_colors || {})){ + channelColorOverrides.set(normalizeChannelName(channel), color); + } return cfg.site || {}; } catch (err) { console.error("Error loading site config:", err); @@ -486,7 +516,8 @@ initMapPolling(); fetch('/api/nodes?days_active=3') .then(r=>r.json()) - .then(data=>{ + .then(async data=>{ + await waitForConfig(); if(!data.nodes) return; nodes = data.nodes.map(n=>({ @@ -753,8 +784,9 @@ function isNodeVisibleForPackets(marker){ function clearBlinkForMarker(marker){ if(activeBlinks.has(marker)){ - const interval = activeBlinks.get(marker); - clearInterval(interval); + const blink = activeBlinks.get(marker); + clearInterval(blink.interval); + if(blink.tooltipTimeout) clearTimeout(blink.tooltipTimeout); activeBlinks.delete(marker); } marker.setStyle({ fillColor: marker.originalColor }); @@ -769,6 +801,96 @@ function clearActiveBlinks(){ activeBlinks.clear(); } +function clearPacketSeenHighlights(){ + activePacketSeenHighlights.forEach(timeout => clearTimeout(timeout)); + activePacketSeenHighlights = []; + packetSeenLayer.clearLayers(); +} + +function schedulePacketSeenNodes(pkt){ + if(!map.hasLayer(packetSeenLayer) || pkt.id == null) return; + + const sourceNode = nodeMap.get(pkt.from_node_id); + if(!sourceNode || isInvalidCoord(sourceNode)) return; + + const timeout = setTimeout(() => showPacketSeenNodes(pkt), PACKET_SEEN_DELAY_MS); + activePacketSeenHighlights.push(timeout); +} + +async function showPacketSeenNodes(pkt){ + if(!map.hasLayer(packetSeenLayer) || pkt.id == null) return; + + try { + const res = await fetch(`/api/packets_seen/${pkt.id}`); + if(!res.ok) return; + + const data = await res.json(); + const seen = data.seen || []; + const sourceId = pkt.from_node_id; + const sourceNode = nodeMap.get(sourceId); + const sourceLatLng = sourceNode && !isInvalidCoord(sourceNode) + ? getNodeLatLng(sourceNode) + : null; + + seen.forEach(row => { + const node = nodeMap.get(row.node_id); + if(!node || isInvalidCoord(node)) return; + + const isSource = row.node_id === sourceId; + if(!sourceLatLng || isSource) return; + + const markerLatLng = markerById[row.node_id]?.getLatLng(); + const lat = markerLatLng?.lat ?? node.lat; + const lng = markerLatLng?.lng ?? node.long; + const trace = L.polyline([[sourceLatLng.lat, sourceLatLng.lng], [lat, lng]], { + color: "#000", + weight: 3, + opacity: 0.9, + className: "packet-seen-trace", + interactive: false + }).addTo(packetSeenLayer); + const traceEl = trace.getElement(); + let traceDurationMs = PACKET_TRACE_MAX_DURATION_MS; + if(traceEl){ + const traceLength = traceEl.getTotalLength(); + traceDurationMs = Math.min( + PACKET_TRACE_MAX_DURATION_MS, + Math.max(PACKET_TRACE_MIN_DURATION_MS, traceLength * PACKET_TRACE_MS_PER_PIXEL) + ); + traceEl.style.setProperty("--packet-trace-gap", `${traceLength + 20}px`); + traceEl.style.setProperty("--packet-trace-distance", `${traceLength + 20}px`); + traceEl.style.setProperty("--packet-trace-duration", `${traceDurationMs}ms`); + } + + const landingTimeout = setTimeout(() => { + const landing = L.circleMarker([lat, lng], { + radius: 16, + color: "#000", + fillColor: "#000", + fillOpacity: 0.24, + opacity: 0.7, + weight: 2, + className: "packet-seen-landing", + interactive: false + }).addTo(packetSeenLayer); + + const clearLandingTimeout = setTimeout(() => { + packetSeenLayer.removeLayer(landing); + }, PACKET_LANDING_DURATION_MS); + activePacketSeenHighlights.push(clearLandingTimeout); + }, traceDurationMs); + activePacketSeenHighlights.push(landingTimeout); + + const timeout = setTimeout(() => { + packetSeenLayer.removeLayer(trace); + }, traceDurationMs + PACKET_LANDING_DURATION_MS); + activePacketSeenHighlights.push(timeout); + }); + } catch(err) { + console.error("Failed to load packet seen nodes", pkt.id, err); + } +} + function blinkNode(marker,longName,portnum,payload){ if(!isNodeVisibleForPackets(marker)) return; @@ -776,9 +898,11 @@ function blinkNode(marker,longName,portnum,payload){ clearBlinkForMarker(marker); } - let blinkCount = 0; const blinkStart = Date.now(); - const blinkDurationMs = 7000; + const tooltipDurationMs = 5000; + const blinkDurationMs = map.hasLayer(packetSeenLayer) ? 15000 : tooltipDurationMs; + const fastBlinkStartMs = blinkDurationMs - 3000; + const blinkColor = "yellow"; const safeName = escapeHtml(longName); const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; const payloadText = (payload || "").trim(); @@ -799,26 +923,39 @@ function blinkNode(marker,longName,portnum,payload){ .addTo(map); marker.tooltip = tooltip; + const tooltipTimeout = setTimeout(() => { + if(blinkDurationMs === tooltipDurationMs){ + clearBlinkForMarker(marker); + return; + } + if(marker.tooltip){ + map.removeLayer(marker.tooltip); + marker.tooltip = null; + } + }, tooltipDurationMs); const interval = setInterval(()=>{ + const elapsedMs = Date.now() - blinkStart; + const blinkPeriodMs = elapsedMs >= fastBlinkStartMs ? 250 : 500; if(isNodeVisibleForPackets(marker)){ marker.setStyle({ - fillColor: blinkCount%2===0 ? 'yellow' : marker.originalColor + fillColor: Math.floor(elapsedMs / blinkPeriodMs)%2===0 + ? blinkColor + : marker.originalColor }); marker.bringToFront(); } else { clearBlinkForMarker(marker); return; } - blinkCount++; - if(Date.now() - blinkStart > blinkDurationMs){ + if(elapsedMs > blinkDurationMs){ clearBlinkForMarker(marker); } - },500); + },250); - activeBlinks.set(marker, interval); + activeBlinks.set(marker, { interval, tooltipTimeout }); } /* ====================================================== diff --git a/meshview/templates/net.html b/meshview/templates/net.html index 669bb97..f96b8a3 100644 --- a/meshview/templates/net.html +++ b/meshview/templates/net.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/node.html b/meshview/templates/node.html index 763ef10..eccc758 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} @@ -548,9 +549,9 @@ function makeNodePopup(node) { return `
- ${node.long_name || node.short_name || node.node_id} + ${escapeHtml(node.long_name || node.short_name || node.node_id)} - ${node.short_name ? ` (${node.short_name})` : ""}
+ ${node.short_name ? ` (${escapeHtml(node.short_name)})` : ""}
${nodeTranslations.node_id || "Node ID"}: @@ -558,19 +559,19 @@ function makeNodePopup(node) { ${nodeTranslations.hw_model || "HW Model"}: - ${node.hw_model ?? "—"}
+
${escapeHtml(node.hw_model ?? "—")}
${nodeTranslations.channel || "Channel"}: - ${node.channel ?? "—"}
+ ${escapeHtml(node.channel ?? "—")}
${nodeTranslations.role || "Role"}: - ${node.role ?? "—"}
+ ${escapeHtml(node.role ?? "—")}
${nodeTranslations.firmware || "Firmware"}: - ${node.firmware ?? "—"}
+ ${escapeHtml(node.firmware ?? "—")}
${nodeTranslations.last_update || "Last Update"}: @@ -745,7 +746,7 @@ function nodeLink(id, labelOverride = null) { const label = labelOverride || nodeMap[id] || id; return ` - ${label} + ${escapeHtml(label)} `; } @@ -855,7 +856,7 @@ function addMarker(id, lat, lon, color = "red", node = null) { node = nodeCache[id] || null; } - const popupHtml = node ? makeNodePopup(node) : `${id}`; + const popupHtml = node ? makeNodePopup(node) : `${escapeHtml(id)}`; const m = L.circleMarker([lat, lon], { radius: 6, @@ -1046,8 +1047,7 @@ async function loadPackets(filters = {}) { // ================================ // TABLE ROW // ================================ - const safePayload = (pkt.payload || "") - .replace(/[<>]/g, m => (m === "<" ? "<" : ">")); + const safePayload = escapeHtml(pkt.payload || ""); const localTime = formatLocalTime(pkt.import_time_us); const fromCell = nodeLink(pkt.from_node_id, pkt.long_name); @@ -1294,13 +1294,26 @@ async function loadLatestNeighborIds() { /* ====================================================== NEIGHBOR CHART (portnum=71) + + The request is bounded to NEIGHBOR_WINDOW_DAYS through the + API's `since` parameter, and only the NEIGHBOR_MAX_SERIES + neighbors with the most data points in that window are + drawn. Without both limits a busy node produces hundreds of + legend entries covering weeks of history. ====================================================== */ +const NEIGHBOR_WINDOW_DAYS = 7; +const NEIGHBOR_MAX_SERIES = 15; + async function loadNeighborTimeSeries() { const container = document.getElementById("neighbor_chart_container"); const chartEl = document.getElementById("chart_neighbors"); - const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`; + // Start of the window, in microseconds (import_time_us is epoch-us). + const sinceUs = (Date.now() - NEIGHBOR_WINDOW_DAYS * 86400 * 1000) * 1000; + + const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}` + + `&since=${sinceUs}&limit=500`; const res = await fetch(url); if (!res.ok) { @@ -1309,7 +1322,11 @@ async function loadNeighborTimeSeries() { } const data = await res.json(); - const packets = data.packets || []; + + // Re-apply the window here too, so the chart cannot drift past + // it if the packet source ever returns something older. + const packets = (data.packets || []) + .filter(p => (p.import_time_us || 0) >= sinceUs); if (!packets.length) { container.style.display = "none"; @@ -1319,7 +1336,8 @@ async function loadNeighborTimeSeries() { // Sort packets chronologically (microseconds) packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0)); - const neighborHistory = {}; // node_id -> { name, times[], snr[] } + // --- Pass 1: collect the readings, without looking up names yet --- + const neighborHistory = {}; // node_id -> { times[], snr[] } for (const pkt of packets) { if (!pkt.import_time_us || !pkt.payload) continue; @@ -1338,39 +1356,56 @@ async function loadNeighborTimeSeries() { if (!idMatch || !snrMatch) continue; const nid = parseInt(idMatch[1], 10); - const snr = parseFloat(snrMatch[1]); - - // Fetch neighbor metadata once - const neighbor = await fetchNodeFromApi(nid); if (!neighborHistory[nid]) { - neighborHistory[nid] = { - name: neighbor?.short_name || - neighbor?.long_name || - `Node ${nid}`, - times: [], - snr: [] - }; + neighborHistory[nid] = { times: [], snr: [] }; } neighborHistory[nid].times.push(ts); - neighborHistory[nid].snr.push(snr); + neighborHistory[nid].snr.push(parseFloat(snrMatch[1])); } } - // Collect ALL timestamps across neighbors - const allTimes = new Set(); - Object.values(neighborHistory).forEach(entry => { - entry.times.forEach(t => allTimes.add(t)); - }); + // --- Keep the neighbors with the most data points in the window --- + const ranked = Object.entries(neighborHistory) + .sort((a, b) => b[1].times.length - a[1].times.length); - // Sort timestamps numerically - const xTimes = Array.from(allTimes).sort((a, b) => a - b); + const kept = ranked.slice(0, NEIGHBOR_MAX_SERIES); + const dropped = ranked.length - kept.length; + + if (!kept.length) { + container.style.display = "none"; + return; + } + + // --- Pass 2: names, only for the series that are drawn --- + await Promise.all(kept.map(async ([nid, entry]) => { + const neighbor = await fetchNodeFromApi(nid); + entry.name = neighbor?.short_name || + neighbor?.long_name || + `Node ${nid}`; + })); + + // ECharts identifies a series by its name, so two neighbors sharing + // a short name would be drawn as one line. Keep them apart. + const usedNames = new Set(); + for (const [nid, entry] of kept) { + if (usedNames.has(entry.name)) entry.name = `${entry.name} (${nid})`; + usedNames.add(entry.name); + } + + // Timestamps across the series that are drawn + const xTimes = Array.from( + new Set(kept.flatMap(([, entry]) => entry.times)) + ).sort((a, b) => a - b); const legend = []; const series = []; - for (const entry of Object.values(neighborHistory)) { + for (const [, entry] of kept) { + const byTime = new Map(); + entry.times.forEach((t, i) => byTime.set(t, entry.snr[i])); + legend.push(entry.name); series.push({ @@ -1379,21 +1414,46 @@ async function loadNeighborTimeSeries() { smooth: true, connectNulls: true, showSymbol: false, - data: xTimes.map(t => { - const idx = entry.times.indexOf(t); - return idx >= 0 ? entry.snr[idx] : null; - }) + data: xTimes.map(t => (byTime.has(t) ? byTime.get(t) : null)) }); } + // Values behind the "Export CSV" button: the most recent SNR + // in the window, per neighbor. + neighborData = { + ids: kept.map(([nid]) => nid), + names: kept.map(([, entry]) => entry.name), + snrs: kept.map(([, entry]) => entry.snr[entry.snr.length - 1]) + }; + + // Say which neighbors are on screen, so a missing line is not a mystery. + const subtextTemplate = dropped > 0 + ? (nodeTranslations.neighbors_chart_subtitle_capped || + "Last {days} days — the {n} neighbors with the most data points, of {total}") + : (nodeTranslations.neighbors_chart_subtitle || + "Last {days} days — {n} neighbors"); + + const subtext = subtextTemplate + .replace("{days}", NEIGHBOR_WINDOW_DAYS) + .replace("{n}", kept.length) + .replace("{total}", ranked.length); + const chart = echarts.init(chartEl); chart.setOption({ + title: { + subtext, + left: "center", + bottom: 0, + subtextStyle: { color: "#888", fontSize: 11 } + }, + grid: { bottom: 60 }, tooltip: { trigger: "axis", axisPointer: { type: "line" } }, legend: { + type: "scroll", data: legend, textStyle: { color: "#ccc" } }, diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index fe515b1..1bebd01 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -5,6 +5,7 @@ {% endblock %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index ca86070..61bab28 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -652,14 +652,14 @@ document.addEventListener("DOMContentLoaded", async function() { // DESKTOP TABLE ROW const row = document.createElement("tr"); row.innerHTML = ` - ${node.short_name || "N/A"} - ${node.long_name || "N/A"} - ${node.hw_model || "N/A"} - ${node.firmware || "N/A"} - ${node.role || "N/A"} + ${escapeHtml(node.short_name || "N/A")} + ${escapeHtml(node.long_name || "N/A")} + ${escapeHtml(node.hw_model || "N/A")} + ${escapeHtml(node.firmware || "N/A")} + ${escapeHtml(node.role || "N/A")} ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} - ${node.channel || "N/A"} + ${escapeHtml(node.channel || "N/A")} ${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")} ${timeAgoFromMs(node.last_seen_ms)} @@ -675,22 +675,22 @@ document.addEventListener("DOMContentLoaded", async function() { card.className = "node-card"; card.innerHTML = `
- ${node.short_name || node.long_name || node.node_id} + ${escapeHtml(node.short_name || node.long_name || node.node_id)} ${star}
ID: ${node.node_id}
-
Name: ${node.long_name || "N/A"}
-
HW: ${node.hw_model || "N/A"}
-
Firmware: ${node.firmware || "N/A"}
-
Role: ${node.role || "N/A"}
+
Name: ${escapeHtml(node.long_name || "N/A")}
+
HW: ${escapeHtml(node.hw_model || "N/A")}
+
Firmware: ${escapeHtml(node.firmware || "N/A")}
+
Role: ${escapeHtml(node.role || "N/A")}
Location: ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
-
Channel: ${node.channel || "N/A"}
+
Channel: ${escapeHtml(node.channel || "N/A")}
Last Seen: ${timeAgoFromMs(node.last_seen_ms)}
diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html index 0be014a..1db8451 100644 --- a/meshview/templates/packet.html +++ b/meshview/templates/packet.html @@ -206,12 +206,12 @@ document.addEventListener("DOMContentLoaded", async () => { const fromNodeObj = nodeLookup[p.from_node_id]; const toNodeObj = nodeLookup[p.to_node_id]; - const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id; + const fromNodeLabel = escapeHtml(fromNodeObj?.long_name || p.from_node_id); const toNodeLabel = p.to_node_id == 4294967295 ? (packetTranslations.all_broadcast || "All") - : (toNodeObj?.long_name || p.to_node_id); + : escapeHtml(toNodeObj?.long_name || p.to_node_id); /* --------------------------------------------- Parse payload for lat/lon @@ -267,7 +267,7 @@ document.addEventListener("DOMContentLoaded", async () => { }
${packetTranslations.channel || "Channel"}:
-
${p.channel ?? "—"}
+
${escapeHtml(p.channel ?? "—")}
${packetTranslations.port || "Port"}:
${PORT_NAMES[p.portnum] || "UNKNOWN APP"} (${p.portnum})
@@ -338,7 +338,7 @@ document.addEventListener("DOMContentLoaded", async () => { Lat: ${srcLat.toFixed(6)}
Lon: ${srcLon.toFixed(6)}
${packetTranslations.from_node || "From Node:"} ${fromNodeLabel}
- ${packetTranslations.channel || "Channel:"} ${p.channel ?? "—"}
+ ${packetTranslations.channel || "Channel:"} ${escapeHtml(p.channel ?? "—")}
${packetTranslations.port || "Port:"} ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
`); @@ -427,7 +427,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups) const rows = hopGroups[hopKey].map(s => { const node = nodeLookup[s.node_id]; - const label = node?.long_name || s.node_id; + const label = escapeHtml(node?.long_name || s.node_id); const hopDisplay = formatHopDisplay(hopKey, s.hop_start); const timeStr = s.import_time_us @@ -473,8 +473,8 @@ seenTableBody.innerHTML = Object.keys(hopGroups) ${label}
Node ID:
${s.node_id}
- HW: ${node?.hw_model ?? "—"}
- Channel: ${s.channel ?? "—"}
+ HW: ${escapeHtml(node?.hw_model ?? "—")}
+ Channel: ${escapeHtml(s.channel ?? "—")}
${ distanceKm !== null ? `Distance: @@ -500,7 +500,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups) ${s.rx_rssi ?? "—"} ${s.rx_snr ?? "—"} ${hopDisplay} - ${s.channel ?? "—"} + ${escapeHtml(s.channel ?? "—")} ${timeStr} `; @@ -535,7 +535,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups) Escape HTML helper ----------------------------------------------*/ function escapeHtml(unsafe) { - return (unsafe ?? "").replace(/[&<"'>]/g, m => ({ + return String(unsafe ?? "").replace(/[&<"'>]/g, m => ({ "&":"&", "<":"<", ">":">", diff --git a/meshview/templates/reliability.html b/meshview/templates/reliability.html index 8cc5475..5c3668d 100644 --- a/meshview/templates/reliability.html +++ b/meshview/templates/reliability.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index ce5e2fd..deab1f9 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block head %} diff --git a/meshview/templates/top.html b/meshview/templates/top.html index cc17574..d42678c 100644 --- a/meshview/templates/top.html +++ b/meshview/templates/top.html @@ -194,11 +194,11 @@ async function renderTable() { - ${n.long_name || n.node_id} + ${escapeHtml(n.long_name || n.node_id)} - ${n.short_name || ""} - ${n.channel || ""} + ${escapeHtml(n.short_name || "")} + ${escapeHtml(n.channel || "")} ${n.sent} ${n.seen} ${n.avg.toFixed(1)} diff --git a/meshview/templates/traceroute.html b/meshview/templates/traceroute.html index f42993e..dc8922e 100644 --- a/meshview/templates/traceroute.html +++ b/meshview/templates/traceroute.html @@ -5,6 +5,7 @@ {% endblock %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/web.py b/meshview/web.py index 72beeb4..887264b 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -241,8 +241,11 @@ async def serve_page(request): if not page.endswith(".html"): page = f"{page}.html" - html_file = pathlib.Path(__file__).parent / "static" / page - if not html_file.exists(): + # aiohttp lets an encoded slash (%2F) through a single path segment, so resolve the + # final path and make sure it is still inside the static directory. + static_root = (pathlib.Path(__file__).parent / "static").resolve() + html_file = (static_root / page).resolve() + if static_root not in html_file.parents or not html_file.is_file(): raise web.HTTPNotFound(text=f"Page '{page}' not found") content = html_file.read_text(encoding="utf-8") diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 3d94282..bf33b32 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -1,10 +1,12 @@ """API endpoints for MeshView.""" +import asyncio import datetime import json import logging import math import os +import re from aiohttp import web from sqlalchemy import func, select @@ -37,6 +39,9 @@ _LANG_CACHE = {} # Create dedicated route table for API endpoints routes = web.RouteTableDef() +# Language codes are short identifiers such as "en", "es", "pt-BR". +LANG_CODE_RE = re.compile(r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})?$") + def _config_bool(section: str, key: str, default: bool = False) -> bool: return str(CONFIG.get(section, {}).get(key, default)).lower() in ("1", "true", "yes", "on") @@ -150,6 +155,20 @@ def _bearing_deg(lat1, lon1, lat2, lon2): OBSERVED_MAX_DISTANCE_KM = 50.0 +# Bounds for /api/coverage query parameters. The ITM loop runs once per (distance step x +# bearing), so the request size must be capped or a single call can hold the CPU for minutes. +COVERAGE_LIMITS = { + "freq_mhz": (1.0, 10_000.0), + "tx_dbm": (-50.0, 60.0), + "tx_height_m": (0.0, 1_000.0), + "rx_height_m": (0.0, 1_000.0), + "radius_km": (1.0, 200.0), + "step_km": (0.05, 50.0), + "reliability": (0.01, 0.99), + "threshold_dbm": (-200.0, 0.0), +} +COVERAGE_MAX_STEPS = 4_000 # radius_km / step_km + def init_api_module(packet_class, seq_regex, lang_dir): """Initialize API module with dependencies from main web module.""" @@ -725,6 +744,12 @@ async def api_config(request): "map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0), "map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0), "map_interval": get_int(site, "map_interval", 3), + + "map_channel_colors": { + channel: color.strip() + for channel, color in CONFIG.get("map_channel_colors", {}).items() + if re.fullmatch(r"#[0-9a-fA-F]{6}", color.strip()) + }, "firehose_interval": get_int(site, "firehose_interval", 3), "weekly_net_message": get_str( site, "weekly_net_message", "Weekly Mesh check-in message." @@ -779,6 +804,10 @@ async def api_lang(request): lang_code = request.query.get("lang") or CONFIG.get("site", {}).get("language", "en") section = request.query.get("section") + # The code names a file, so it must stay a plain language code. Anything else (a path, + # an absolute filename) falls back to English just like an unknown language does. + if not LANG_CODE_RE.match(lang_code): + lang_code = "en" lang_file = os.path.join(LANG_DIR, f"{lang_code}.json") if not os.path.exists(lang_file): lang_file = os.path.join(LANG_DIR, "en.json") @@ -1219,12 +1248,19 @@ async def api_coverage(request): if value is None: return default try: - return float(value) + parsed = float(value) except ValueError as exc: raise web.HTTPBadRequest( text=json.dumps({"error": f"{name} must be a number"}), content_type="application/json", ) from exc + low, high = COVERAGE_LIMITS[name] + if not math.isfinite(parsed) or not (low <= parsed <= high): + raise web.HTTPBadRequest( + text=json.dumps({"error": f"{name} must be between {low} and {high}"}), + content_type="application/json", + ) + return parsed try: freq_mhz = parse_float("freq_mhz", 907.0) @@ -1238,6 +1274,11 @@ async def api_coverage(request): except web.HTTPBadRequest as exc: raise exc + if radius_km / step_km > COVERAGE_MAX_STEPS: + return web.json_response( + {"error": f"radius_km / step_km must not exceed {COVERAGE_MAX_STEPS}"}, status=400 + ) + node = await store.get_node(node_id) if not node or not node.last_lat or not node.last_long: return web.json_response({"error": "Node not found or missing location"}, status=404) @@ -1246,8 +1287,11 @@ async def api_coverage(request): lon = node.last_long * 1e-7 mode = request.query.get("mode", "perimeter") + # The ITM model is pure CPU work; keep it off the event loop so other requests + # are still served while it runs. if mode == "perimeter": - perimeter = compute_perimeter( + perimeter = await asyncio.to_thread( + compute_perimeter, lat=round(lat, 7), lon=round(lon, 7), freq_mhz=round(freq_mhz, 3), @@ -1263,7 +1307,8 @@ async def api_coverage(request): {"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter} ) - points = compute_coverage( + points = await asyncio.to_thread( + compute_coverage, lat=round(lat, 7), lon=round(lon, 7), freq_mhz=round(freq_mhz, 3), diff --git a/sample.config.ini b/sample.config.ini index df24db5..e70f02f 100644 --- a/sample.config.ini +++ b/sample.config.ini @@ -59,6 +59,19 @@ firehose_interal=3 weekly_net_message = Weekly Mesh check-in. We will keep it open on every Wednesday from 5:00pm for checkins. The message format should be (LONG NAME) - (CITY YOU ARE IN) #BayMeshNet. net_tag = #BayMeshNet +# Map channel color overrides. Use six-digit hex colors; other channels get generated colors. +# Names ignore case, spaces, hyphens, and underscores. Restart the web server after editing. +[map_channel_colors] +LongFast = #4363d8 +LongSlow = #000075 +VeryLongSlow = #808000 +MediumSlow = #008080 +MediumFast = #e6194b +ShortSlow = #911eb4 +ShortFast = #f58231 +LongModerate = #46f0f0 +ShortTurbo = #f032e6 + # ------------------------- # MQTT Broker Configuration # ------------------------- diff --git a/startdb.py b/startdb.py index ac334a9..5b55d98 100644 --- a/startdb.py +++ b/startdb.py @@ -338,11 +338,16 @@ async def load_database_from_mqtt( mqtt_user: str | None = None, mqtt_passwd: str | None = None, ): + logger = logging.getLogger(__name__) async for topic, env in mqtt_reader.get_topic_envelopes( mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd ): async with db_lock: # Block if cleanup is running - await mqtt_store.process_envelope(topic, env) + try: + await mqtt_store.process_envelope(topic, env) + except Exception: + # One bad or unexpected message must not stop ingestion for everyone. + logger.exception("Failed to store a message from topic %s; skipping it", topic) # -------------------------