* fix(node): read telemetry fields from their block
The telemetry charts matched fields with unanchored regexes over the
whole payload, so "voltage:" also matched "ch1_voltage:" (power_metrics)
and environment_metrics voltage, plotting them as the device voltage.
The same applied to "temperature:" and other shared field names.
Parse each value only inside its own metrics block (device_metrics or
environment_metrics), match the exact field name and accept negative
values (e.g. temperatures below zero).
* feat(node): add power metrics chart
Plot power_metrics telemetry (ch1..ch8 voltage and current) on the node
page. Each channel that reported data gets a voltage series on the left
axis (V) and a dashed current series on the right axis (mA).
The legacy voltage/current fields of environment_metrics, reported by INA
sensors and to be deprecated in favor of PowerMetrics, are shown in the
same chart as "Environment" series.
The chart is hidden when the node sends no power data and supports the
usual expand and CSV export actions. Translations added for en, es, ru.
leaflet.heat 1.0.0 was published to npm on 2026-11-03 and is ESM-only
(it starts with `import * as e from "leaflet"`). The pages loaded it
unversioned from unpkg as a classic <script>, so unpkg started serving
1.0.0. That throws "Cannot use import statement outside a module", which
leaves L.heatLayer undefined. map-view.html calls L.heatLayer at the top
level before the map gets a view, so the live map is a blank grey box,
and the heatmap page fails to load nodes.
Pin 0.2.0, the release these pages were written against, and add an
SRI hash so a future upstream change can't break or alter the pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep one malformed packet from breaking API endpoints or node updates
Values in a packet come from the mesh, so a forged packet or a newer firmware can carry
data the code did not expect:
- Routing.error_reason with a number the bundled protobufs do not define made
Routing.Error.Name() raise, and /api/packets returned 500 for every list that included
that packet.
- An unknown DeviceConfig.Role or HardwareModel in MAP_REPORT or NODEINFO raised the same
way, and the whole node update was dropped.
- rx_snr of +inf was stored and served as "Infinity", which is not valid JSON, so the
packet page's "seen by" list failed to load.
- A NEIGHBORINFO packet whose payload does not parse made /api/edges return 500 while it
was among the 50 most recent.
Add decode_payload.enum_name(), which returns "unknown(N)" for undefined values, and use it
at all five enum lookups. Store non-finite SNR as NULL and serve existing ones as null.
Skip NEIGHBORINFO packets that do not decode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three places still put mesh-provided strings into HTML unescaped:
- stats.html: the pie-chart tooltip formatter returned params.name raw. The charts cover
channel, hardware model, role and gateway firmware, so a channel or firmware string ran
as HTML when someone hovered its slice.
- traceroute.html: the node tooltip formatter returned the long name raw.
- packet.html: "PDOP", "sats_in_view", "ground_speed" and "altitude" values parsed from
the payload text went into the card unescaped. A broadcast text message reading
"PDOP: <img ...>" is enough.
Use the existing escapeHtml() from static/html-escape.js in all three.
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
/kiosk builds every marker in one forEach, so a single node with unexpected data stops
the whole page:
- node.role.toLowerCase() throws when role is null. Nodes first heard through a POSITION
packet are created by ensure_node_exists() without a role, so on a fresh database the
kiosk draws no nodes at all once one of them has a position.
- markers is a plain object keyed by channel name. A node on a channel called "__proto__",
"constructor" or "toString" finds an existing non-array value there, and .push() throws.
Either error aborts the loop, and the channel filters and live packet blinking after it
never start. Treat a missing role as "not a router" and keep markers in a Map.
/stats counted values per field in a plain object too, so a channel called "constructor"
was counted as the string "function Object() { [native code] }1" and "__proto__" was lost.
Count in a Map there as well.
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The four "Export CSV" buttons each built CSV by hand, from values that come from the mesh:
- /nodelist used a data: URL through encodeURI(), which leaves "#" alone, so the file
ended at the first "#" in any cell. Real node names contain one ("Big Rock Ridge #1").
- The node page's chart export (neighbors, telemetry) did not quote cells at all.
- /stats quoted cells but did not double quotes inside them, so a channel such as
Mesh "West", Bay split into extra columns.
- None of them guarded against spreadsheet formulas. A node name, channel, firmware string
or message that starts with = + - or @ opens as a live formula in Excel or LibreOffice.
Add static/csv.js (loaded from base.html, like html-escape.js) with csvCell(), which
quotes every cell, doubles inner quotes and prefixes formula-like text with ', leaving
plain numbers alone, and csvFromRows(). All four exports use it; /nodelist now downloads a
Blob like the others.
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Answer 400, not 500, for malformed or out-of-range numeric parameters
Many endpoints turned query or path values into integers with a bare int(), so
?length=abc on /api/channels or /api/stats/top, /api/traceroute/abc and
/graph/traceroute/abc answered with an unhandled exception and a 500.
Values that do parse could still fail: node and packet ids are BIGINT and portnum is
INTEGER, and a longer number made the database driver raise, which also answered 500
(/api/packets?node_id=99999999999999999999999, /api/packets_seen/<same>, the node QR and
coverage routes, /api/stats and /api/stats/count filters). On PostgreSQL that includes
any portnum outside 32 bits.
length had no upper bound, so a huge value overflowed timedelta (500) or widened the
aggregate to the whole table. On SQLite a negative limit means "no limit", so
/api/stats/top?limit=-1 returned every node and skipped its 100-row cap.
Add parse_db_int(), which also rejects values outside the column's range, plus two small
helpers that answer 400 with a message. Clamp length to 1..3650, limit to 1..100 and
offset to >= 0. Endpoints that already ignored an unparseable filter (the /api/packets
node, portnum and time filters) treat an out-of-range one the same way. Valid requests are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Log request values and MQTT topics with %r so they cannot add log lines
/api/packets logged an unparseable since, portnum or node id filter with an f-string, so
the decoded query value went into the log as is. ?since=1%0A<fake line> wrote a second,
forged log line, and escape sequences (ESC, BEL) reached whoever reads the log in a
terminal. The ingest error added in startdb.py logged the MQTT topic with %s.
Use %r for these values, as the gateway_id warning in mqtt_store.py already does, so CR,
LF, ESC and other control characters appear escaped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Bound the traceroute data a request has to draw
Every TRACEROUTE message is stored as a new row under its packet id, and its route can list
any number of hops. /graph/traceroute decodes every row, looks up every node on every route
and runs Graphviz over the result inside the request handler, so a publisher can make one
page view take a very long time and stall every other request meanwhile:
- 3,000 rows of 8 random hops for one packet id: the graph took about 43 s and other
requests waited up to 24 s.
- 150 rows of 400 hops: about 40 s, then a 500, with other requests waiting about 10 s.
Real traceroutes are small. In 17 days of bay-area traffic no packet id had more than 72
rows, and none of 66,709 routes had more than 8 hops in either direction, which is the
firmware's limit.
get_traceroute() now returns at most the 200 earliest rows, and the TRACEROUTE decoder
treats a route longer than 8 hops as undecodable, which every caller already skips.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Include the node's public key in the contact QR code #184
The contact QR code and import URL never set user.public_key. Current
Meshtastic apps refuse to import a keyless contact. On 2.7.x firmware,
an older app's add_contact also overwrites the key the radio already
holds, so direct messages to that node fail.
Read the key from the node's latest stored NODEINFO_APP packet. Share
the contact only when a 32-byte key is known. Otherwise the node page
hides the QR buttons and says why. The QR API response now also
returns public_key.
Also drop the stray blank line that fails ruff format --check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Do not share a contact when a node has reported more than one key #184
A forged NODEINFO uplinked over MQTT could otherwise become the newest
packet, and the shared contact would then replace the key a scanning
radio already trusts. The QR endpoint now reads every stored NODEINFO
from the node and refuses to share when their 32-byte keys disagree.
It reports that in public_key_conflict, and the node page says why.
Add an index on packet (from_node_id, portnum, import_time_us DESC) so
the lookup reads only the node's NODEINFO packets. Order with NULLS
LAST so PostgreSQL does not treat old rows without import_time_us as
newest. Carry is_licensed and is_unmessagable into the contact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Skip NODEINFO rows with no payload when building the contact #184
packet.payload is nullable, and MeshPacket.FromString(None) raises
TypeError, so one such row made the QR endpoint answer 500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>