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 = `
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)
# -------------------------