mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
Merge branch 'develop'
This commit is contained in:
commit
9952426aee
28 files changed
+585
-147
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.8"
|
||||
__version__ = "3.0.9"
|
||||
__release_date__ = "2026-5-12"
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Время",
|
||||
|
||||
+128
-29
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
})();
|
||||
@@ -45,6 +45,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js" crossorigin></script>
|
||||
<script src="/static/portmaps.js"></script>
|
||||
<script src="/static/html-escape.js"></script>
|
||||
|
||||
<script>
|
||||
(async function(){
|
||||
@@ -115,12 +116,12 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
marker.originalColor = color;
|
||||
markerById[node.node_id]=marker;
|
||||
|
||||
let popupContent=`<b>${node.long_name} (${node.short_name})</b><br>
|
||||
<b>Channel:</b> ${node.channel}<br>
|
||||
<b>Model:</b> ${node.hw_model}<br>
|
||||
<b>Role:</b> ${node.role}<br>`;
|
||||
let popupContent=`<b>${escapeHtml(node.long_name)} (${escapeHtml(node.short_name)})</b><br>
|
||||
<b>Channel:</b> ${escapeHtml(node.channel)}<br>
|
||||
<b>Model:</b> ${escapeHtml(node.hw_model)}<br>
|
||||
<b>Role:</b> ${escapeHtml(node.role)}<br>`;
|
||||
if(node.last_seen_us) popupContent+=`<b>Last seen:</b> ${timeAgoFromUs(node.last_seen_us)}<br>`;
|
||||
if(node.firmware) popupContent+=`<b>Firmware:</b> ${node.firmware}<br>`;
|
||||
if(node.firmware) popupContent+=`<b>Firmware:</b> ${escapeHtml(node.firmware)}<br>`;
|
||||
|
||||
marker.on('click', e=>{
|
||||
e.originalEvent.stopPropagation();
|
||||
@@ -139,7 +140,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
const color=hashToColor(channel);
|
||||
const label=document.createElement('label');
|
||||
label.style.color=color;
|
||||
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${id}" checked> ${channel}`;
|
||||
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${escapeHtml(id)}" checked> ${escapeHtml(channel)}`;
|
||||
filterContainer.appendChild(label);
|
||||
});
|
||||
|
||||
@@ -178,7 +179,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
let count=0;
|
||||
const portName=portMap[portnum]||`Port ${portnum}`;
|
||||
const tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
|
||||
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
|
||||
.setContent(`${escapeHtml(longName)} (${escapeHtml(portName)})`).setLatLng(marker.getLatLng());
|
||||
tooltip.addTo(map); marker.tooltip=tooltip;
|
||||
const interval=setInterval(()=>{
|
||||
if(map.hasLayer(marker)){
|
||||
|
||||
@@ -4,11 +4,14 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||
from sqlalchemy.orm import lazyload
|
||||
|
||||
from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||
from meshview import database, models
|
||||
from meshview.models import Node, Packet, PacketSeen, Traceroute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BROADCAST_NODE_ID = 0xFFFFFFFF
|
||||
|
||||
|
||||
async def get_node(node_id):
|
||||
async with database.async_session() as session:
|
||||
@@ -70,6 +73,14 @@ async def get_packets(
|
||||
contains_lower = f"%{contains.lower()}%"
|
||||
payload_text = cast(models.Packet.payload, Text)
|
||||
conditions.append(func.lower(payload_text).like(contains_lower))
|
||||
# Direct text messages are shown as "<redacted>" by the API, so they must not be
|
||||
# searchable either: a match would reveal their contents one substring at a time.
|
||||
conditions.append(
|
||||
or_(
|
||||
models.Packet.portnum != PortNum.TEXT_MESSAGE_APP,
|
||||
models.Packet.to_node_id == BROADCAST_NODE_ID,
|
||||
)
|
||||
)
|
||||
|
||||
# Apply WHERE conditions
|
||||
if conditions:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
<script src="/static/html-escape.js"></script>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
@@ -39,8 +40,9 @@
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
{% block css %}{% endblock %}
|
||||
</style>
|
||||
|
||||
{% block css %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.timestamp {
|
||||
min-width: 10em;
|
||||
color: #ccc;
|
||||
@@ -53,6 +54,7 @@
|
||||
.chat-filter {
|
||||
min-width: 220px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
@@ -90,6 +91,7 @@
|
||||
.inline-link:hover {
|
||||
color: #c7e6ff;
|
||||
}
|
||||
</style>
|
||||
{% 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 `
|
||||
<span class="port-tag" style="background-color:${color}" title="${safePayload}">
|
||||
@@ -325,7 +325,7 @@ async function fetchUpdates() {
|
||||
${firehoseTranslations.all_broadcast || "All"}
|
||||
</span>`
|
||||
: `<a href="/node/${pkt.from_node_id}" style="text-decoration:underline; color:inherit;">
|
||||
${nodeMap[pkt.from_node_id] || pkt.from_node_id}
|
||||
${escapeHtml(nodeMap[pkt.from_node_id] || pkt.from_node_id)}
|
||||
</a>`;
|
||||
|
||||
/* TO — includes translation */
|
||||
@@ -339,7 +339,7 @@ async function fetchUpdates() {
|
||||
${firehoseTranslations.all_broadcast || "All"}
|
||||
</span>`
|
||||
: `<a href="/node/${pkt.to_node_id}" style="text-decoration:underline; color:inherit;">
|
||||
${nodeMap[pkt.to_node_id] || pkt.to_node_id}
|
||||
${escapeHtml(nodeMap[pkt.to_node_id] || pkt.to_node_id)}
|
||||
</a>`;
|
||||
|
||||
let inlineLinks = "";
|
||||
@@ -369,9 +369,7 @@ async function fetchUpdates() {
|
||||
>⮕</a>`;
|
||||
}
|
||||
|
||||
const safePayload = (pkt.payload || "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const safePayload = escapeHtml(pkt.payload || "");
|
||||
const packetIdCell = pkt.packet_id
|
||||
? `<a href="/packet/${pkt.storage_id || pkt.id}"
|
||||
style="text-decoration:underline; color:inherit;">
|
||||
@@ -395,7 +393,7 @@ async function fetchUpdates() {
|
||||
|
||||
<td>${from}</td>
|
||||
<td>${to}</td>
|
||||
<td>${pkt.channel || "—"}</td>
|
||||
<td>${escapeHtml(pkt.channel || "—")}</td>
|
||||
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
+165
-28
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
@@ -27,9 +28,9 @@
|
||||
background: white;
|
||||
color: black;
|
||||
border: 1px solid #111;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
}
|
||||
@@ -37,6 +38,33 @@
|
||||
animation: textPulse 1.1s ease-in-out 6;
|
||||
border-color: #ff8c00;
|
||||
}
|
||||
.packet-seen-trace {
|
||||
stroke-dasharray: 0.01px var(--packet-trace-gap, 999px);
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.65));
|
||||
animation: packetTrace var(--packet-trace-duration, 6.5s) linear 1 forwards;
|
||||
}
|
||||
.packet-seen-landing {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: packetSeenLanding 1s ease-out 1 forwards;
|
||||
}
|
||||
@keyframes packetTrace {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: calc(-1 * var(--packet-trace-distance, 1000px)); }
|
||||
}
|
||||
@keyframes packetSeenLanding {
|
||||
from {
|
||||
stroke-opacity: 0.7;
|
||||
fill-opacity: 0.24;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
to {
|
||||
stroke-opacity: 0;
|
||||
fill-opacity: 0;
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
@keyframes textPulse {
|
||||
0% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
||||
50% { box-shadow: 0 4px 14px rgba(255,140,0,0.45); }
|
||||
@@ -90,6 +118,7 @@
|
||||
.unmapped-node { font-weight: 400; color: #000; }
|
||||
.unmapped-empty { color: #666; font-style: italic; }
|
||||
</style>
|
||||
</style>
|
||||
{% 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 });
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.timestamp { min-width: 10em; color: #ccc; }
|
||||
|
||||
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
|
||||
@@ -26,6 +27,7 @@
|
||||
#weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; }
|
||||
#total-count { margin-bottom: 10px; font-style: italic; color: #ccc; }
|
||||
#net-note { margin: 16px 0 0; font-size: 0.9rem; color: #aaa; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
{{ super() }}
|
||||
|
||||
/* --- Map --- */
|
||||
@@ -295,7 +296,7 @@
|
||||
border-color:#48bb78;
|
||||
color:#fff;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
@@ -548,9 +549,9 @@ function makeNodePopup(node) {
|
||||
return `
|
||||
<div style="font-size:0.9em">
|
||||
<a href="/node/${node.node_id}" style="color:inherit; text-decoration:underline;">
|
||||
<b>${node.long_name || node.short_name || node.node_id}</b>
|
||||
<b>${escapeHtml(node.long_name || node.short_name || node.node_id)}</b>
|
||||
</a>
|
||||
${node.short_name ? ` (${node.short_name})` : ""}<br>
|
||||
${node.short_name ? ` (${escapeHtml(node.short_name)})` : ""}<br>
|
||||
|
||||
<b><span data-translate-lang="node_id">
|
||||
${nodeTranslations.node_id || "Node ID"}:
|
||||
@@ -558,19 +559,19 @@ function makeNodePopup(node) {
|
||||
|
||||
<b><span data-translate-lang="hw_model">
|
||||
${nodeTranslations.hw_model || "HW Model"}:
|
||||
</span></b> ${node.hw_model ?? "—"}<br>
|
||||
</span></b> ${escapeHtml(node.hw_model ?? "—")}<br>
|
||||
|
||||
<b><span data-translate-lang="channel">
|
||||
${nodeTranslations.channel || "Channel"}:
|
||||
</span></b> ${node.channel ?? "—"}<br>
|
||||
</span></b> ${escapeHtml(node.channel ?? "—")}<br>
|
||||
|
||||
<b><span data-translate-lang="role">
|
||||
${nodeTranslations.role || "Role"}:
|
||||
</span></b> ${node.role ?? "—"}<br>
|
||||
</span></b> ${escapeHtml(node.role ?? "—")}<br>
|
||||
|
||||
<b><span data-translate-lang="firmware">
|
||||
${nodeTranslations.firmware || "Firmware"}:
|
||||
</span></b> ${node.firmware ?? "—"}<br>
|
||||
</span></b> ${escapeHtml(node.firmware ?? "—")}<br>
|
||||
|
||||
<b><span data-translate-lang="last_update">
|
||||
${nodeTranslations.last_update || "Last Update"}:
|
||||
@@ -745,7 +746,7 @@ function nodeLink(id, labelOverride = null) {
|
||||
const label = labelOverride || nodeMap[id] || id;
|
||||
|
||||
return `<a href="/node/${id}" style="text-decoration:underline; color:inherit;">
|
||||
${label}
|
||||
${escapeHtml(label)}
|
||||
</a>`;
|
||||
}
|
||||
|
||||
@@ -855,7 +856,7 @@ function addMarker(id, lat, lon, color = "red", node = null) {
|
||||
node = nodeCache[id] || null;
|
||||
}
|
||||
|
||||
const popupHtml = node ? makeNodePopup(node) : `<b>${id}</b>`;
|
||||
const popupHtml = node ? makeNodePopup(node) : `<b>${escapeHtml(id)}</b>`;
|
||||
|
||||
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" }
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
#mynetwork {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
@@ -82,6 +83,7 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
.circle { border-radius: 6px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
@@ -652,14 +652,14 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
// DESKTOP TABLE ROW
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${node.short_name || "N/A"}</td>
|
||||
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
|
||||
<td>${node.hw_model || "N/A"}</td>
|
||||
<td>${node.firmware || "N/A"}</td>
|
||||
<td>${node.role || "N/A"}</td>
|
||||
<td>${escapeHtml(node.short_name || "N/A")}</td>
|
||||
<td><a href="/node/${node.node_id}">${escapeHtml(node.long_name || "N/A")}</a></td>
|
||||
<td>${escapeHtml(node.hw_model || "N/A")}</td>
|
||||
<td>${escapeHtml(node.firmware || "N/A")}</td>
|
||||
<td>${escapeHtml(node.role || "N/A")}</td>
|
||||
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.channel || "N/A"}</td>
|
||||
<td>${escapeHtml(node.channel || "N/A")}</td>
|
||||
<td>${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")}</td>
|
||||
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
||||
<td style="text-align:center;">
|
||||
@@ -675,22 +675,22 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
card.className = "node-card";
|
||||
card.innerHTML = `
|
||||
<div class="node-card-header">
|
||||
<span>${node.short_name || node.long_name || node.node_id}</span>
|
||||
<span>${escapeHtml(node.short_name || node.long_name || node.node_id)}</span>
|
||||
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
|
||||
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${escapeHtml(node.long_name || "N/A")}</div>
|
||||
<div class="node-card-field"><b>HW:</b> ${escapeHtml(node.hw_model || "N/A")}</div>
|
||||
<div class="node-card-field"><b>Firmware:</b> ${escapeHtml(node.firmware || "N/A")}</div>
|
||||
<div class="node-card-field"><b>Role:</b> ${escapeHtml(node.role || "N/A")}</div>
|
||||
<div class="node-card-field"><b>Location:</b>
|
||||
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
|
||||
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
|
||||
</div>
|
||||
<div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Channel:</b> ${escapeHtml(node.channel || "N/A")}</div>
|
||||
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
||||
|
||||
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
|
||||
|
||||
@@ -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 () => {
|
||||
}</dd>
|
||||
|
||||
<dt data-translate-lang="channel">${packetTranslations.channel || "Channel"}:</dt>
|
||||
<dd>${p.channel ?? "—"}</dd>
|
||||
<dd>${escapeHtml(p.channel ?? "—")}</dd>
|
||||
|
||||
<dt data-translate-lang="port">${packetTranslations.port || "Port"}:</dt>
|
||||
<dd><i>${PORT_NAMES[p.portnum] || "UNKNOWN APP"}</i> (${p.portnum})</dd>
|
||||
@@ -338,7 +338,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
Lat: ${srcLat.toFixed(6)}<br>
|
||||
Lon: ${srcLon.toFixed(6)}<br>
|
||||
<span data-translate-lang="from_node">${packetTranslations.from_node || "From Node:"}</span> ${fromNodeLabel}<br>
|
||||
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${p.channel ?? "—"}<br>
|
||||
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${escapeHtml(p.channel ?? "—")}<br>
|
||||
<span data-translate-lang="port">${packetTranslations.port || "Port:"}</span> ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
|
||||
</div>
|
||||
`);
|
||||
@@ -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)
|
||||
<b>${label}</b><br>
|
||||
<span data-translate-lang="node_id_short">Node ID</span>:
|
||||
<a href="/node/${s.node_id}">${s.node_id}</a><br>
|
||||
HW: ${node?.hw_model ?? "—"}<br>
|
||||
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
|
||||
HW: ${escapeHtml(node?.hw_model ?? "—")}<br>
|
||||
<span data-translate-lang="channel">Channel</span>: ${escapeHtml(s.channel ?? "—")}<br>
|
||||
${
|
||||
distanceKm !== null
|
||||
? `<span data-translate-lang="distance">Distance</span>:
|
||||
@@ -500,7 +500,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
|
||||
<td>${s.rx_rssi ?? "—"}</td>
|
||||
<td>${s.rx_snr ?? "—"}</td>
|
||||
<td>${hopDisplay}</td>
|
||||
<td>${s.channel ?? "—"}</td>
|
||||
<td>${escapeHtml(s.channel ?? "—")}</td>
|
||||
<td>${timeStr}</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -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 => ({
|
||||
"&":"&",
|
||||
"<":"<",
|
||||
">":">",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.reach-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
@@ -258,6 +259,7 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
#packet_details {
|
||||
height: 95vh;
|
||||
overflow: auto;
|
||||
@@ -94,6 +95,7 @@
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
|
||||
@@ -194,11 +194,11 @@ async function renderTable() {
|
||||
<td>
|
||||
<a class="node-link" href="/node/${n.node_id}"
|
||||
onclick="event.stopPropagation()">
|
||||
${n.long_name || n.node_id}
|
||||
${escapeHtml(n.long_name || n.node_id)}
|
||||
</a>
|
||||
</td>
|
||||
<td>${n.short_name || ""}</td>
|
||||
<td>${n.channel || ""}</td>
|
||||
<td>${escapeHtml(n.short_name || "")}</td>
|
||||
<td>${escapeHtml(n.channel || "")}</td>
|
||||
<td>${n.sent}</td>
|
||||
<td>${n.seen}</td>
|
||||
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
#traceroute-graph {
|
||||
width: 100%;
|
||||
height: calc(100vh - 270px);
|
||||
@@ -62,6 +63,7 @@
|
||||
#traceroute-error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
+5
-2
@@ -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")
|
||||
|
||||
+48
-3
@@ -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),
|
||||
|
||||
@@ -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
|
||||
# -------------------------
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
|
||||
# -------------------------
|
||||
|
||||
Reference in new issue
Block a user