46 Commits
Author SHA1 Message Date
pablorevilla-meshtastic fd94477013 updatge readme 2026-10-23 14:27:06 -07:00
pablorevilla-meshtastic 5559c84a58 Apply Ruff formatting after develop merge 2026-10-23 14:23:40 -07:00
pablorevilla-meshtastic 9952426aee Merge branch 'develop' 2026-10-23 14:23:31 -07:00
pablorevilla-meshtastic df7766e783 change reporing on Snapshot 2026-10-19 17:37:30 -07:00
pablorevilla-meshtastic fb3a0154dd some updates 2026-10-15 22:49:55 -07:00
pablorevilla-meshtastic 012150cba9 update big 2026-10-14 10:12:11 -07:00
pablorevilla-meshtastic ef37a910b9 change packet reach activity 2026-10-14 10:00:17 -07:00
pablorevilla-meshtastic f4fbf16e4f uncheck packets reach 2026-10-14 08:46:00 -07:00
pablorevilla-meshtastic f9b684ee44 fix template 2026-10-14 07:36:27 -07:00
cd78c392e9 Bound /api/coverage parameters and run the ITM model off the event loop (#172)
* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Bound /api/coverage parameters and run the ITM model off the event loop

The coverage endpoint took radius_km, step_km and the other model inputs
straight from the query string with only a float() conversion. The ITM
loop advances by step_km until it passes radius_km, so step_km=0 (or a
negative value) never ends and radius_km=inf never ends either; a very
small step with a large radius means millions of model calls. All of this
ran synchronously inside the request handler, so one such request stalls
every other request the server is handling.

- Reject non-finite values and anything outside a sane range for each
  parameter (400 with a message saying the allowed range).
- Cap radius_km / step_km at 4000 steps.
- Run compute_perimeter / compute_coverage in a worker thread so the event
  loop keeps serving other requests while the model runs.
- Make the loops themselves refuse a non-positive step or non-finite radius,
  so a future caller cannot reintroduce the hang, and shrink the result
  cache (64 entries) since bounded inputs make large results possible.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-14 07:26:18 -07:00
96b49bb893 Keep the MQTT writer running when a message is malformed (#171)
* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Keep the MQTT writer running when a message is malformed

startdb.py stores every message it receives with mqtt_store.process_envelope,
and nothing caught an exception from it. The gateway id in a ServiceEnvelope
was converted with int(gateway_id[1:], 16) without checks, so a gateway id
of "x", "!zz" or a very long hex string raised ValueError/OverflowError,
the ingest task died and startdb.py exited. A NODEINFO whose id spelled an
existing node differently ("!00abcd" vs "!abcd") hit the UNIQUE constraint
on node.node_id at commit time with the same result. Anyone who can publish
to the broker can trigger these, and repeating them keeps the writer in a
restart loop so the site stops receiving packets.

- Parse gateway and user ids with one helper that returns None for anything
  that is not "!" + hex (or does not fit a 64-bit column) and skip the
  PacketSeen/Traceroute/Node update instead of raising.
- When a NODEINFO id is new but its node_id already exists, update that row
  instead of inserting a duplicate.
- Wrap the per-message store call in startdb.py so an unexpected error is
  logged and the loop moves on to the next message.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-14 07:26:03 -07:00
5900c41cf4 Escape mesh-provided strings before inserting them into the page (#170)
* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Escape mesh-provided strings before inserting them into the page

Node names, firmware strings, channel ids and message text all come from
the radio network, so anyone with a Meshtastic node (or publish access to
the MQTT broker) chooses their contents. Several pages dropped those
values straight into innerHTML / insertAdjacentHTML / Leaflet popups,
which lets a crafted node name run JavaScript in every visitor's browser.

Add a small shared helper (static/html-escape.js, loaded by base.html and
kiosk.html) and route every mesh-controlled value on the node list, top
nodes, firehose, node, packet and kiosk pages through it. Pages that
already had a local escapeHtml (chat, net, map, reliability) are unchanged.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-14 07:25:01 -07:00
5214b233c1 Keep /api/lang and the static page route inside their directories (#173)
* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Keep /api/lang and the static page route inside their directories

Two routes built a filename from request input without checking where it
ended up:

- /api/lang?lang=... joined the value onto the lang directory and added
  ".json". os.path.join discards the directory when the value is an
  absolute path, so ?lang=/some/where/file returned /some/where/file.json.
  Any .json file the server process can read was served.
- /{page} appended ".html" and read static/<page>. aiohttp passes an
  encoded slash (%2F) through a single path segment and unquotes it, so
  /..%2F..%2F..%2Fx read ../../../x.html relative to the static directory.

The lang route now only accepts a plain language code (en, es, pt-BR, ...)
and falls back to English otherwise, exactly as it already did for unknown
languages. The page route resolves the final path and requires it to be a
file inside meshview/static, the same check /docs/{doc} already does.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-14 07:22:37 -07:00
0109d68c1b Do not let payload search match redacted direct messages (#174)
* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* Do not let payload search match redacted direct messages

/api/packets shows the text of a direct message (TEXT_MESSAGE_APP to a
specific node) as "<redacted>". The contains= filter, however, is a LIKE
over the stored packet bytes, which include the decrypted text for
messages on a known channel key. With portnum left out of the request,
contains=<guess> returned the packet when the guess appeared in a redacted
message, so the text could be recovered one substring at a time.

Direct text messages are now excluded from the substring match. Broadcast
messages and all other packet types search exactly as before.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-14 07:21:05 -07:00
81612128a5 fix: limit the neighbor SNR chart to the last 7 days (#165)
* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

* fix: limit the neighbor SNR chart to the last 7 days

The chart asked for the last 500 neighbor-info packets and put no
limit on how old they could be. On a busy node that is several weeks
of history, and every neighbor heard in that span gets its own line.
On one node it was 119 packets covering 21.8 days and 339 lines.

The chart now asks for the last 7 days, using the `since` parameter
that /api/packets already supports, and draws only the 15 neighbors
with the most data points in that window. A subtitle says how many
neighbors are shown and how many there were, so a missing line is not
a mystery.

Three smaller fixes in the same function:

- Neighbor names are looked up only for the lines that are drawn.
  fetchNodeFromApi() does not cache a node it cannot find, so every
  unknown neighbor was fetched again on every packet it appeared in.
- Each point is read from a Map instead of calling indexOf() on the
  timestamp list, which was quadratic in the number of points.
- Two neighbors sharing a short name are told apart. ECharts
  identifies a series by its name, so they were drawn as one line.

The "Export CSV" button for this chart wrote an empty file, because
neighborData was declared but never filled in. It is now filled in
from the neighbors on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:37:37 -07:00
pablorevilla-meshtastic bb8da4cc13 update 2026-09-10 15:39:55 -07:00
pablorevilla-meshtastic 325e685209 update 2026-09-09 20:41:24 -07:00
pablorevilla-meshtastic 0850c433f2 update 2026-09-09 20:36:02 -07:00
pablorevilla-meshtastic 28651cad7b update 2026-09-09 20:33:42 -07:00
pablorevilla-meshtastic 4df2072a72 update 2026-09-09 20:20:32 -07:00
pablorevilla-meshtastic 3c66597b65 updte 2026-09-09 20:13:15 -07:00
pablorevilla-meshtastic 64486dabc8 update 2026-09-09 20:10:19 -07:00
pablorevilla-meshtastic 9d3bd891b7 update 2026-09-09 19:58:19 -07:00
pablorevilla-meshtastic 03943c463e update 2026-09-09 19:41:42 -07:00
pablorevilla-meshtastic 849ab69566 update 2026-09-09 18:48:51 -07:00
pablorevilla-meshtastic c1d21d1ce1 update 2026-09-09 18:41:17 -07:00
pablorevilla-meshtastic 4329ccc0f3 update 2026-09-09 18:35:15 -07:00
pablorevilla-meshtastic 116a1c30b3 update 2026-09-09 18:32:23 -07:00
pablorevilla-meshtastic 295f0f086e update 2026-09-09 18:09:33 -07:00
pablorevilla-meshtastic 4988b887c7 update 2026-09-09 17:40:19 -07:00
pablorevilla-meshtastic 40ed330523 update 2026-09-09 17:26:13 -07:00
pablorevilla-meshtastic 728072dfac update 2026-09-09 17:23:37 -07:00
pablorevilla-meshtastic 4d036299f0 update 2026-09-09 16:40:22 -07:00
pablorevilla-meshtastic 0657899a6f create heard by packet trace 2026-09-09 16:33:46 -07:00
Pablo Revilla ad1f404f4a Update CONTRIBUTING.md 2026-09-09 13:41:43 -07:00
Pablo Revilla 424998090d Update CONTRIBUTING.md 2026-09-09 13:32:22 -07:00
Pablo Revilla b3b95dbb81 Update CONTRIBUTING.md 2026-09-09 13:30:24 -07:00
pablorevilla-meshtastic 88fe764e7b updates 2026-09-09 13:19:26 -07:00
pablorevilla-meshtastic 87344d4902 Merge branch 'develop' of github.com:pablorevilla-meshtastic/meshview into develop 2026-09-09 13:19:11 -07:00
pablorevilla-meshtastic dc7ddb5bf3 updates 2026-09-09 13:19:08 -07:00
pablorevilla-meshtastic d1e1e1a91f update 2026-09-09 12:42:37 -07:00
pablorevilla-meshtastic 81535c08b1 update 2026-09-09 12:41:27 -07:00
pablorevilla-meshtastic 41dbd12ed2 update changes 2026-09-09 12:32:55 -07:00
pablorevilla-meshtastic d78de2fd0b Latest updates summary 2026-09-09 12:30:28 -07:00
pablorevilla-meshtastic 85753d8704 Merge branch '3.0.8' 2026-09-09 12:27:04 -07:00
Manuel Villaverde e9e66b2dfb fix: make non-preset channel colors visible on the map (#164)
The fallback palette used for channels that are not Meshtastic presets was
built out of pastels, which are unreadable against the light OSM tiles. Six
of the nine entries scored under 1.6:1 contrast on the map: #fffac8 at
1.06:1 and #aaffc3 at 1.18:1 were effectively invisible, leaving only the
white marker outline showing.

The underlying issue is that one color feeds two opposite backgrounds. The
marker fill sits on light map tiles, while the filter label is text on the
dark Bootstrap panel. The old palette was calibrated only for the panel.

Replace it with 12 colors chosen by a search over HSL, constrained to clear
3:1 contrast against both backgrounds, stay at deltaE >= 30 from every
channelColorOverrides preset, and deltaE >= 20.9 from each other. Saturation
is capped at 80% to keep out neon tones that would collide with ShortTurbo.

Growing the palette from 9 to 12 entries is safe: the index is derived from
palette.length rather than a fixed number.
2026-09-09 11:21:41 -07:00
29 changed files with 609 additions and 156 deletions

No files matched your search

+6 -9
View File
@@ -79,8 +79,6 @@ How to report:
- Use a **clear and descriptive title**.
- Include reproduction steps and expected vs. actual behavior.
⚠️ Security issues should **not** be reported in public issues. Instead, email us at **meshview-maintainers@proton.me**.
---
### Suggesting Enhancements
@@ -103,12 +101,11 @@ When submitting:
We love first-time contributors! 🚀
If you’d like to start coding:
1. Look for issues tagged with [good first issue](../../issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
2. Fork the repository and clone it locally.
3. Set up the development environment:
4. Run the app locally
5. Create a new branch, make your changes, commit, and push.
6. Open a pull request!
1. Fork the repository and clone it locally.
2. Set up the development environment:
3. Run the app locally
4. Create a new branch from `develop`, make your changes, commit, and push.
5. Open a pull request against the `develop` branch.
---
@@ -130,4 +127,4 @@ Start by contributing regularly, engaging in issues/PRs, and helping others.
---
✨ That’s it! Thanks again for being part of Meshview. Every contribution matters.
✨ That’s it! Thanks again for being part of Meshview. Every contribution matters.
+20 -1
View File
@@ -4,6 +4,25 @@
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
### Version 3.0.9 — October 2026
- Live map: added animated packet traces from senders to reporting gateways and configurable channel colors through the optional `[map_channel_colors]` section in `config.ini`.
- Daily snapshots: new snapshots report activity for the previous complete UTC day, including newly imported packets, unique packet senders observed that day, and gateways that reported packets that day. Existing historical snapshots keep their original counts.
- Node tracking: record nodes from packet activity before their node information arrives, and preserve known positions when map reports have missing or invalid coordinates.
- Neighbor SNR chart: show the last seven days and the 15 neighbors with the most data points, distinguish neighbors with identical short names, and fix CSV export.
- MQTT reliability: skip malformed messages and invalid node IDs without stopping packet ingestion.
- Security and privacy: escape mesh-provided text in web pages, restrict language and static file requests to their intended directories, and prevent payload searches from matching redacted direct messages.
- Coverage API: validate and bound request parameters, and run coverage calculations off the event loop to keep the web server responsive.
### Version 3.0.8 — September 2026
- Map updates: added OpenTopoMap as a selectable base map, improved map controls, and fixed several map/node display edge cases.
- Traceroute: added a dedicated traceroute page, corrected route path orientation in graph edges, and improved traceroute API/documentation support.
- Routing display: show ROUTING ACK/NAK packets clearly instead of reporting misleading `error_reason: NONE` values.
- MQTT/decryption: expanded Meshtastic one-byte PSK shorthand support in `mqtt.secondary_keys`.
- Health/maintenance: added cleanup status reporting to the health page and fixed timezone handling in health reporting.
- UI/API fixes: improved Firehose, Node, and Top channel handling, including showing all channels where expected.
- Contributors: thanks to [giannoug](https://github.com/giannoug) for fixes to ROUTING ACK/NAK display, `mqtt.secondary_keys` PSK shorthand handling, and traceroute path orientation, plus jschrempp and the Meshview community for testing and feedback included in this release.
### Version 3.0.7 — May 2026
- Reliability report: added a dedicated reliability page for checking how well a selected node is heard across the mesh, including heard/missed status, packet counts, hop counts, gateway tables, and a map view.
- Node list/map UX: added node list filtering for wider active-node ranges, included all database nodes where appropriate, and made map node colors persistent across reboots.
@@ -13,7 +32,7 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
- Tooling: improved the protobuf updater so it supports raw commit SHAs, the newer upstream `meshtastic/*.proto` layout, generated `.pyi` stubs, and the vendored `nanopb`/`serial_hal` outputs.
### 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.
- Daily snapshots: added the `daily_snapshot` table, scheduled snapshot capture, and `/api/snapshots/daily` for historical node, packet, and gateway counts. These originally stored database totals; version 3.0.9 changes new snapshots to daily activity counts.
- 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.
+28
View File
@@ -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
{
+1 -1
View File
@@ -3,7 +3,7 @@
import subprocess
from pathlib import Path
__version__ = "3.0.8"
__version__ = "3.0.9"
__release_date__ = "2026-5-12"
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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,
)
)
+9 -4
View File
@@ -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 = []
+10
View File
@@ -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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
window.escapeHtml = function escapeHtml(value) {
if (value === null || value === undefined) return "";
return String(value).replace(/[&<>"']/g, ch => MAP[ch]);
};
})();
+8 -7
View File
@@ -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)){
+11
View File
@@ -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:
+3 -1
View File
@@ -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>
+2
View File
@@ -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 %}
+7 -9
View File
@@ -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, "&quot;")
: "";
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, "&lt;")
.replace(/>/g, "&gt;");
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>
+167 -27
View File
@@ -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,29 +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;
const palette = ["#bcf60c","#fabebe","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#ffd8b1",
"#808080"];
// 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();
@@ -276,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;
@@ -392,6 +420,7 @@ function fetchNewPackets(){
} else {
addUnmappedPacket(pkt, nodeData);
}
schedulePacketSeenNodes(pkt);
});
lastImportTime = latest;
@@ -427,6 +456,7 @@ map.on("overlayadd", e => {
map.on("overlayremove", e => {
if(e.layer === nodeLayer) clearActiveBlinks();
if(e.layer === packetSeenLayer) clearPacketSeenHighlights();
});
async function waitForConfig() {
@@ -435,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);
@@ -483,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=>({
@@ -750,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 });
@@ -766,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;
@@ -773,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();
@@ -796,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 });
}
/* ======================================================
+2
View File
@@ -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 %}
+98 -38
View File
@@ -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 === "<" ? "&lt;" : "&gt;"));
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" }
},
+2
View File
@@ -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 %}
+12 -12
View File
@@ -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;">
+9 -9
View File
@@ -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 => ({
"&":"&amp;",
"<":"&lt;",
">":"&gt;",
+2
View File
@@ -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 %}
+2
View File
@@ -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 %}
+3 -3
View File
@@ -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>
+2
View File
@@ -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
View File
@@ -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")
+47 -3
View File
@@ -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,11 @@ 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 +803,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 +1247,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 +1273,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 +1286,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 +1306,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),
+13
View File
@@ -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
View File
@@ -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)
# -------------------------