From dc7ddb5bf3ea073346cf8d6f3dcd4e371c2d5392 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 12:42:37 -0700 Subject: [PATCH 01/34] updates --- CONTRIBUTING.md | 144 +++++++++--------------------------------------- README.md | 2 +- 2 files changed, 28 insertions(+), 118 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dafea5..20a0149 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,133 +1,43 @@ # Contributing to Meshview -First off, thanks for taking the time to contribute! ❤️ -All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for ways to help and details about how this project handles contributions. Please read the relevant section before getting started — it will make things smoother for both you and the maintainers. +Thanks for helping improve Meshview. Bug reports, fixes, documentation updates, and feature ideas are welcome. -The Meshview community looks forward to your contributions. 🎉 +## Be Respectful -> And if you like the project but don’t have time to contribute code, that’s fine! You can still support Meshview by: -> - ⭐ Starring the repo on GitHub -> - Talking about Meshview on social media -> - Referencing Meshview in your own project’s README -> - Mentioning Meshview at local meetups or to colleagues/friends +Keep discussion constructive and focused on the project. Harassment, personal attacks, hate speech, or sharing private information are not acceptable. ---- +## Questions and Issues -## Table of Contents +Before opening a new issue, please search the existing issues first. -- [Code of Conduct](#code-of-conduct) -- [I Have a Question](#i-have-a-question) -- [I Want to Contribute](#i-want-to-contribute) - - [Reporting Bugs](#reporting-bugs) - - [Suggesting Enhancements](#suggesting-enhancements) - - [Your First Code Contribution](#your-first-code-contribution) - - [Improving the Documentation](#improving-the-documentation) -- [Styleguides](#styleguides) - - [Commit Messages](#commit-messages) -- [Join the Project Team](#join-the-project-team) +For bugs, include: +- what you expected to happen +- what actually happened +- steps to reproduce +- OS, Python version, database type, and useful logs ---- +For feature requests, describe the use case and why it helps Meshview users. -## Code of Conduct +Security issues should not be reported in public issues. Email them to **meshview-maintainers@proton.me**. -Meshview is an open and welcoming community. We want everyone to feel safe, respected, and valued. +## Pull Requests -### Our Standards -- Be respectful and considerate in all interactions. -- Welcome new contributors and help them learn. -- Provide constructive feedback, not personal attacks. -- Focus on collaboration and what benefits the community. +Use the `develop` branch for development work. -Unacceptable behavior includes harassment, insults, hate speech, personal attacks, or publishing others’ private information without permission. +1. Fork and clone the repository. +2. Create your branch from `develop`. +3. Make your changes. +4. Test the app locally. +5. Commit and push your branch. +6. Open a pull request against `develop`. ---- +For Python changes, run: -## I Have a Question +```bash +./env/bin/ruff check . +./env/bin/ruff format . +``` -> Before asking, please read the [documentation](docs/README.md) if available. +## Contribution Terms -1. Search the [issues list](../../issues) to see if your question has already been asked. -2. If not, open a [new issue](../../issues/new) with the **question** label. -3. Provide as much context as possible (OS, Python version, database type, etc.). - ---- - -## I Want to Contribute! - -### Legal Notice -By contributing to Meshview, you agree that: -- You authored the content yourself. -- You have the necessary rights to the content. -- Your contribution can be provided under the project’s license. - ---- - -### Reporting Bugs - -Before submitting a bug report: -- Make sure you’re using the latest Meshview version. -- Verify the issue is not due to a misconfigured environment (SQLite/MySQL, Python version, etc.). -- Search existing [bug reports](../../issues?q=label%3Abug). -- Collect relevant information: - - Steps to reproduce - - Error messages / stack traces - - OS, Python version, and database backend - - Any logs (`meshview-db.service`, `mqtt_reader.py`, etc.) - -How to report: -- Open a [new issue](../../issues/new). -- 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 - -Enhancements are tracked as [issues](../../issues). Before suggesting: -- Make sure the feature doesn’t already exist. -- Search for prior suggestions. -- Check that it fits Meshview’s scope (mesh packet analysis, visualization, telemetry, etc.). - -When submitting: -- Use a **clear and descriptive title**. -- Describe the current behavior and what you’d like to see instead. -- Include examples, screenshots, or mockups if relevant. -- Explain why it would be useful to most Meshview users. - ---- - -### Your First Code Contribution - -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! - ---- - -### Improving the Documentation - -Docs are just as important as code. You can help by: -- Fixing typos or broken links. -- Clarifying confusing instructions. -- Adding examples (e.g., setting up Nginx as a reverse proxy, SQLite vs. MySQL setup). -- Writing or updating tutorials. - ---- - -## Join the Project Team - -Meshview is a community-driven project. If you consistently contribute (code, documentation, or community help), we’d love to invite you as a maintainer. - -Start by contributing regularly, engaging in issues/PRs, and helping others. - ---- - -✨ That’s it! Thanks again for being part of Meshview. Every contribution matters. \ No newline at end of file +By contributing, you confirm that you wrote the contribution or have the right to submit it, and that it can be provided under this project's license. diff --git a/README.md b/README.md index 64edcca..df01437 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas - 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 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. +- 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. From 88fe764e7bd5fa17d383de3ef146d3fc3a05c0ed Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 13:19:26 -0700 Subject: [PATCH 02/34] updates --- CONTRIBUTING.md | 142 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 27 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20a0149..51fea94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,43 +1,131 @@ # Contributing to Meshview -Thanks for helping improve Meshview. Bug reports, fixes, documentation updates, and feature ideas are welcome. +First off, thanks for taking the time to contribute! ❤️ +All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for ways to help and details about how this project handles contributions. Please read the relevant section before getting started — it will make things smoother for both you and the maintainers. -## Be Respectful +The Meshview community looks forward to your contributions. 🎉 -Keep discussion constructive and focused on the project. Harassment, personal attacks, hate speech, or sharing private information are not acceptable. +> And if you like the project but don’t have time to contribute code, that’s fine! You can still support Meshview by: +> - ⭐ Starring the repo on GitHub +> - Talking about Meshview on social media +> - Referencing Meshview in your own project’s README +> - Mentioning Meshview at local meetups or to colleagues/friends -## Questions and Issues +--- -Before opening a new issue, please search the existing issues first. +## Table of Contents -For bugs, include: -- what you expected to happen -- what actually happened -- steps to reproduce -- OS, Python version, database type, and useful logs +- [Code of Conduct](#code-of-conduct) +- [I Have a Question](#i-have-a-question) +- [I Want to Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) + - [Improving the Documentation](#improving-the-documentation) +- [Styleguides](#styleguides) + - [Commit Messages](#commit-messages) +- [Join the Project Team](#join-the-project-team) -For feature requests, describe the use case and why it helps Meshview users. +--- -Security issues should not be reported in public issues. Email them to **meshview-maintainers@proton.me**. +## Code of Conduct -## Pull Requests +Meshview is an open and welcoming community. We want everyone to feel safe, respected, and valued. -Use the `develop` branch for development work. +### Our Standards +- Be respectful and considerate in all interactions. +- Welcome new contributors and help them learn. +- Provide constructive feedback, not personal attacks. +- Focus on collaboration and what benefits the community. -1. Fork and clone the repository. -2. Create your branch from `develop`. -3. Make your changes. -4. Test the app locally. -5. Commit and push your branch. -6. Open a pull request against `develop`. +Unacceptable behavior includes harassment, insults, hate speech, personal attacks, or publishing others’ private information without permission. -For Python changes, run: +--- -```bash -./env/bin/ruff check . -./env/bin/ruff format . -``` +## I Have a Question -## Contribution Terms +> Before asking, please read the [documentation](docs/README.md) if available. -By contributing, you confirm that you wrote the contribution or have the right to submit it, and that it can be provided under this project's license. +1. Search the [issues list](../../issues) to see if your question has already been asked. +2. If not, open a [new issue](../../issues/new) with the **question** label. +3. Provide as much context as possible (OS, Python version, database type, etc.). + +--- + +## I Want to Contribute! + +### Legal Notice +By contributing to Meshview, you agree that: +- You authored the content yourself. +- You have the necessary rights to the content. +- Your contribution can be provided under the project’s license. + +--- + +### Reporting Bugs + +Before submitting a bug report: +- Make sure you’re using the latest Meshview version. +- Verify the issue is not due to a misconfigured environment (SQLite/MySQL, Python version, etc.). +- Search existing [bug reports](../../issues?q=label%3Abug). +- Collect relevant information: + - Steps to reproduce + - Error messages / stack traces + - OS, Python version, and database backend + - Any logs (`meshview-db.service`, `mqtt_reader.py`, etc.) + +How to report: +- Open a [new issue](../../issues/new). +- Use a **clear and descriptive title**. +- Include reproduction steps and expected vs. actual behavior. + +--- + +### Suggesting Enhancements + +Enhancements are tracked as [issues](../../issues). Before suggesting: +- Make sure the feature doesn’t already exist. +- Search for prior suggestions. +- Check that it fits Meshview’s scope (mesh packet analysis, visualization, telemetry, etc.). + +When submitting: +- Use a **clear and descriptive title**. +- Describe the current behavior and what you’d like to see instead. +- Include examples, screenshots, or mockups if relevant. +- Explain why it would be useful to most Meshview users. + +--- + +### Your First Code Contribution + +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 from `develop`, make your changes, commit, and push. +6. Open a pull request against the `develop` branch. + +--- + +### Improving the Documentation + +Docs are just as important as code. You can help by: +- Fixing typos or broken links. +- Clarifying confusing instructions. +- Adding examples (e.g., setting up Nginx as a reverse proxy, SQLite vs. MySQL setup). +- Writing or updating tutorials. + +--- + +## Join the Project Team + +Meshview is a community-driven project. If you consistently contribute (code, documentation, or community help), we’d love to invite you as a maintainer. + +Start by contributing regularly, engaging in issues/PRs, and helping others. + +--- + +✨ That’s it! Thanks again for being part of Meshview. Every contribution matters. From ad1f404f4a0d5ec44e0252b3b2dfe7baa305d3d2 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Wed, 9 Sep 2026 13:41:43 -0700 Subject: [PATCH 03/34] Update CONTRIBUTING.md --- CONTRIBUTING.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51fea94..b43e614 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,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 from `develop`, make your changes, commit, and push. -6. Open a pull request against the `develop` branch. +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. --- From 0657899a6fc57cdb4a0d35505cc4a5c7ffe1df6a Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 16:33:46 -0700 Subject: [PATCH 04/34] create heard by packet trace --- meshview/__version__.py | 2 +- meshview/mqtt_store.py | 66 +++++++++++++++++++++++++++++++------ meshview/templates/map.html | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/meshview/__version__.py b/meshview/__version__.py index 50d30b1..c2f2fda 100644 --- a/meshview/__version__.py +++ b/meshview/__version__.py @@ -3,7 +3,7 @@ import subprocess from pathlib import Path -__version__ = "3.0.8" +__version__ = "3.0.9" __release_date__ = "2026-5-12" diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 68acd8e..fc130f5 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -22,6 +22,18 @@ NODE_NAME_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") MQTT_DIRECT_NODE_ID = 1 +def has_valid_location(lat: int | None, lon: int | None) -> bool: + return lat not in (None, 0) and lon not in (None, 0) + + +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: name = name.strip() if not name or NODE_NAME_CONTROL_CHARS.search(name): @@ -47,6 +59,35 @@ 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() @@ -130,6 +171,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 +181,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 +198,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 +216,9 @@ 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) + await ensure_node_exists(session, from_node_id, env.channel_id) + result = await session.execute(select(Packet).where(Packet.id == packet_id)) packet = result.scalar_one_or_none() if not packet: @@ -179,7 +226,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, @@ -332,11 +379,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 diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 4844f4c..bf7e07a 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -37,6 +37,16 @@ animation: textPulse 1.1s ease-in-out 6; border-color: #ff8c00; } + .packet-seen-trace { + stroke-dasharray: 34 999; + stroke-linecap: round; + filter: drop-shadow(0 0 4px rgba(0, 183, 255, 0.85)); + animation: packetTrace 6.5s cubic-bezier(0.25, 0.05, 0.15, 1) 1 forwards; + } + @keyframes packetTrace { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -1000; } + } @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); } @@ -207,6 +217,7 @@ L.control.scale({ }).addTo(map); var nodeLayer = L.layerGroup().addTo(map); var edgeLayer = L.layerGroup().addTo(map); +var packetSeenLayer = L.layerGroup().addTo(map); var trafficHeatLayer = L.heatLayer([], { radius: 34, blur: 18, @@ -221,6 +232,7 @@ var trafficHeatLayer = L.heatLayer([], { }); var overlayLayers = { "Nodes": nodeLayer, + "Packet Heard By": packetSeenLayer, "Traffic HeatMap": trafficHeatLayer }; L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map); @@ -229,6 +241,7 @@ 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; @@ -395,6 +408,7 @@ function fetchNewPackets(){ } else { addUnmappedPacket(pkt, nodeData); } + showPacketSeenNodes(pkt); }); lastImportTime = latest; @@ -430,6 +444,7 @@ map.on("overlayadd", e => { map.on("overlayremove", e => { if(e.layer === nodeLayer) clearActiveBlinks(); + if(e.layer === packetSeenLayer) clearPacketSeenHighlights(); }); async function waitForConfig() { @@ -769,6 +784,55 @@ function clearActiveBlinks(){ activeBlinks.clear(); } +function clearPacketSeenHighlights(){ + activePacketSeenHighlights.forEach(timeout => clearTimeout(timeout)); + activePacketSeenHighlights = []; + packetSeenLayer.clearLayers(); +} + +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: "#26d9ff", + weight: 6, + opacity: 0.9, + className: "packet-seen-trace", + interactive: false + }).addTo(packetSeenLayer); + + const timeout = setTimeout(() => { + packetSeenLayer.removeLayer(trace); + }, 6700); + 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; From 4d036299f00d8011fa9739a37ab0e8539b2a8e3d Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 16:40:22 -0700 Subject: [PATCH 05/34] update --- meshview/templates/map.html | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index bf7e07a..b1aa75b 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -248,6 +248,7 @@ var trafficHeatLoaded = false; var trafficHeatLoading = false; const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; +const PACKET_SEEN_DELAY_MS = 3000; const portMap = window.PORT_LABEL_MAP; @@ -408,7 +409,7 @@ function fetchNewPackets(){ } else { addUnmappedPacket(pkt, nodeData); } - showPacketSeenNodes(pkt); + schedulePacketSeenNodes(pkt); }); lastImportTime = latest; @@ -790,6 +791,13 @@ function clearPacketSeenHighlights(){ packetSeenLayer.clearLayers(); } +function schedulePacketSeenNodes(pkt){ + if(!map.hasLayer(packetSeenLayer) || pkt.id == null) 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; From 728072dfac77a34e1706846b3508c9b78f66284d Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 17:23:37 -0700 Subject: [PATCH 06/34] update --- meshview/templates/map.html | 44 ++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index b1aa75b..58fe278 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -38,15 +38,32 @@ border-color: #ff8c00; } .packet-seen-trace { - stroke-dasharray: 34 999; + stroke-dasharray: 1 999; stroke-linecap: round; filter: drop-shadow(0 0 4px rgba(0, 183, 255, 0.85)); animation: packetTrace 6.5s cubic-bezier(0.25, 0.05, 0.15, 1) 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: -1000; } } + @keyframes packetSeenLanding { + from { + stroke-opacity: 0.95; + fill-opacity: 0.35; + transform: scale(0.2); + } + to { + stroke-opacity: 0; + fill-opacity: 0; + transform: scale(1.8); + } + } @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); } @@ -248,7 +265,9 @@ var trafficHeatLoaded = false; var trafficHeatLoading = false; const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; -const PACKET_SEEN_DELAY_MS = 3000; +const PACKET_SEEN_DELAY_MS = 5000; +const PACKET_TRACE_DURATION_MS = 6500; +const PACKET_LANDING_DURATION_MS = 1000; const portMap = window.PORT_LABEL_MAP; @@ -831,9 +850,28 @@ async function showPacketSeenNodes(pkt){ interactive: false }).addTo(packetSeenLayer); + const landingTimeout = setTimeout(() => { + const landing = L.circleMarker([lat, lng], { + radius: 14, + color: "#26d9ff", + fillColor: "#26d9ff", + fillOpacity: 0.35, + opacity: 0.95, + weight: 2, + className: "packet-seen-landing", + interactive: false + }).addTo(packetSeenLayer); + + const clearLandingTimeout = setTimeout(() => { + packetSeenLayer.removeLayer(landing); + }, PACKET_LANDING_DURATION_MS); + activePacketSeenHighlights.push(clearLandingTimeout); + }, PACKET_TRACE_DURATION_MS); + activePacketSeenHighlights.push(landingTimeout); + const timeout = setTimeout(() => { packetSeenLayer.removeLayer(trace); - }, 6700); + }, PACKET_TRACE_DURATION_MS + PACKET_LANDING_DURATION_MS); activePacketSeenHighlights.push(timeout); }); } catch(err) { From 40ed330523b4b193b90bd85f574c1c51b536a8db Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 17:26:13 -0700 Subject: [PATCH 07/34] update --- meshview/templates/map.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 58fe278..a97d95e 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -40,7 +40,7 @@ .packet-seen-trace { stroke-dasharray: 1 999; stroke-linecap: round; - filter: drop-shadow(0 0 4px rgba(0, 183, 255, 0.85)); + filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.65)); animation: packetTrace 6.5s cubic-bezier(0.25, 0.05, 0.15, 1) 1 forwards; } .packet-seen-landing { @@ -843,7 +843,7 @@ async function showPacketSeenNodes(pkt){ const lat = markerLatLng?.lat ?? node.lat; const lng = markerLatLng?.lng ?? node.long; const trace = L.polyline([[sourceLatLng.lat, sourceLatLng.lng], [lat, lng]], { - color: "#26d9ff", + color: "#000", weight: 6, opacity: 0.9, className: "packet-seen-trace", @@ -853,8 +853,8 @@ async function showPacketSeenNodes(pkt){ const landingTimeout = setTimeout(() => { const landing = L.circleMarker([lat, lng], { radius: 14, - color: "#26d9ff", - fillColor: "#26d9ff", + color: "#000", + fillColor: "#000", fillOpacity: 0.35, opacity: 0.95, weight: 2, From 4988b887c7a0701c18f1c04ebcbfb5ab97d1610c Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 17:40:19 -0700 Subject: [PATCH 08/34] update --- meshview/templates/map.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index a97d95e..2accfb6 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -267,6 +267,7 @@ const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; const PACKET_SEEN_DELAY_MS = 5000; const PACKET_TRACE_DURATION_MS = 6500; +const PACKET_LANDING_START_MS = 5800; const PACKET_LANDING_DURATION_MS = 1000; const portMap = window.PORT_LABEL_MAP; @@ -866,7 +867,7 @@ async function showPacketSeenNodes(pkt){ packetSeenLayer.removeLayer(landing); }, PACKET_LANDING_DURATION_MS); activePacketSeenHighlights.push(clearLandingTimeout); - }, PACKET_TRACE_DURATION_MS); + }, PACKET_LANDING_START_MS); activePacketSeenHighlights.push(landingTimeout); const timeout = setTimeout(() => { From 295f0f086e31adeafe5f9994771b5724b025ff32 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 18:09:33 -0700 Subject: [PATCH 09/34] update --- meshview/templates/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 2accfb6..2f75735 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -267,7 +267,7 @@ const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; const PACKET_SEEN_DELAY_MS = 5000; const PACKET_TRACE_DURATION_MS = 6500; -const PACKET_LANDING_START_MS = 5800; +const PACKET_LANDING_START_MS = 1500; const PACKET_LANDING_DURATION_MS = 1000; const portMap = window.PORT_LABEL_MAP; From 116a1c30b3e916e43a8b4b2820956a9e551fbc7f Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 18:32:23 -0700 Subject: [PATCH 10/34] update --- meshview/templates/map.html | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 2f75735..ed81dc1 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -38,10 +38,10 @@ border-color: #ff8c00; } .packet-seen-trace { - stroke-dasharray: 1 999; + stroke-dasharray: 1 var(--packet-trace-gap, 999); stroke-linecap: round; filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.65)); - animation: packetTrace 6.5s cubic-bezier(0.25, 0.05, 0.15, 1) 1 forwards; + animation: packetTrace var(--packet-trace-duration, 6.5s) linear 1 forwards; } .packet-seen-landing { transform-box: fill-box; @@ -50,7 +50,7 @@ } @keyframes packetTrace { from { stroke-dashoffset: 0; } - to { stroke-dashoffset: -1000; } + to { stroke-dashoffset: calc(-1 * var(--packet-trace-distance, 1000)); } } @keyframes packetSeenLanding { from { @@ -266,8 +266,9 @@ var trafficHeatLoading = false; const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; const PACKET_SEEN_DELAY_MS = 5000; -const PACKET_TRACE_DURATION_MS = 6500; -const PACKET_LANDING_START_MS = 1500; +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; @@ -850,6 +851,18 @@ async function showPacketSeenNodes(pkt){ 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}`); + traceEl.style.setProperty("--packet-trace-distance", `${traceLength + 20}`); + traceEl.style.setProperty("--packet-trace-duration", `${traceDurationMs}ms`); + } const landingTimeout = setTimeout(() => { const landing = L.circleMarker([lat, lng], { @@ -867,12 +880,12 @@ async function showPacketSeenNodes(pkt){ packetSeenLayer.removeLayer(landing); }, PACKET_LANDING_DURATION_MS); activePacketSeenHighlights.push(clearLandingTimeout); - }, PACKET_LANDING_START_MS); + }, traceDurationMs); activePacketSeenHighlights.push(landingTimeout); const timeout = setTimeout(() => { packetSeenLayer.removeLayer(trace); - }, PACKET_TRACE_DURATION_MS + PACKET_LANDING_DURATION_MS); + }, traceDurationMs + PACKET_LANDING_DURATION_MS); activePacketSeenHighlights.push(timeout); }); } catch(err) { From 4329ccc0f3de8452fd1f6d14caee7d9dc59129b3 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 18:35:15 -0700 Subject: [PATCH 11/34] update --- meshview/templates/map.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index ed81dc1..80e4a00 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -38,7 +38,7 @@ border-color: #ff8c00; } .packet-seen-trace { - stroke-dasharray: 1 var(--packet-trace-gap, 999); + stroke-dasharray: 8px var(--packet-trace-gap, 999px); stroke-linecap: round; filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.65)); animation: packetTrace var(--packet-trace-duration, 6.5s) linear 1 forwards; @@ -50,7 +50,7 @@ } @keyframes packetTrace { from { stroke-dashoffset: 0; } - to { stroke-dashoffset: calc(-1 * var(--packet-trace-distance, 1000)); } + to { stroke-dashoffset: calc(-1 * var(--packet-trace-distance, 1000px)); } } @keyframes packetSeenLanding { from { @@ -859,8 +859,8 @@ async function showPacketSeenNodes(pkt){ 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}`); - traceEl.style.setProperty("--packet-trace-distance", `${traceLength + 20}`); + 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`); } From c1d21d1ce1ed36beea73ab9488d16fa22ff7caf9 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 18:41:17 -0700 Subject: [PATCH 12/34] update --- meshview/templates/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 80e4a00..827bba0 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -38,7 +38,7 @@ border-color: #ff8c00; } .packet-seen-trace { - stroke-dasharray: 8px var(--packet-trace-gap, 999px); + stroke-dasharray: 5px var(--packet-trace-gap, 999px); stroke-linecap: round; filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.65)); animation: packetTrace var(--packet-trace-duration, 6.5s) linear 1 forwards; From 849ab695664a0c6098ed33ad619c5793b868b14b Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 18:48:51 -0700 Subject: [PATCH 13/34] update --- meshview/templates/map.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 827bba0..c068ee0 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -38,9 +38,9 @@ border-color: #ff8c00; } .packet-seen-trace { - stroke-dasharray: 5px var(--packet-trace-gap, 999px); + stroke-dasharray: 8px var(--packet-trace-gap, 999px); stroke-linecap: round; - filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.65)); + 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 { @@ -846,7 +846,7 @@ async function showPacketSeenNodes(pkt){ const lng = markerLatLng?.lng ?? node.long; const trace = L.polyline([[sourceLatLng.lat, sourceLatLng.lng], [lat, lng]], { color: "#000", - weight: 6, + weight: 3, opacity: 0.9, className: "packet-seen-trace", interactive: false From 03943c463e191e4d67b63ec5ffe891a2d8069816 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 19:41:42 -0700 Subject: [PATCH 14/34] update --- meshview/templates/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index c068ee0..6e13f40 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -38,7 +38,7 @@ border-color: #ff8c00; } .packet-seen-trace { - stroke-dasharray: 8px var(--packet-trace-gap, 999px); + 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; From 9d3bd891b787c3ab6cc43dac431bb805a981f18e Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 19:58:19 -0700 Subject: [PATCH 15/34] update --- meshview/templates/map.html | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 6e13f40..34155b2 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -265,7 +265,7 @@ var trafficHeatLoaded = false; var trafficHeatLoading = false; const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; -const PACKET_SEEN_DELAY_MS = 5000; +const PACKET_SEEN_DELAY_MS = 10000; const PACKET_TRACE_MIN_DURATION_MS = 1500; const PACKET_TRACE_MAX_DURATION_MS = 6500; const PACKET_TRACE_MS_PER_PIXEL = 18; @@ -790,8 +790,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 }); @@ -815,6 +816,9 @@ function clearPacketSeenHighlights(){ 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); } @@ -902,7 +906,10 @@ function blinkNode(marker,longName,portnum,payload){ let blinkCount = 0; const blinkStart = Date.now(); - const blinkDurationMs = 7000; + const tooltipDurationMs = 5000; + const blinkDurationMs = 10000; + const earlyBlinkColor = "yellow"; + const lateBlinkColor = "#ff7a00"; const safeName = escapeHtml(longName); const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; const payloadText = (payload || "").trim(); @@ -923,11 +930,20 @@ function blinkNode(marker,longName,portnum,payload){ .addTo(map); marker.tooltip = tooltip; + const tooltipTimeout = setTimeout(() => { + if(marker.tooltip){ + map.removeLayer(marker.tooltip); + marker.tooltip = null; + } + }, tooltipDurationMs); const interval = setInterval(()=>{ if(isNodeVisibleForPackets(marker)){ + const blinkColor = Date.now() - blinkStart < tooltipDurationMs + ? earlyBlinkColor + : lateBlinkColor; marker.setStyle({ - fillColor: blinkCount%2===0 ? 'yellow' : marker.originalColor + fillColor: blinkCount%2===0 ? blinkColor : marker.originalColor }); marker.bringToFront(); } else { @@ -942,7 +958,7 @@ function blinkNode(marker,longName,portnum,payload){ },500); - activeBlinks.set(marker, interval); + activeBlinks.set(marker, { interval, tooltipTimeout }); } /* ====================================================== From 64486dabc8d9dfd10e57afe130528d291b936285 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 20:10:19 -0700 Subject: [PATCH 16/34] update --- meshview/templates/map.html | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 34155b2..acde159 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -54,14 +54,14 @@ } @keyframes packetSeenLanding { from { - stroke-opacity: 0.95; - fill-opacity: 0.35; - transform: scale(0.2); + stroke-opacity: 0.45; + fill-opacity: 0.16; + transform: scale(0.35); } to { stroke-opacity: 0; fill-opacity: 0; - transform: scale(1.8); + transform: scale(1.45); } } @keyframes textPulse { @@ -870,12 +870,12 @@ async function showPacketSeenNodes(pkt){ const landingTimeout = setTimeout(() => { const landing = L.circleMarker([lat, lng], { - radius: 14, + radius: 16, color: "#000", fillColor: "#000", - fillOpacity: 0.35, - opacity: 0.95, - weight: 2, + fillOpacity: 0.16, + opacity: 0.45, + weight: 1, className: "packet-seen-landing", interactive: false }).addTo(packetSeenLayer); @@ -908,8 +908,8 @@ function blinkNode(marker,longName,portnum,payload){ const blinkStart = Date.now(); const tooltipDurationMs = 5000; const blinkDurationMs = 10000; - const earlyBlinkColor = "yellow"; - const lateBlinkColor = "#ff7a00"; + const earlyBlinkColor = "#ff4f00"; + const lateBlinkColor = "yellow"; const safeName = escapeHtml(longName); const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; const payloadText = (payload || "").trim(); From 3c66597b65504eef7a80dd0f5043fbe385e4b974 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 20:13:15 -0700 Subject: [PATCH 17/34] updte --- meshview/templates/map.html | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index acde159..2236a85 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -54,14 +54,14 @@ } @keyframes packetSeenLanding { from { - stroke-opacity: 0.45; - fill-opacity: 0.16; - transform: scale(0.35); + stroke-opacity: 0.7; + fill-opacity: 0.24; + transform: scale(0.3); } to { stroke-opacity: 0; fill-opacity: 0; - transform: scale(1.45); + transform: scale(1.6); } } @keyframes textPulse { @@ -873,9 +873,9 @@ async function showPacketSeenNodes(pkt){ radius: 16, color: "#000", fillColor: "#000", - fillOpacity: 0.16, - opacity: 0.45, - weight: 1, + fillOpacity: 0.24, + opacity: 0.7, + weight: 2, className: "packet-seen-landing", interactive: false }).addTo(packetSeenLayer); @@ -908,8 +908,7 @@ function blinkNode(marker,longName,portnum,payload){ const blinkStart = Date.now(); const tooltipDurationMs = 5000; const blinkDurationMs = 10000; - const earlyBlinkColor = "#ff4f00"; - const lateBlinkColor = "yellow"; + const blinkColor = "yellow"; const safeName = escapeHtml(longName); const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; const payloadText = (payload || "").trim(); @@ -939,9 +938,6 @@ function blinkNode(marker,longName,portnum,payload){ const interval = setInterval(()=>{ if(isNodeVisibleForPackets(marker)){ - const blinkColor = Date.now() - blinkStart < tooltipDurationMs - ? earlyBlinkColor - : lateBlinkColor; marker.setStyle({ fillColor: blinkCount%2===0 ? blinkColor : marker.originalColor }); From 4df2072a72c61cddb0b2f5cb189f63f6349324ce Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 20:20:32 -0700 Subject: [PATCH 18/34] update --- meshview/templates/map.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 2236a85..0c3881d 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -27,9 +27,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); } @@ -249,7 +249,7 @@ var trafficHeatLayer = L.heatLayer([], { }); var overlayLayers = { "Nodes": nodeLayer, - "Packet Heard By": packetSeenLayer, + "Packets Reach": packetSeenLayer, "Traffic HeatMap": trafficHeatLayer }; L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map); From 28651cad7ba1974787feb16f67c7941b3855abb7 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 20:33:42 -0700 Subject: [PATCH 19/34] update --- meshview/templates/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 0c3881d..0ef33d8 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -265,7 +265,7 @@ var trafficHeatLoaded = false; var trafficHeatLoading = false; const UNMAPPED_LIMIT = 50; const UNMAPPED_TTL_MS = 5000; -const PACKET_SEEN_DELAY_MS = 10000; +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; From 0850c433f28becd8902f5bfae7a49b2f49a62019 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 20:36:02 -0700 Subject: [PATCH 20/34] update --- meshview/templates/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 0ef33d8..c1e32a1 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -907,7 +907,7 @@ function blinkNode(marker,longName,portnum,payload){ let blinkCount = 0; const blinkStart = Date.now(); const tooltipDurationMs = 5000; - const blinkDurationMs = 10000; + const blinkDurationMs = 15000; const blinkColor = "yellow"; const safeName = escapeHtml(longName); const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; From 325e68520940df068bb73b472fd0796886361969 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 9 Sep 2026 20:41:24 -0700 Subject: [PATCH 21/34] update --- meshview/templates/map.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index c1e32a1..1785df4 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -904,10 +904,10 @@ function blinkNode(marker,longName,portnum,payload){ clearBlinkForMarker(marker); } - let blinkCount = 0; const blinkStart = Date.now(); const tooltipDurationMs = 5000; const blinkDurationMs = 15000; + const fastBlinkStartMs = blinkDurationMs - 3000; const blinkColor = "yellow"; const safeName = escapeHtml(longName); const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; @@ -937,22 +937,25 @@ function blinkNode(marker,longName,portnum,payload){ }, tooltipDurationMs); const interval = setInterval(()=>{ + const elapsedMs = Date.now() - blinkStart; + const blinkPeriodMs = elapsedMs >= fastBlinkStartMs ? 250 : 500; if(isNodeVisibleForPackets(marker)){ marker.setStyle({ - fillColor: blinkCount%2===0 ? blinkColor : 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, tooltipTimeout }); } From bb8da4cc13bc954fddd802c7db0d53c231afa7e0 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Thu, 10 Sep 2026 15:39:55 -0700 Subject: [PATCH 22/34] update --- meshview/mqtt_store.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index fc130f5..7b164de 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -23,7 +23,7 @@ MQTT_DIRECT_NODE_ID = 1 def has_valid_location(lat: int | None, lon: int | None) -> bool: - return lat not in (None, 0) and lon not in (None, 0) + 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: @@ -217,7 +217,6 @@ 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) - await ensure_node_exists(session, from_node_id, env.channel_id) result = await session.execute(select(Packet).where(Packet.id == packet_id)) packet = result.scalar_one_or_none() From 81612128a5de74bcf530390f02db318c7efb5d8c Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Fri, 18 Sep 2026 10:37:37 -0700 Subject: [PATCH 23/34] 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) --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Opus 5 (1M context) --- meshview/lang/en.json | 2 + meshview/lang/es.json | 2 + meshview/lang/ru.json | 2 + meshview/templates/node.html | 114 ++++++++++++++++++++++++++--------- 4 files changed, 93 insertions(+), 27 deletions(-) diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 6eec50e..1f8c103 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -221,6 +221,8 @@ "air_channel": "Air & Channel Utilization", "environment": "Environment Metrics", "neighbors_chart": "Neighbors (Signal-to-Noise)", + "neighbors_chart_subtitle": "Last {days} days — {n} neighbors", + "neighbors_chart_subtitle_capped": "Last {days} days — the {n} neighbors with the most data points, of {total}", "expand": "Expand", "export_csv": "Export CSV", "time": "Time", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index e7561d9..c362ce5 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -207,6 +207,8 @@ "air_channel": "Utilización del aire y del canal", "environment": "Métricas Ambientales", "neighbors_chart": "Vecinos (Relación Señal/Ruido)", + "neighbors_chart_subtitle": "Últimos {days} días — {n} vecinos", + "neighbors_chart_subtitle_capped": "Últimos {days} días — los {n} vecinos con más datos, de {total}", "expand": "Ampliar", "export_csv": "Exportar CSV", "time": "Hora", diff --git a/meshview/lang/ru.json b/meshview/lang/ru.json index 902aa64..34c2f13 100644 --- a/meshview/lang/ru.json +++ b/meshview/lang/ru.json @@ -202,6 +202,8 @@ "air_channel": "Использование сети и каналов", "environment": "Метрики среды", "neighbors_chart": "Соседи (Соотношение сигнал/шум)", + "neighbors_chart_subtitle": "Последние {days} дн. — {n} соседей", + "neighbors_chart_subtitle_capped": "Последние {days} дн. — {n} соседей с наибольшим числом точек, из {total}", "expand": "Расширить", "export_csv": "Экспорт в CSV", "time": "Время", diff --git a/meshview/templates/node.html b/meshview/templates/node.html index 763ef10..901c381 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -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" } }, From 0109d68c1bd8cd74c3a185d1c0a5a1250bef8771 Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Wed, 14 Oct 2026 07:21:05 -0700 Subject: [PATCH 24/34] 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 "". 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= 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 --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Mythos 5.1 --- meshview/store.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/meshview/store.py b/meshview/store.py index eaa3b92..bbec37c 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -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 "" 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: From 5214b233c17c68b2f8b3847f96821cd09b55fcb8 Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Wed, 14 Oct 2026 07:22:37 -0700 Subject: [PATCH 25/34] 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/. 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 --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Mythos 5.1 --- meshview/web.py | 7 +++++-- meshview/web_api/api.py | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/meshview/web.py b/meshview/web.py index 72beeb4..887264b 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -241,8 +241,11 @@ async def serve_page(request): if not page.endswith(".html"): page = f"{page}.html" - html_file = pathlib.Path(__file__).parent / "static" / page - if not html_file.exists(): + # aiohttp lets an encoded slash (%2F) through a single path segment, so resolve the + # final path and make sure it is still inside the static directory. + static_root = (pathlib.Path(__file__).parent / "static").resolve() + html_file = (static_root / page).resolve() + if static_root not in html_file.parents or not html_file.is_file(): raise web.HTTPNotFound(text=f"Page '{page}' not found") content = html_file.read_text(encoding="utf-8") diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 3d94282..f811ce4 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -5,6 +5,7 @@ import json import logging import math import os +import re from aiohttp import web from sqlalchemy import func, select @@ -37,6 +38,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") @@ -779,6 +783,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") From 5900c41cf482c925484ed7b6392f3a51a7683270 Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Wed, 14 Oct 2026 07:25:01 -0700 Subject: [PATCH 26/34] 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 --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Mythos 5.1 --- meshview/static/html-escape.js | 10 ++++++++++ meshview/static/kiosk.html | 15 ++++++++------- meshview/templates/base.html | 1 + meshview/templates/firehose.html | 14 +++++--------- meshview/templates/node.html | 19 +++++++++---------- meshview/templates/nodelist.html | 24 ++++++++++++------------ meshview/templates/packet.html | 16 ++++++++-------- meshview/templates/top.html | 6 +++--- 8 files changed, 56 insertions(+), 49 deletions(-) create mode 100644 meshview/static/html-escape.js diff --git a/meshview/static/html-escape.js b/meshview/static/html-escape.js new file mode 100644 index 0000000..691fc4b --- /dev/null +++ b/meshview/static/html-escape.js @@ -0,0 +1,10 @@ +/* Shared HTML escaping for strings that come from the mesh (node names, channel ids, + firmware strings, message text). Anything a radio can send must go through this + before it is placed in innerHTML, insertAdjacentHTML or a Leaflet popup/tooltip. */ +(function () { + const MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + window.escapeHtml = function escapeHtml(value) { + if (value === null || value === undefined) return ""; + return String(value).replace(/[&<>"']/g, ch => MAP[ch]); + }; +})(); diff --git a/meshview/static/kiosk.html b/meshview/static/kiosk.html index 2c3943b..0077d68 100644 --- a/meshview/static/kiosk.html +++ b/meshview/static/kiosk.html @@ -45,6 +45,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; } + + {% block head %}{% endblock %} diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index e109cc1..e9c6b21 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -199,9 +199,7 @@ function portLabel(portnum, payload, linksHtml) { const name = PORT_MAP[portnum] || "Unknown"; const color = PORT_COLORS[portnum] || "#6c757d"; - const safePayload = payload - ? payload.replace(/"/g, """) - : ""; + const safePayload = escapeHtml(payload || ""); return ` @@ -325,7 +323,7 @@ async function fetchUpdates() { ${firehoseTranslations.all_broadcast || "All"} ` : ` - ${nodeMap[pkt.from_node_id] || pkt.from_node_id} + ${escapeHtml(nodeMap[pkt.from_node_id] || pkt.from_node_id)} `; /* TO — includes translation */ @@ -339,7 +337,7 @@ async function fetchUpdates() { ${firehoseTranslations.all_broadcast || "All"} ` : ` - ${nodeMap[pkt.to_node_id] || pkt.to_node_id} + ${escapeHtml(nodeMap[pkt.to_node_id] || pkt.to_node_id)} `; let inlineLinks = ""; @@ -369,9 +367,7 @@ async function fetchUpdates() { >⮕`; } - const safePayload = (pkt.payload || "") - .replace(//g, ">"); + const safePayload = escapeHtml(pkt.payload || ""); const packetIdCell = pkt.packet_id ? ` @@ -395,7 +391,7 @@ async function fetchUpdates() { ${from} ${to} - ${pkt.channel || "—"} + ${escapeHtml(pkt.channel || "—")} ${portLabel(pkt.portnum, pkt.payload, inlineLinks)} diff --git a/meshview/templates/node.html b/meshview/templates/node.html index 901c381..bd65ca0 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -548,9 +548,9 @@ function makeNodePopup(node) { return `
- ${node.long_name || node.short_name || node.node_id} + ${escapeHtml(node.long_name || node.short_name || node.node_id)} - ${node.short_name ? ` (${node.short_name})` : ""}
+ ${node.short_name ? ` (${escapeHtml(node.short_name)})` : ""}
${nodeTranslations.node_id || "Node ID"}: @@ -558,19 +558,19 @@ function makeNodePopup(node) { ${nodeTranslations.hw_model || "HW Model"}: - ${node.hw_model ?? "—"}
+
${escapeHtml(node.hw_model ?? "—")}
${nodeTranslations.channel || "Channel"}: - ${node.channel ?? "—"}
+ ${escapeHtml(node.channel ?? "—")}
${nodeTranslations.role || "Role"}: - ${node.role ?? "—"}
+ ${escapeHtml(node.role ?? "—")}
${nodeTranslations.firmware || "Firmware"}: - ${node.firmware ?? "—"}
+ ${escapeHtml(node.firmware ?? "—")}
${nodeTranslations.last_update || "Last Update"}: @@ -745,7 +745,7 @@ function nodeLink(id, labelOverride = null) { const label = labelOverride || nodeMap[id] || id; return ` - ${label} + ${escapeHtml(label)} `; } @@ -855,7 +855,7 @@ function addMarker(id, lat, lon, color = "red", node = null) { node = nodeCache[id] || null; } - const popupHtml = node ? makeNodePopup(node) : `${id}`; + const popupHtml = node ? makeNodePopup(node) : `${escapeHtml(id)}`; const m = L.circleMarker([lat, lon], { radius: 6, @@ -1046,8 +1046,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); diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index ca86070..61bab28 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -652,14 +652,14 @@ document.addEventListener("DOMContentLoaded", async function() { // DESKTOP TABLE ROW const row = document.createElement("tr"); row.innerHTML = ` - ${node.short_name || "N/A"} - ${node.long_name || "N/A"} - ${node.hw_model || "N/A"} - ${node.firmware || "N/A"} - ${node.role || "N/A"} + ${escapeHtml(node.short_name || "N/A")} + ${escapeHtml(node.long_name || "N/A")} + ${escapeHtml(node.hw_model || "N/A")} + ${escapeHtml(node.firmware || "N/A")} + ${escapeHtml(node.role || "N/A")} ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} - ${node.channel || "N/A"} + ${escapeHtml(node.channel || "N/A")} ${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")} ${timeAgoFromMs(node.last_seen_ms)} @@ -675,22 +675,22 @@ document.addEventListener("DOMContentLoaded", async function() { card.className = "node-card"; card.innerHTML = `
- ${node.short_name || node.long_name || node.node_id} + ${escapeHtml(node.short_name || node.long_name || node.node_id)} ${star}
ID: ${node.node_id}
-
Name: ${node.long_name || "N/A"}
-
HW: ${node.hw_model || "N/A"}
-
Firmware: ${node.firmware || "N/A"}
-
Role: ${node.role || "N/A"}
+
Name: ${escapeHtml(node.long_name || "N/A")}
+
HW: ${escapeHtml(node.hw_model || "N/A")}
+
Firmware: ${escapeHtml(node.firmware || "N/A")}
+
Role: ${escapeHtml(node.role || "N/A")}
Location: ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
-
Channel: ${node.channel || "N/A"}
+
Channel: ${escapeHtml(node.channel || "N/A")}
Last Seen: ${timeAgoFromMs(node.last_seen_ms)}
diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html index 0be014a..358cb50 100644 --- a/meshview/templates/packet.html +++ b/meshview/templates/packet.html @@ -206,12 +206,12 @@ document.addEventListener("DOMContentLoaded", async () => { const fromNodeObj = nodeLookup[p.from_node_id]; const toNodeObj = nodeLookup[p.to_node_id]; - const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id; + const fromNodeLabel = escapeHtml(fromNodeObj?.long_name || p.from_node_id); const toNodeLabel = p.to_node_id == 4294967295 ? (packetTranslations.all_broadcast || "All") - : (toNodeObj?.long_name || p.to_node_id); + : escapeHtml(toNodeObj?.long_name || p.to_node_id); /* --------------------------------------------- Parse payload for lat/lon @@ -267,7 +267,7 @@ document.addEventListener("DOMContentLoaded", async () => { }
${packetTranslations.channel || "Channel"}:
-
${p.channel ?? "—"}
+
${escapeHtml(p.channel ?? "—")}
${packetTranslations.port || "Port"}:
${PORT_NAMES[p.portnum] || "UNKNOWN APP"} (${p.portnum})
@@ -338,7 +338,7 @@ document.addEventListener("DOMContentLoaded", async () => { Lat: ${srcLat.toFixed(6)}
Lon: ${srcLon.toFixed(6)}
${packetTranslations.from_node || "From Node:"} ${fromNodeLabel}
- ${packetTranslations.channel || "Channel:"} ${p.channel ?? "—"}
+ ${packetTranslations.channel || "Channel:"} ${escapeHtml(p.channel ?? "—")}
${packetTranslations.port || "Port:"} ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
`); @@ -427,7 +427,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups) const rows = hopGroups[hopKey].map(s => { const node = nodeLookup[s.node_id]; - const label = node?.long_name || s.node_id; + const label = escapeHtml(node?.long_name || s.node_id); const hopDisplay = formatHopDisplay(hopKey, s.hop_start); const timeStr = s.import_time_us @@ -473,8 +473,8 @@ seenTableBody.innerHTML = Object.keys(hopGroups) ${label}
Node ID:
${s.node_id}
- HW: ${node?.hw_model ?? "—"}
- Channel: ${s.channel ?? "—"}
+ HW: ${escapeHtml(node?.hw_model ?? "—")}
+ Channel: ${escapeHtml(s.channel ?? "—")}
${ distanceKm !== null ? `Distance: @@ -500,7 +500,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups) ${s.rx_rssi ?? "—"} ${s.rx_snr ?? "—"} ${hopDisplay} - ${s.channel ?? "—"} + ${escapeHtml(s.channel ?? "—")} ${timeStr} `; diff --git a/meshview/templates/top.html b/meshview/templates/top.html index cc17574..d42678c 100644 --- a/meshview/templates/top.html +++ b/meshview/templates/top.html @@ -194,11 +194,11 @@ async function renderTable() { - ${n.long_name || n.node_id} + ${escapeHtml(n.long_name || n.node_id)} - ${n.short_name || ""} - ${n.channel || ""} + ${escapeHtml(n.short_name || "")} + ${escapeHtml(n.channel || "")} ${n.sent} ${n.seen} ${n.avg.toFixed(1)} From 96b49bb893f9e72a101d8e07ec08cbd6a78ed145 Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Wed, 14 Oct 2026 07:26:03 -0700 Subject: [PATCH 27/34] 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 --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Mythos 5.1 --- meshview/mqtt_store.py | 53 ++++++++++++++++++++++++++++++++---------- startdb.py | 7 +++++- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 7b164de..e72e7e6 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -20,6 +20,27 @@ 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: @@ -259,23 +280,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, @@ -317,9 +340,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, @@ -341,6 +363,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) @@ -401,7 +430,7 @@ async def process_envelope(topic, env): packet_id=packet_id, route=env.packet.decoded.payload, done=not env.packet.decoded.want_response, - gateway_node_id=int(env.gateway_id[1:], 16), + gateway_node_id=gateway_node_id, import_time_us=now_us, ) ) diff --git a/startdb.py b/startdb.py index ac334a9..5b55d98 100644 --- a/startdb.py +++ b/startdb.py @@ -338,11 +338,16 @@ async def load_database_from_mqtt( mqtt_user: str | None = None, mqtt_passwd: str | None = None, ): + logger = logging.getLogger(__name__) async for topic, env in mqtt_reader.get_topic_envelopes( mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd ): async with db_lock: # Block if cleanup is running - await mqtt_store.process_envelope(topic, env) + try: + await mqtt_store.process_envelope(topic, env) + except Exception: + # One bad or unexpected message must not stop ingestion for everyone. + logger.exception("Failed to store a message from topic %s; skipping it", topic) # ------------------------- From cd78c392e9c2df001d4bdd00a8ae8c2a8223ba4d Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Wed, 14 Oct 2026 07:26:18 -0700 Subject: [PATCH 28/34] 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 --------- Co-authored-by: Pablo Revilla Co-authored-by: Nathan Co-authored-by: Claude Mythos 5.1 --- meshview/radio/coverage.py | 13 +++++++++---- meshview/web_api/api.py | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/meshview/radio/coverage.py b/meshview/radio/coverage.py index d14ed8c..524a3ac 100644 --- a/meshview/radio/coverage.py +++ b/meshview/radio/coverage.py @@ -42,7 +42,12 @@ def destination_point( return math.degrees(lat2), math.degrees(lon2) -@lru_cache(maxsize=512) +def _loop_is_safe(radius_km: float, step_km: float) -> bool: + """A non-positive step or a non-finite radius would make the distance loop endless.""" + return math.isfinite(radius_km) and math.isfinite(step_km) and step_km > 0 + + +@lru_cache(maxsize=64) def compute_coverage( lat: float, lon: float, @@ -54,7 +59,7 @@ def compute_coverage( step_km: float, reliability: float, ) -> list[tuple[float, float, float]]: - if not ITM_AVAILABLE: + if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km): return [] points = [] @@ -90,7 +95,7 @@ def compute_coverage( return points -@lru_cache(maxsize=512) +@lru_cache(maxsize=64) def compute_perimeter( lat: float, lon: float, @@ -103,7 +108,7 @@ def compute_perimeter( reliability: float, threshold_dbm: float, ) -> list[tuple[float, float]]: - if not ITM_AVAILABLE: + if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km): return [] perimeter = [] diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index f811ce4..29bbd09 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -1,5 +1,6 @@ """API endpoints for MeshView.""" +import asyncio import datetime import json import logging @@ -154,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.""" @@ -1227,12 +1242,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) @@ -1246,6 +1268,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) @@ -1254,8 +1281,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), @@ -1271,7 +1301,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), From f9b684ee443ec02f292dcea23456d033eedfcc30 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 14 Oct 2026 07:36:27 -0700 Subject: [PATCH 29/34] fix template --- meshview/templates/base.html | 4 ++-- meshview/templates/chat.html | 2 ++ meshview/templates/firehose.html | 2 ++ meshview/templates/map.html | 2 ++ meshview/templates/net.html | 2 ++ meshview/templates/node.html | 3 ++- meshview/templates/nodegraph.html | 2 ++ meshview/templates/reliability.html | 2 ++ meshview/templates/stats.html | 2 ++ meshview/templates/traceroute.html | 2 ++ 10 files changed, 20 insertions(+), 3 deletions(-) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index 8cc650b..addaed6 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -10,7 +10,6 @@ - {% block head %}{% endblock %} @@ -40,8 +39,9 @@ height: 500px; } - {% block css %}{% endblock %} + + {% block css %}{% endblock %} diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index d724afe..ca7cbb3 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index e9c6b21..17309eb 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 1785df4..b65ed9a 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + + {% endblock %} {% block body %} diff --git a/meshview/templates/net.html b/meshview/templates/net.html index 669bb97..f96b8a3 100644 --- a/meshview/templates/net.html +++ b/meshview/templates/net.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/node.html b/meshview/templates/node.html index bd65ca0..eccc758 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index fe515b1..1bebd01 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -5,6 +5,7 @@ {% endblock %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/reliability.html b/meshview/templates/reliability.html index 8cc5475..5c3668d 100644 --- a/meshview/templates/reliability.html +++ b/meshview/templates/reliability.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block body %} diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index ce5e2fd..deab1f9 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block css %} + {% endblock %} {% block head %} diff --git a/meshview/templates/traceroute.html b/meshview/templates/traceroute.html index f42993e..dc8922e 100644 --- a/meshview/templates/traceroute.html +++ b/meshview/templates/traceroute.html @@ -5,6 +5,7 @@ {% endblock %} {% block css %} + {% endblock %} {% block body %} From f4fbf16e4f9debe7cb0459f24f047126e7345bab Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 14 Oct 2026 08:46:00 -0700 Subject: [PATCH 30/34] uncheck packets reach --- meshview/templates/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index b65ed9a..2a96c53 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -236,7 +236,7 @@ L.control.scale({ }).addTo(map); var nodeLayer = L.layerGroup().addTo(map); var edgeLayer = L.layerGroup().addTo(map); -var packetSeenLayer = L.layerGroup().addTo(map); +var packetSeenLayer = L.layerGroup(); var trafficHeatLayer = L.heatLayer([], { radius: 34, blur: 18, From ef37a910b9eea401ca97f6dd4667e99449e4261f Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 14 Oct 2026 10:00:17 -0700 Subject: [PATCH 31/34] change packet reach activity --- meshview/templates/map.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 2a96c53..4ca8c13 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -908,7 +908,7 @@ function blinkNode(marker,longName,portnum,payload){ const blinkStart = Date.now(); const tooltipDurationMs = 5000; - const blinkDurationMs = 15000; + const blinkDurationMs = map.hasLayer(packetSeenLayer) ? 15000 : tooltipDurationMs; const fastBlinkStartMs = blinkDurationMs - 3000; const blinkColor = "yellow"; const safeName = escapeHtml(longName); @@ -932,6 +932,10 @@ function blinkNode(marker,longName,portnum,payload){ marker.tooltip = tooltip; const tooltipTimeout = setTimeout(() => { + if(blinkDurationMs === tooltipDurationMs){ + clearBlinkForMarker(marker); + return; + } if(marker.tooltip){ map.removeLayer(marker.tooltip); marker.tooltip = null; From 012150cba9a0a639f9c415e66b5bdef08c91738c Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Wed, 14 Oct 2026 10:12:11 -0700 Subject: [PATCH 32/34] update big --- meshview/templates/base.html | 1 + 1 file changed, 1 insertion(+) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index addaed6..4d470a4 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -10,6 +10,7 @@ + {% block head %}{% endblock %} From fb3a0154dd264023b7c4a0a0ecc623ab9a81cc59 Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Thu, 15 Oct 2026 22:49:55 -0700 Subject: [PATCH 33/34] some updates --- README.md | 3 +++ docs/API_Documentation.md | 23 +++++++++++++++++++++++ meshview/mqtt_store.py | 19 ++++++++++++++----- meshview/templates/map.html | 24 ++++++++---------------- meshview/templates/packet.html | 2 +- meshview/web_api/api.py | 6 ++++++ sample.config.ini | 13 +++++++++++++ 7 files changed, 68 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index df01437..b8b7225 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas ### Version 3.0.6 — March 2026 - Daily snapshots: added the `daily_snapshot` table, scheduled snapshot capture, and `/api/snapshots/daily` for historical node, packet, and gateway counts. + Each new snapshot stores packets imported during the previous complete UTC day, + labeled with that day's date. Node and gateway counts are database totals at capture + time. Existing historical snapshots retain their previously stored packet totals. - Stats/UI: expanded stats views with snapshot data and added channel filters to Chat and Firehose. - Map/node fixes: corrected node location handling, map popup behavior, packet page display, and node list sorting. - Ingestion/API reliability: improved duplicate `PacketSeen` handling, guarded invalid node info packets, and made traceroute/edge APIs more tolerant of decode failures. diff --git a/docs/API_Documentation.md b/docs/API_Documentation.md index 5531c26..5b13098 100644 --- a/docs/API_Documentation.md +++ b/docs/API_Documentation.md @@ -227,9 +227,32 @@ 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 and gateway counts +remain database totals at capture time. Older snapshots retain the cumulative packet +counts originally stored; they are not automatically converted. + ### GET `/api/config` Returns a safe subset of server configuration. +`site.map_channel_colors` contains channel color overrides from the optional +`[map_channel_colors]` INI section. Each entry is `channel = #RRGGBB`; invalid colors +are omitted. Channel matching on the map ignores case, spaces, hyphens, and underscores. +Unlisted channels use generated colors. For example: + +```ini +[map_channel_colors] +LongFast = #4363d8 +LocalChat = #18aa18 +``` + +Restart the web server and reload the map after changing these settings. Omitting +the section uses generated colors for every channel. + Response Example ```json { diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index e72e7e6..c3b9acb 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -1,7 +1,7 @@ import logging import re import time -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -110,12 +110,21 @@ async def ensure_node_exists(session, node_id: int | None, channel: str | None) async def capture_daily_snapshot() -> None: - today = datetime.now(UTC).date() + """Record packet traffic for the previous complete 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() 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( @@ -124,7 +133,7 @@ async def capture_daily_snapshot() -> None: ).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, @@ -151,7 +160,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: diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 4ca8c13..d92eab0 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -275,23 +275,11 @@ 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(); @@ -316,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; @@ -477,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); @@ -525,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=>({ diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html index 358cb50..1db8451 100644 --- a/meshview/templates/packet.html +++ b/meshview/templates/packet.html @@ -535,7 +535,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups) Escape HTML helper ----------------------------------------------*/ function escapeHtml(unsafe) { - return (unsafe ?? "").replace(/[&<"'>]/g, m => ({ + return String(unsafe ?? "").replace(/[&<"'>]/g, m => ({ "&":"&", "<":"<", ">":">", diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 29bbd09..bf33b32 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -744,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." diff --git a/sample.config.ini b/sample.config.ini index df24db5..e70f02f 100644 --- a/sample.config.ini +++ b/sample.config.ini @@ -59,6 +59,19 @@ firehose_interal=3 weekly_net_message = Weekly Mesh check-in. We will keep it open on every Wednesday from 5:00pm for checkins. The message format should be (LONG NAME) - (CITY YOU ARE IN) #BayMeshNet. net_tag = #BayMeshNet +# Map channel color overrides. Use six-digit hex colors; other channels get generated colors. +# Names ignore case, spaces, hyphens, and underscores. Restart the web server after editing. +[map_channel_colors] +LongFast = #4363d8 +LongSlow = #000075 +VeryLongSlow = #808000 +MediumSlow = #008080 +MediumFast = #e6194b +ShortSlow = #911eb4 +ShortFast = #f58231 +LongModerate = #46f0f0 +ShortTurbo = #f032e6 + # ------------------------- # MQTT Broker Configuration # ------------------------- From df7766e783edfa50a3e2fd6e81c21f38439e4c8f Mon Sep 17 00:00:00 2001 From: pablorevilla-meshtastic Date: Mon, 19 Oct 2026 17:37:30 -0700 Subject: [PATCH 34/34] change reporing on Snapshot --- docs/API_Documentation.md | 11 ++++++++--- meshview/mqtt_store.py | 22 +++++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/API_Documentation.md b/docs/API_Documentation.md index 5b13098..fde9e39 100644 --- a/docs/API_Documentation.md +++ b/docs/API_Documentation.md @@ -232,9 +232,14 @@ Response Example 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 and gateway counts -remain database totals at capture time. Older snapshots retain the cumulative packet -counts originally stored; they are not automatically converted. +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. diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index c3b9acb..1a2eec5 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -110,7 +110,7 @@ async def ensure_node_exists(session, node_id: int | None, channel: str | None) async def capture_daily_snapshot() -> None: - """Record packet traffic for the previous complete UTC day.""" + """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) @@ -118,7 +118,21 @@ async def capture_daily_snapshot() -> None: 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()) @@ -128,7 +142,9 @@ async def capture_daily_snapshot() -> None: ).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)