Compare commits

...

26 Commits

Author SHA1 Message Date
l5y e1d43cec57 Added comprehensive helper unit tests (#457)
* Added comprehensive helper unit tests

* run black
2025-11-16 16:47:57 +01:00
l5y cd7bced827 Added reaction-aware handling (#455) 2025-11-16 15:31:17 +01:00
l5y b298f2f22c env: add map zoom (#454)
* chore: bump version to 0.5.5 everywhere

* add MAP_ZOOM varibale

* run black
2025-11-16 12:57:47 +01:00
l5y 9304a99745 charts: render aggregated telemetry charts for all nodes (#453) 2025-11-15 17:09:55 +01:00
l5y 4a03e17886 nodes: render charts detail pages as overlay (#452) 2025-11-15 12:13:06 +01:00
l5y e502ddd436 fix telemetry parsing for charts (#451) 2025-11-14 21:18:37 +01:00
l5y 12f1801ed2 nodes: improve charts on detail pages (#450)
* nodes: add charts to detail pages

* nodes: improve charts on detail pages

* fix ignored packet debug loggin

* run rufo

* address review comments
2025-11-14 20:17:58 +01:00
l5y a6a63bf12e nodes: add charts to detail pages (#449) 2025-11-14 16:24:09 +01:00
l5y 631455237f Aggregate frontend snapshots across views (#447) 2025-11-13 22:02:42 +01:00
Alexkurd 382e2609c9 Remove added 1 if reply with emoji (#443)
In reply message.text contains emoji, and message.emoji is 1.
2025-11-13 21:15:35 +01:00
l5y 05efbc5f20 Refine node detail view layout (#442)
* Refine node detail view layout

* Refine node detail controls and formatting

* Improve node detail neighbor roles and message metadata

* Fix node detail neighbor metadata hydration
2025-11-13 19:59:07 +01:00
l5y 9a45430321 Enable map centering from node table coordinates (#439)
* Enable map centering from node table coordinates

* Replace node coordinate buttons with links
2025-11-13 17:23:35 +01:00
l5y cb843d5774 Add node detail route and page (#441) 2025-11-13 17:19:20 +01:00
l5y c823347175 Ensure nodeinfo patch runs before importing interfaces (#440) 2025-11-13 17:16:59 +01:00
l5y d87c0cc226 Filter zero-valued fields from API responses (#438)
* Filter zero-value fields from API responses

* Restore zero-valued API fields (#438)

* Clarify compact_api_row documentation
2025-11-13 17:10:46 +01:00
l5y 9c957a4a14 Add debug payload tracing and ignored packet logging (#437) 2025-11-13 17:06:35 +01:00
l5y 16442bab08 Tighten map auto-fit behaviour (#435) 2025-11-12 20:49:03 +01:00
l5y e479983d38 Fetch encrypted chat log entries for log tab (#434)
* Fetch encrypted chat log entries for log tab

* Guard log-only chat log merge from plaintext
2025-11-12 14:13:46 +01:00
l5y 70fca17230 Add encrypted filter to messages API (#432) 2025-11-12 12:46:34 +01:00
l5y 2107d6790d Guard NodeInfo handler against missing IDs (#426) (#431) 2025-11-12 12:39:36 +01:00
l5y 8823b7cb48 Add standalone full-screen map, chat, and nodes views (#429)
* Add dedicated full-screen dashboard views

* Simplify full-screen routes layout

* Restore refresh controls on full-screen views

* Polish standalone view layout

* Streamline standalone layouts
2025-11-12 11:38:26 +01:00
l5y e40c0d9078 Ensure chat history fetches full message limit (#428) 2025-11-11 22:33:30 +01:00
l5y 8b090cb238 Handle nodeinfo packets without identifiers (#426) (#427) 2025-11-11 20:45:32 +01:00
l5y 2bb8e3fd66 Chore: update license headers (#424) 2025-11-08 10:41:57 +01:00
l5y deb7263c3e Chore: bump version to 0.5.5 (#423) 2025-11-08 09:15:52 +00:00
l5y 3daadc4f68 handle naming when primary channel has a name (#422) 2025-11-08 09:44:41 +01:00
154 changed files with 8537 additions and 540 deletions
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
coverage:
status:
project:
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
version: 2
updates:
- package-ecosystem: "ruby"
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: "CodeQL Advanced"
on:
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Build and Push Docker Images
on:
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: JavaScript
on:
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Python
on:
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Ruby
on:
+7
View File
@@ -69,3 +69,10 @@ ai_docs/
# Generated credentials for the instance
web/.config
# JavaScript dependencies
node_modules/
web/node_modules/
# Debug symbols
ignored.txt
+1 -1
View File
@@ -8,7 +8,7 @@ Make sure all code is properly inline documented (PDoc, RDoc, JSDoc, et.c). We d
Make sure all code is 100% unit tested. We want all lines, units, and branches to be thouroughly covered by tests.
New source files should have Apache v2 license headers.
New source files should have Apache v2 license headers using the exact string `Copyright © 2025-26 l5yth & contributors`.
Run linters for Python (`black`) and Ruby (`rufo`) to ensure consistent code formatting.
+25
View File
@@ -1,5 +1,30 @@
# CHANGELOG
## v0.5.4
* Handle naming when primary channel has a name by @l5yth in <https://github.com/l5yth/potato-mesh/pull/422>
* Handle edge case when primary channel has a name by @l5yth in <https://github.com/l5yth/potato-mesh/pull/421>
* Add preset mode to logs by @l5yth in <https://github.com/l5yth/potato-mesh/pull/420>
* Parallelize federation tasks with worker pool by @l5yth in <https://github.com/l5yth/potato-mesh/pull/419>
* Allow filtering chat and logs by node name by @l5yth in <https://github.com/l5yth/potato-mesh/pull/417>
* Gem: Add erb as dependency removed from std by @l5yth in <https://github.com/l5yth/potato-mesh/pull/416>
* Implement support for replies and reactions app by @l5yth in <https://github.com/l5yth/potato-mesh/pull/411>
* Ingestor: Ignore direct messages on default channel by @l5yth in <https://github.com/l5yth/potato-mesh/pull/414>
* Agents: Add instructions by @l5yth in <https://github.com/l5yth/potato-mesh/pull/410>
* Display encrypted messages in frontend log window by @l5yth in <https://github.com/l5yth/potato-mesh/pull/409>
* Add chat log entries for telemetry, position, and neighbor events by @l5yth in <https://github.com/l5yth/potato-mesh/pull/408>
* Handle missing instance domain outside production by @l5yth in <https://github.com/l5yth/potato-mesh/pull/405>
* Add tabbed chat panel with channel grouping by @l5yth in <https://github.com/l5yth/potato-mesh/pull/404>
* Normalize numeric client roles using Meshtastic CLI enums by @l5yth in <https://github.com/l5yth/potato-mesh/pull/402>
* Ensure Docker images publish versioned tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/403>
* Document environment configuration variables by @l5yth in <https://github.com/l5yth/potato-mesh/pull/400>
* Document federation refresh cadence by @l5yth in <https://github.com/l5yth/potato-mesh/pull/401>
* Add Prometheus monitoring documentation by @l5yth in <https://github.com/l5yth/potato-mesh/pull/399>
* Config: Read PROM_REPORT_IDS from environment by @nicjansma in <https://github.com/l5yth/potato-mesh/pull/398>
* Feat: Mesh-Ingestor: Ability to provide already-existing interface instance by @KenADev in <https://github.com/l5yth/potato-mesh/pull/395>
* Fix: Mesh-Ingestor: Fix error for non-existing datetime.UTC reference by @KenADev in <https://github.com/l5yth/potato-mesh/pull/396>
* Chore: bump version to 0.5.4 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/388>
## v0.5.3
* Add telemetry formatting utilities and extend node overlay by @l5yth in <https://github.com/l5yth/potato-mesh/pull/387>
+1
View File
@@ -47,6 +47,7 @@ Additional environment variables are optional:
| `FREQUENCY` | `"915MHz"` | Default LoRa frequency description shown in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix room alias rendered in UI footers and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map view. |
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom (disables the auto-fit checkbox when set). |
| `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. |
| `DEBUG` | `0` | Enables verbose logging across services when set to `1`. |
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
+15
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# NOTE: This Dockerfile is kept for backward compatibility. The canonical build
# instructions live in `web/Dockerfile`; keep the two files in sync.
@@ -70,6 +84,7 @@ ENV APP_ENV=production \
CHANNEL="#LongFast" \
FREQUENCY="915MHz" \
MAP_CENTER="38.761944,-27.090833" \
MAP_ZOOM="" \
MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \
DEBUG=0
+2 -1
View File
@@ -79,6 +79,7 @@ The web app can be configured with environment variables (defaults shown):
| `FREQUENCY` | `"915MHz"` | Default frequency description displayed in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix alias rendered in the footer and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map on load. |
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. |
| `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. |
| `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. |
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
@@ -92,7 +93,7 @@ logo for Open Graph and Twitter cards.
Example:
```bash
SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh
SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAP_ZOOM=11 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh
```
### Configuration & Storage
+13 -1
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -77,6 +77,7 @@ FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' ||
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833")
MAP_ZOOM=$(grep "^MAP_ZOOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42")
CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo")
API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
@@ -90,6 +91,7 @@ echo "📍 Location Settings"
echo "-------------------"
read_with_default "Site Name (your mesh network name)" "$SITE_NAME" SITE_NAME
read_with_default "Map Center (lat,lon)" "$MAP_CENTER" MAP_CENTER
read_with_default "Default map zoom (leave blank to auto-fit)" "$MAP_ZOOM" MAP_ZOOM
read_with_default "Max Distance (km)" "$MAX_DISTANCE" MAX_DISTANCE
echo ""
@@ -180,6 +182,11 @@ update_env "SITE_NAME" "\"$SITE_NAME\""
update_env "CHANNEL" "\"$CHANNEL\""
update_env "FREQUENCY" "\"$FREQUENCY\""
update_env "MAP_CENTER" "\"$MAP_CENTER\""
if [ -n "$MAP_ZOOM" ]; then
update_env "MAP_ZOOM" "$MAP_ZOOM"
else
sed -i.bak '/^MAP_ZOOM=.*/d' .env
fi
update_env "MAX_DISTANCE" "$MAX_DISTANCE"
update_env "CONTACT_LINK" "\"$CONTACT_LINK\""
update_env "DEBUG" "$DEBUG"
@@ -222,6 +229,11 @@ echo ""
echo "📋 Your settings:"
echo " Site Name: $SITE_NAME"
echo " Map Center: $MAP_CENTER"
if [ -n "$MAP_ZOOM" ]; then
echo " Map Zoom: $MAP_ZOOM"
else
echo " Map Zoom: Auto-fit"
fi
echo " Max Distance: ${MAX_DISTANCE}km"
echo " Channel: $CHANNEL"
echo " Frequency: $FREQUENCY"
+13
View File
@@ -1,4 +1,17 @@
# syntax=docker/dockerfile:1.6
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
ARG TARGETOS=linux
ARG PYTHON_VERSION=3.12.6
+6 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,3 +17,8 @@
The ``data.mesh`` module exposes helpers for reading Meshtastic node and
message information before forwarding it to the accompanying web application.
"""
VERSION = "0.5.5"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION
+1 -1
View File
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -2
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+54 -5
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,10 +21,55 @@ import contextlib
import importlib
import json
import sys
import threading
import time
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path
from . import channels, config, queue
_IGNORED_PACKET_LOG_PATH = Path(__file__).resolve().parents[2] / "ignored.txt"
"""Filesystem path that stores ignored packets when debugging."""
_IGNORED_PACKET_LOCK = threading.Lock()
"""Lock guarding writes to :data:`_IGNORED_PACKET_LOG_PATH`."""
def _ignored_packet_default(value: object) -> object:
"""Return a JSON-serialisable representation for ignored packet data."""
if isinstance(value, (list, tuple, set)):
return list(value)
if isinstance(value, bytes):
return base64.b64encode(value).decode("ascii")
if isinstance(value, Mapping):
return {
str(key): _ignored_packet_default(sub_value)
for key, sub_value in value.items()
}
return str(value)
def _record_ignored_packet(packet: Mapping | object, *, reason: str) -> None:
"""Persist packet details to :data:`ignored.txt` during debugging."""
if not config.DEBUG:
return
timestamp = datetime.now(timezone.utc).isoformat()
entry = {
"timestamp": timestamp,
"reason": reason,
"packet": _ignored_packet_default(packet),
}
payload = json.dumps(entry, ensure_ascii=False, sort_keys=True)
with _IGNORED_PACKET_LOCK:
_IGNORED_PACKET_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with _IGNORED_PACKET_LOG_PATH.open("a", encoding="utf-8") as handle:
handle.write(f"{payload}\n")
from .serialization import (
_canonical_node_id,
_coerce_float,
@@ -1087,10 +1132,6 @@ def store_packet_dict(packet: Mapping) -> None:
if emoji_text:
emoji = emoji_text
encrypted_flag = _is_encrypted_flag(encrypted)
if not any([text, encrypted_flag, emoji is not None, reply_id is not None]):
return
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"}
allowed_port_ints = {1}
@@ -1130,8 +1171,14 @@ def store_packet_dict(packet: Mapping) -> None:
if portnum and portnum not in allowed_port_values:
if portnum_int not in allowed_port_ints:
_record_ignored_packet(packet, reason="unsupported-port")
return
encrypted_flag = _is_encrypted_flag(encrypted)
if not any([text, encrypted_flag, emoji is not None, reply_id is not None]):
_record_ignored_packet(packet, reason="no-message-payload")
return
channel = _first(decoded, "channel", default=None)
if channel is None:
channel = _first(packet, "channel", default=0)
@@ -1142,6 +1189,7 @@ def store_packet_dict(packet: Mapping) -> None:
pkt_id = _first(packet, "id", "packet_id", "packetId", default=None)
if pkt_id is None:
_record_ignored_packet(packet, reason="missing-packet-id")
return
rx_time = int(_first(packet, "rxTime", "rx_time", default=time.time()))
from_id = _first(packet, "fromId", "from_id", "from", default=None)
@@ -1181,6 +1229,7 @@ def store_packet_dict(packet: Mapping) -> None:
to_id=_canonical_node_id(to_id) or to_id,
channel=channel,
)
_record_ignored_packet(packet, reason="skipped-direct-message")
return
message_payload = {
+212 -42
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -16,18 +16,125 @@
from __future__ import annotations
import contextlib
import glob
import importlib
import ipaddress
import re
import sys
import urllib.parse
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from meshtastic.serial_interface import SerialInterface
from meshtastic.tcp_interface import TCPInterface
try: # pragma: no cover - dependency optional in tests
import meshtastic # type: ignore
except Exception: # pragma: no cover - dependency optional in tests
meshtastic = None # type: ignore[assignment]
from . import channels, config, serialization
def _ensure_mapping(value) -> Mapping | None:
"""Return ``value`` as a mapping when conversion is possible."""
if isinstance(value, Mapping):
return value
if hasattr(value, "__dict__") and isinstance(value.__dict__, Mapping):
return value.__dict__
with contextlib.suppress(Exception):
converted = serialization._node_to_dict(value)
if isinstance(converted, Mapping):
return converted
return None
def _candidate_node_id(mapping: Mapping | None) -> str | None:
"""Extract a canonical node identifier from ``mapping`` when present."""
if mapping is None:
return None
primary_keys = (
"id",
"userId",
"user_id",
"fromId",
"from_id",
"from",
"nodeId",
"node_id",
"nodeNum",
"node_num",
"num",
)
for key in primary_keys:
with contextlib.suppress(Exception):
node_id = serialization._canonical_node_id(mapping.get(key))
if node_id:
return node_id
user_section = _ensure_mapping(mapping.get("user"))
if user_section is not None:
for key in ("id", "userId", "user_id", "num", "nodeNum", "node_num"):
with contextlib.suppress(Exception):
node_id = serialization._canonical_node_id(user_section.get(key))
if node_id:
return node_id
decoded_section = _ensure_mapping(mapping.get("decoded"))
if decoded_section is not None:
node_id = _candidate_node_id(decoded_section)
if node_id:
return node_id
payload_section = _ensure_mapping(mapping.get("payload"))
if payload_section is not None:
node_id = _candidate_node_id(payload_section)
if node_id:
return node_id
for key in ("packet", "meta", "info"):
node_id = _candidate_node_id(_ensure_mapping(mapping.get(key)))
if node_id:
return node_id
for value in mapping.values():
if isinstance(value, (list, tuple)):
for item in value:
node_id = _candidate_node_id(_ensure_mapping(item))
if node_id:
return node_id
else:
node_id = _candidate_node_id(_ensure_mapping(value))
if node_id:
return node_id
return None
def _normalise_nodeinfo_packet(packet) -> dict | None:
"""Return a dictionary view of ``packet`` with a guaranteed ``id`` when known."""
mapping = _ensure_mapping(packet)
if mapping is None:
return None
try:
normalised: dict = dict(mapping)
except Exception:
try:
normalised = {key: mapping[key] for key in mapping}
except Exception:
return None
node_id = _candidate_node_id(normalised)
if node_id and normalised.get("id") != node_id:
normalised["id"] = node_id
return normalised
if TYPE_CHECKING: # pragma: no cover - import only used for type checking
from meshtastic.ble_interface import BLEInterface as _BLEInterface
@@ -37,50 +144,36 @@ BLEInterface = None
def _patch_meshtastic_nodeinfo_handler() -> None:
"""Ensure Meshtastic nodeinfo packets always include an ``id`` field."""
try:
import meshtastic # type: ignore
except Exception: # pragma: no cover - dependency optional in tests
module = sys.modules.get("meshtastic", meshtastic)
if module is None:
with contextlib.suppress(Exception):
module = importlib.import_module("meshtastic")
if module is None:
return
globals()["meshtastic"] = module
original = getattr(meshtastic, "_onNodeInfoReceive", None)
original = getattr(module, "_onNodeInfoReceive", None)
if not callable(original):
return
if getattr(original, "_potato_mesh_safe_wrapper", False):
return
mesh_interface_module = getattr(module, "mesh_interface", None)
if mesh_interface_module is None:
with contextlib.suppress(Exception):
mesh_interface_module = importlib.import_module("meshtastic.mesh_interface")
if not getattr(original, "_potato_mesh_safe_wrapper", False):
module._onNodeInfoReceive = _build_safe_nodeinfo_callback(original)
_patch_nodeinfo_handler_class(mesh_interface_module, module)
def _build_safe_nodeinfo_callback(original):
"""Return a wrapper that injects a missing ``id`` before dispatching."""
def _safe_on_node_info_receive(iface, packet): # type: ignore[override]
candidate_mapping: Mapping | None = None
if isinstance(packet, Mapping):
candidate_mapping = packet
elif hasattr(packet, "__dict__") and isinstance(packet.__dict__, Mapping):
candidate_mapping = packet.__dict__
node_id = None
if candidate_mapping is not None:
node_id = serialization._canonical_node_id(candidate_mapping.get("id"))
if node_id is None:
user_section = candidate_mapping.get("user")
if isinstance(user_section, Mapping):
node_id = serialization._canonical_node_id(user_section.get("id"))
if node_id is None:
for key in ("fromId", "from_id", "from", "num", "nodeId", "node_id"):
node_id = serialization._canonical_node_id(
candidate_mapping.get(key)
)
if node_id:
break
if node_id:
if not isinstance(candidate_mapping, dict):
try:
candidate_mapping = dict(candidate_mapping)
except Exception:
candidate_mapping = {
k: candidate_mapping[k] for k in candidate_mapping
}
if candidate_mapping.get("id") != node_id:
candidate_mapping["id"] = node_id
packet = candidate_mapping
normalised = _normalise_nodeinfo_packet(packet)
if normalised is not None:
packet = normalised
try:
return original(iface, packet)
@@ -90,12 +183,89 @@ def _patch_meshtastic_nodeinfo_handler() -> None:
raise
_safe_on_node_info_receive._potato_mesh_safe_wrapper = True # type: ignore[attr-defined]
meshtastic._onNodeInfoReceive = _safe_on_node_info_receive
return _safe_on_node_info_receive
def _update_nodeinfo_handler_aliases(original, replacement) -> None:
"""Ensure Meshtastic modules reference the patched ``NodeInfoHandler``."""
for module_name, module in list(sys.modules.items()):
if not module_name.startswith("meshtastic"):
continue
existing = getattr(module, "NodeInfoHandler", None)
if existing is original:
setattr(module, "NodeInfoHandler", replacement)
def _patch_nodeinfo_handler_class(
mesh_interface_module, meshtastic_module=None
) -> None:
"""Wrap ``NodeInfoHandler.onReceive`` to normalise packets before callbacks."""
if mesh_interface_module is None:
return
handler_class = getattr(mesh_interface_module, "NodeInfoHandler", None)
if handler_class is None:
return
if getattr(handler_class, "_potato_mesh_safe_wrapper", False):
return
original_on_receive = getattr(handler_class, "onReceive", None)
if not callable(original_on_receive):
return
class _SafeNodeInfoHandler(handler_class): # type: ignore[misc]
"""Subclass that guards against missing node identifiers."""
def onReceive(self, iface, packet): # type: ignore[override]
normalised = _normalise_nodeinfo_packet(packet)
if normalised is not None:
packet = normalised
try:
return super().onReceive(iface, packet)
except KeyError as exc: # pragma: no cover - defensive only
if exc.args and exc.args[0] == "id":
return None
raise
_SafeNodeInfoHandler.__name__ = handler_class.__name__
_SafeNodeInfoHandler.__qualname__ = getattr(
handler_class, "__qualname__", handler_class.__name__
)
_SafeNodeInfoHandler.__module__ = getattr(
handler_class, "__module__", mesh_interface_module.__name__
)
_SafeNodeInfoHandler.__doc__ = getattr(
handler_class, "__doc__", _SafeNodeInfoHandler.__doc__
)
_SafeNodeInfoHandler._potato_mesh_safe_wrapper = True # type: ignore[attr-defined]
setattr(mesh_interface_module, "NodeInfoHandler", _SafeNodeInfoHandler)
if meshtastic_module is None:
meshtastic_module = globals().get("meshtastic")
if meshtastic_module is not None:
existing_top = getattr(meshtastic_module, "NodeInfoHandler", None)
if existing_top is handler_class:
setattr(meshtastic_module, "NodeInfoHandler", _SafeNodeInfoHandler)
_update_nodeinfo_handler_aliases(handler_class, _SafeNodeInfoHandler)
_patch_meshtastic_nodeinfo_handler()
try: # pragma: no cover - optional dependency may be unavailable
from meshtastic.serial_interface import SerialInterface # type: ignore
except Exception: # pragma: no cover - optional dependency may be unavailable
SerialInterface = None # type: ignore[assignment]
try: # pragma: no cover - optional dependency may be unavailable
from meshtastic.tcp_interface import TCPInterface # type: ignore
except Exception: # pragma: no cover - optional dependency may be unavailable
TCPInterface = None # type: ignore[assignment]
def _patch_meshtastic_ble_receive_loop() -> None:
"""Prevent ``UnboundLocalError`` crashes in Meshtastic's BLE reader."""
+62 -2
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,10 +22,57 @@ import json
import threading
import urllib.request
from dataclasses import dataclass, field
from typing import Callable, Iterable, Tuple
from typing import Callable, Iterable, Mapping, Tuple
from . import config
def _stringify_payload_value(value: object) -> str:
"""Return a stable string representation for ``value``."""
if isinstance(value, Mapping):
try:
return json.dumps(
{
str(key): value[key]
for key in sorted(value, key=lambda item: str(item))
},
sort_keys=True,
ensure_ascii=False,
default=str,
)
except Exception: # pragma: no cover - defensive guard
return str(value)
if isinstance(value, (list, tuple)):
try:
return json.dumps(list(value), ensure_ascii=False, default=str)
except Exception: # pragma: no cover - defensive guard
return str(value)
if isinstance(value, set):
try:
return json.dumps(sorted(value, key=str), ensure_ascii=False, default=str)
except Exception: # pragma: no cover - defensive guard
return str(value)
if isinstance(value, bytes):
return json.dumps(value.decode("utf-8", "replace"), ensure_ascii=False)
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False)
return str(value)
def _payload_key_value_pairs(payload: Mapping[str, object]) -> str:
"""Serialise ``payload`` into ``key=value`` pairs for debug logs."""
pairs: list[str] = []
for key in sorted(payload):
try:
formatted = _stringify_payload_value(payload[key])
except Exception: # pragma: no cover - defensive guard
formatted = str(payload[key])
pairs.append(f"{key}={formatted}")
return " ".join(pairs)
_MESSAGE_POST_PRIORITY = 10
_NEIGHBOR_POST_PRIORITY = 20
_POSITION_POST_PRIORITY = 30
@@ -173,6 +220,19 @@ def _queue_post_json(
if send is None:
send = _post_json
if config.DEBUG:
formatted_payload = (
_payload_key_value_pairs(payload)
if isinstance(payload, Mapping)
else str(payload)
)
config._debug_log(
f"Forwarding payload to API: {formatted_payload}",
context="queue.queue_post_json",
path=path,
priority=priority,
)
_enqueue_post_json(path, payload, priority, state=state)
with state.lock:
if state.active:
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
@@ -1,3 +1,17 @@
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
-- Add support for encrypted messages to the existing schema.
BEGIN;
ALTER TABLE messages ADD COLUMN encrypted TEXT;
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
@@ -11,8 +11,9 @@
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Extend the nodes and messages tables with LoRa metadata columns.
BEGIN;
ALTER TABLE nodes ADD COLUMN lora_freq INTEGER;
ALTER TABLE nodes ADD COLUMN modem_preset TEXT;
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
@@ -11,8 +11,9 @@
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Extend the telemetry table with additional environment metrics.
BEGIN;
ALTER TABLE telemetry ADD COLUMN gas_resistance REAL;
ALTER TABLE telemetry ADD COLUMN current REAL;
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
@@ -11,8 +11,9 @@
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Extend the messages table to capture reply relationships and emoji reactions.
BEGIN;
ALTER TABLE messages ADD COLUMN reply_id INTEGER;
ALTER TABLE messages ADD COLUMN emoji TEXT;
+1 -1
View File
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Copyright (C) 2025 l5yth
-- Copyright © 2025-26 l5yth & contributors
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Development overrides for docker-compose.yml
services:
web:
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Production overrides for docker-compose.yml
services:
web:
+15
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
x-web-base: &web-base
image: ghcr.io/l5yth/potato-mesh-web-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
environment:
@@ -7,6 +21,7 @@ x-web-base: &web-base
CHANNEL: ${CHANNEL:-#LongFast}
FREQUENCY: ${FREQUENCY:-915MHz}
MAP_CENTER: ${MAP_CENTER:-38.761944,-27.090833}
MAP_ZOOM: ${MAP_ZOOM:-""}
MAX_DISTANCE: ${MAX_DISTANCE:-42}
CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo}
FEDERATION: ${FEDERATION:-1}
+1 -2
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+2 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -11,6 +11,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Minimal Meshtastic protobuf stubs for isolated unit testing."""
from __future__ import annotations
+216
View File
@@ -0,0 +1,216 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Additional tests that exercise defensive helpers and interfaces."""
import importlib
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor import channels, config, interfaces, queue, serialization
@pytest.fixture(autouse=True)
def reset_state(monkeypatch):
"""Ensure mutable singletons are cleaned up between tests."""
repo_root = Path(__file__).resolve().parents[1]
monkeypatch.syspath_prepend(str(repo_root))
channels._reset_channel_cache()
yield
channels._reset_channel_cache()
importlib.reload(config)
def test_config_module_port_aliases(monkeypatch):
"""Ensure the config module keeps CONNECTION and PORT in sync."""
reloaded = importlib.reload(config)
monkeypatch.setattr(reloaded, "CONNECTION", "dev-tty", raising=False)
reloaded.PORT = "new-port"
assert reloaded.CONNECTION == "new-port"
assert reloaded.PORT == "new-port"
def test_queue_stringification_and_ordering():
"""Exercise queue payload formatting and priority ordering."""
mapping_payload = {"b": 1, "a": 2}
assert queue._stringify_payload_value(mapping_payload).startswith('{"a"')
assert queue._stringify_payload_value([1, 2, 3]).startswith("[1")
assert queue._stringify_payload_value({1, 2}).replace(" ", "") in ("[1,2]", "[2,1]")
assert queue._stringify_payload_value(b"bytes") == '"bytes"'
assert queue._stringify_payload_value("text") == '"text"'
pairs = queue._payload_key_value_pairs(mapping_payload)
assert pairs.split(" ") == ["a=2", "b=1"]
state = queue.QueueState()
order = []
queue._enqueue_post_json("/low", {"x": 1}, priority=90, state=state)
queue._enqueue_post_json("/high", {"x": 2}, priority=10, state=state)
state.active = True
queue._drain_post_queue(
state=state, send=lambda path, payload: order.append((path, payload["x"]))
)
assert order == [("/high", 2), ("/low", 1)]
assert state.active is False
assert state.queue == []
def test_channels_iterator_and_capture(monkeypatch):
"""Verify channel helpers normalise roles and cache primary/secondary entries."""
channels._reset_channel_cache()
class StubSettings:
def __init__(self, name):
self.name = name
class PrimaryChannel:
def __init__(self):
self.role = "PRIMARY"
self.settings = StubSettings("Alpha")
class SecondaryChannel:
def __init__(self, index, name):
self.role = "SECONDARY"
self.index = index
self.settings = StubSettings(name)
class Container:
def __len__(self):
return 2
def __getitem__(self, idx):
if idx == 0:
return PrimaryChannel()
if idx == 1:
return SecondaryChannel(5, "Bravo")
raise IndexError
class StubLocalNode:
def __init__(self):
self.channels = Container()
class StubIface:
def __init__(self):
self.localNode = StubLocalNode()
def waitForConfig(self):
return True
channels.capture_from_interface(StubIface())
assert channels.channel_mappings() == ((0, "Alpha"), (5, "Bravo"))
assert channels.channel_name(5) == "Bravo"
assert list(channels._iter_channel_objects({"0": "zero"})) == ["zero"]
def test_candidate_node_id_and_normaliser():
"""Ensure node identifiers are found inside nested payloads."""
nested = {
"payload": {"meta": {"user": {"id": "0x42"}}},
"decoded": {"from": "!0000002a"},
}
node_id = interfaces._candidate_node_id(nested)
assert node_id == "!0000002a"
packet = {"user": {"id": "!0000002a"}, "userId": None}
normalised = interfaces._normalise_nodeinfo_packet(packet)
assert normalised["id"] == "!0000002a"
assert normalised["user"]["id"] == "!0000002a"
def test_safe_nodeinfo_wrapper_handles_missing_id():
"""Cover the KeyError guard and wrapper marker."""
called = {}
def original(_iface, _packet):
called["ran"] = True
raise KeyError("id")
wrapper = interfaces._build_safe_nodeinfo_callback(original)
result = wrapper(SimpleNamespace(), {"anything": 1})
assert called["ran"] is True
assert result is None
assert getattr(wrapper, "_potato_mesh_safe_wrapper")
def test_patch_nodeinfo_handler_class(monkeypatch):
"""Ensure NodeInfoHandler subclasses normalise packets with missing ids."""
class DummyHandler:
def __init__(self):
self.calls = []
def onReceive(self, iface, packet):
self.calls.append(packet)
return packet.get("id")
mesh_interface = types.SimpleNamespace(
NodeInfoHandler=DummyHandler, __name__="meshtastic.mesh_interface"
)
interfaces._patch_nodeinfo_handler_class(mesh_interface)
handler_cls = mesh_interface.NodeInfoHandler
handler = handler_cls()
iface = SimpleNamespace()
packet = {"user": {"id": "abcd"}}
result = handler.onReceive(iface, packet)
assert result == serialization._canonical_node_id("abcd")
assert handler.calls[0]["id"] == serialization._canonical_node_id("abcd")
def test_region_frequency_and_resolution_helpers():
"""Cover enum name parsing for LoRa region frequency."""
class EnumValue:
def __init__(self, name):
self.name = name
class EnumType:
def __init__(self):
self.values_by_number = {1: EnumValue("REGION_915")}
class FieldDesc:
def __init__(self):
self.enum_type = EnumType()
class Descriptor:
def __init__(self):
self.fields_by_name = {"region": FieldDesc()}
class LoraMessage:
def __init__(self, region):
self.region = region
self.DESCRIPTOR = Descriptor()
freq = interfaces._region_frequency(LoraMessage(1))
assert freq == 915
class LocalConfig:
def __init__(self, lora):
self.lora = lora
lora_msg = LoraMessage(1)
resolved = interfaces._resolve_lora_message(LocalConfig(lora_msg))
assert resolved is lora_msg
+226 -8
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@
import base64
import enum
import importlib
import json
import re
import sys
import threading
@@ -129,6 +130,32 @@ def mesh_module(monkeypatch):
meshtastic_mod.serial_interface = serial_interface_mod
meshtastic_mod.tcp_interface = tcp_interface_mod
meshtastic_mod.ble_interface = ble_interface_mod
mesh_interface_mod = types.ModuleType("meshtastic.mesh_interface")
def _default_nodeinfo_callback(iface, packet):
iface.nodes[packet["id"]] = packet
return packet["id"]
class DummyNodeInfoHandler:
"""Stub that mimics Meshtastic's NodeInfo handler semantics."""
def __init__(self):
self.callback = getattr(
meshtastic_mod, "_onNodeInfoReceive", _default_nodeinfo_callback
)
def onReceive(self, iface, packet):
nodes = getattr(iface, "nodes", None)
if isinstance(nodes, dict):
nodes[packet["id"]] = packet
return self.callback(iface, packet)
mesh_interface_mod.NodeInfoHandler = DummyNodeInfoHandler
meshtastic_mod.mesh_interface = mesh_interface_mod
monkeypatch.setitem(sys.modules, "meshtastic.mesh_interface", mesh_interface_mod)
meshtastic_mod._onNodeInfoReceive = _default_nodeinfo_callback
if real_protobuf is not None:
meshtastic_mod.protobuf = real_protobuf
else:
@@ -198,7 +225,6 @@ def mesh_module(monkeypatch):
def test_snapshot_interval_defaults_to_60_seconds(mesh_module):
mesh = mesh_module
assert mesh.SNAPSHOT_SECS == 60
@@ -1038,12 +1064,167 @@ def test_store_packet_dict_nodeinfo_uses_from_id_when_user_missing(
assert captured
_, payload, _ = captured[0]
assert "!01020304" in payload
node_entry = payload["!01020304"]
assert node_entry["num"] == 0x01020304
assert node_entry["lastHeard"] == 200
assert node_entry["snr"] == pytest.approx(1.5)
assert node_entry["lora_freq"] == 868
assert node_entry["modem_preset"] == "MediumFast"
def test_nodeinfo_wrapper_infers_missing_identifier(mesh_module, monkeypatch):
"""Ensure the Meshtastic nodeinfo hook derives canonical IDs from payloads."""
_ = mesh_module
import meshtastic
from data.mesh_ingestor import interfaces
captured_packets: list[dict] = []
def _original_handler(iface, packet):
captured_packets.append(packet)
return packet["id"]
monkeypatch.setattr(
meshtastic, "_onNodeInfoReceive", _original_handler, raising=False
)
interfaces._patch_meshtastic_nodeinfo_handler()
safe_handler = meshtastic._onNodeInfoReceive
class DummyUser:
def __init__(self) -> None:
self.num = 0x88776655
class DummyDecoded:
def __init__(self) -> None:
self.user = DummyUser()
class DummyPacket:
def __init__(self) -> None:
self.decoded = DummyDecoded()
iface = types.SimpleNamespace(nodes={})
safe_handler(iface, DummyPacket())
assert captured_packets, "Expected wrapper to call the original handler"
packet = captured_packets[0]
assert packet["id"] == "!88776655"
def test_nodeinfo_handler_wrapper_prevents_key_error(mesh_module):
"""The NodeInfo handler should operate safely when the ID field is absent."""
import meshtastic
from data.mesh_ingestor import interfaces
interfaces._patch_meshtastic_nodeinfo_handler()
assert getattr(
meshtastic.mesh_interface.NodeInfoHandler,
"_potato_mesh_safe_wrapper",
False,
), "Expected NodeInfoHandler to be replaced with a safe subclass"
handler = meshtastic.mesh_interface.NodeInfoHandler()
iface = types.SimpleNamespace(nodes={})
packet = {"decoded": {"user": {"id": "!01020304"}}}
result = handler.onReceive(iface, packet)
assert iface.nodes["!01020304"]["id"] == "!01020304"
assert result == "!01020304"
def test_interfaces_patch_handles_preimported_serial():
"""Regression: importing serial module before patch still updates handler."""
preserved_modules: dict[str, types.ModuleType | None] = {}
module_names = [
"data.mesh_ingestor.interfaces",
"data.mesh_ingestor",
"meshtastic.serial_interface",
"meshtastic.tcp_interface",
"meshtastic.mesh_interface",
"meshtastic",
]
for name in module_names:
preserved_modules[name] = sys.modules.pop(name, None)
try:
def _default_nodeinfo_callback(_iface, packet):
return packet["id"]
mesh_interface_mod = types.ModuleType("meshtastic.mesh_interface")
class DummyNodeInfoHandler:
"""Stub that mirrors Meshtastic's original handler semantics."""
def __init__(self) -> None:
self.callback = _default_nodeinfo_callback
def onReceive(self, iface, packet): # noqa: D401 - simple passthrough
return self.callback(iface, packet)
mesh_interface_mod.NodeInfoHandler = DummyNodeInfoHandler
serial_interface_mod = types.ModuleType("meshtastic.serial_interface")
class DummySerialInterface:
def __init__(self, *_, **__):
self.nodes = {}
def close(self): # noqa: D401 - mimic Meshtastic close API
self.nodes.clear()
serial_interface_mod.SerialInterface = DummySerialInterface
serial_interface_mod.NodeInfoHandler = DummyNodeInfoHandler
tcp_interface_mod = types.ModuleType("meshtastic.tcp_interface")
class DummyTCPInterface:
def __init__(self, *_, **__):
self.nodes = {}
def close(self): # noqa: D401 - mimic Meshtastic close API
self.nodes.clear()
tcp_interface_mod.TCPInterface = DummyTCPInterface
meshtastic_mod = types.ModuleType("meshtastic")
meshtastic_mod.__path__ = [] # mark as package for import machinery
meshtastic_mod._onNodeInfoReceive = _default_nodeinfo_callback
meshtastic_mod.mesh_interface = mesh_interface_mod
meshtastic_mod.serial_interface = serial_interface_mod
meshtastic_mod.tcp_interface = tcp_interface_mod
sys.modules["meshtastic"] = meshtastic_mod
sys.modules["meshtastic.mesh_interface"] = mesh_interface_mod
sys.modules["meshtastic.serial_interface"] = serial_interface_mod
sys.modules["meshtastic.tcp_interface"] = tcp_interface_mod
serial_module = importlib.import_module("meshtastic.serial_interface")
assert serial_module.NodeInfoHandler is DummyNodeInfoHandler
interfaces = importlib.import_module("data.mesh_ingestor.interfaces")
patched_handler = serial_module.NodeInfoHandler
assert patched_handler is not DummyNodeInfoHandler
assert getattr(patched_handler, "_potato_mesh_safe_wrapper", False)
handler = patched_handler()
iface = types.SimpleNamespace(nodes={})
assert handler.onReceive(iface, {}) is None
assert iface.nodes == {}
patched_callback = getattr(meshtastic_mod, "_onNodeInfoReceive")
assert getattr(patched_callback, "_potato_mesh_safe_wrapper", False)
assert interfaces.SerialInterface is DummySerialInterface
finally:
for name in module_names:
sys.modules.pop(name, None)
for name, module in preserved_modules.items():
if module is not None:
sys.modules[name] = module
def test_store_packet_dict_ignores_non_text(mesh_module, monkeypatch):
@@ -2073,6 +2254,24 @@ def test_post_json_logs_failures(mesh_module, monkeypatch, capsys):
assert "POST request failed" in captured.out
def test_queue_post_json_logs_payload_details(mesh_module, monkeypatch, capsys):
mesh = mesh_module
mesh._clear_post_queue()
monkeypatch.setattr(mesh, "DEBUG", True)
mesh._queue_post_json(
"/api/test",
{"alpha": "beta", "count": 7},
send=lambda *_: None,
)
out = capsys.readouterr().out
assert "Forwarding payload to API" in out
assert 'alpha="beta"' in out
assert "count=7" in out
def test_queue_post_json_skips_when_active(mesh_module, monkeypatch):
mesh = mesh_module
@@ -2128,6 +2327,25 @@ def test_upsert_node_logs_in_debug(mesh_module, monkeypatch, capsys):
assert "Queued node upsert payload" in out
def test_store_packet_dict_records_ignored_packets(mesh_module, monkeypatch, tmp_path):
mesh = mesh_module
monkeypatch.setattr(mesh, "DEBUG", True)
ignored_path = tmp_path / "ignored.txt"
monkeypatch.setattr(mesh.handlers, "_IGNORED_PACKET_LOG_PATH", ignored_path)
monkeypatch.setattr(mesh.handlers, "_IGNORED_PACKET_LOCK", threading.Lock())
packet = {"decoded": {"portnum": "UNKNOWN"}}
mesh.store_packet_dict(packet)
assert ignored_path.exists()
lines = ignored_path.read_text(encoding="utf-8").strip().splitlines()
assert lines
payload = json.loads(lines[-1])
assert payload["reason"] == "unsupported-port"
assert payload["packet"]["decoded"]["portnum"] == "UNKNOWN"
def test_coerce_int_and_float_cover_edge_cases(mesh_module):
mesh = mesh_module
+69
View File
@@ -0,0 +1,69 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Ensure version identifiers stay synchronised across all packages."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import data
def _ruby_fallback_version() -> str:
config_path = REPO_ROOT / "web" / "lib" / "potato_mesh" / "config.rb"
contents = config_path.read_text(encoding="utf-8")
inside = False
for line in contents.splitlines():
stripped = line.strip()
if stripped.startswith("def version_fallback"):
inside = True
continue
if inside and stripped == "end":
break
if inside:
literal = re.search(r"['\"](?P<version>[^'\"]+)['\"]", stripped)
if literal:
return literal.group("version")
raise AssertionError("Unable to locate version_fallback definition in config.rb")
def _javascript_package_version() -> str:
package_path = REPO_ROOT / "web" / "package.json"
data = json.loads(package_path.read_text(encoding="utf-8"))
version = data.get("version")
if isinstance(version, str):
return version
raise AssertionError("package.json does not expose a string version")
def test_version_identifiers_match_across_languages() -> None:
"""Guard against version drift between Python, Ruby, and JavaScript."""
python_version = getattr(data, "__version__", None)
assert (
isinstance(python_version, str) and python_version
), "data.__version__ missing"
ruby_version = _ruby_fallback_version()
javascript_version = _javascript_package_version()
assert python_version == ruby_version == javascript_version
+1 -2
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+14
View File
@@ -1,4 +1,17 @@
# syntax=docker/dockerfile:1.6
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main application builder stage
FROM ruby:3.3-alpine AS builder
@@ -78,6 +91,7 @@ ENV RACK_ENV=production \
CHANNEL="#LongFast" \
FREQUENCY="915MHz" \
MAP_CENTER="38.761944,-27.090833" \
MAP_ZOOM="" \
MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \
DEBUG=0
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+2
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
+1 -2
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
# Copyright (C) 2025 l5yth
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
+2
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -120,6 +122,7 @@ module PotatoMesh
lat: PotatoMesh::Config.map_center_lat,
lon: PotatoMesh::Config.map_center_lon,
},
mapZoom: PotatoMesh::Config.map_zoom,
maxDistanceKm: PotatoMesh::Config.max_distance_km,
tileFilters: PotatoMesh::Config.tile_filters,
instanceDomain: app_constant(:INSTANCE_DOMAIN),
@@ -156,6 +159,67 @@ module PotatoMesh
PotatoMesh::Meta.formatted_distance_km(distance)
end
# Build the canonical node detail path for the supplied identifier.
#
# @param identifier [String, nil] node identifier in ``!xxxx`` notation.
# @return [String, nil] detail path including the canonical ``!`` prefix.
def node_detail_path(identifier)
ident = string_or_nil(identifier)
return nil unless ident && !ident.empty?
trimmed = ident.strip
return nil if trimmed.empty?
body = trimmed.start_with?("!") ? trimmed[1..-1] : trimmed
return nil unless body && !body.empty?
escaped = Rack::Utils.escape_path(body)
"/nodes/!#{escaped}"
end
# Present a version string with a leading ``v`` when missing to keep
# UI labels consistent across tagged and fallback builds.
#
# @param version [String, nil] raw application version string.
# @return [String, nil] version string prefixed with ``v`` when needed.
def display_version(version)
return nil if version.nil? || version.to_s.strip.empty?
text = version.to_s.strip
text.start_with?("v") ? text : "v#{text}"
end
# Render a linked long name pointing to the node detail page.
#
# @param long_name [String] display name for the node.
# @param identifier [String, nil] canonical node identifier.
# @param css_class [String, nil] optional CSS class applied to the anchor.
# @return [String] escaped HTML snippet.
def node_long_name_link(long_name, identifier, css_class: "node-long-link")
text = string_or_nil(long_name)
return "" unless text
href = node_detail_path(identifier)
escaped_text = Rack::Utils.escape_html(text)
return escaped_text unless href
canonical_identifier = canonical_node_identifier(identifier)
class_attr = css_class ? %( class="#{css_class}") : ""
data_attrs = %( data-node-detail-link="true")
if canonical_identifier
escaped_identifier = Rack::Utils.escape_html(canonical_identifier)
data_attrs = %(#{data_attrs} data-node-id="#{escaped_identifier}")
end
%(<a#{class_attr} href="#{href}"#{data_attrs}>#{escaped_text}</a>)
end
# Normalise a node identifier by ensuring the canonical ``!`` prefix.
#
# @param identifier [String, nil] raw identifier string.
# @return [String, nil] canonical identifier or ``nil`` when unavailable.
def canonical_node_identifier(identifier)
ident = string_or_nil(identifier)
return nil unless ident && !ident.empty?
trimmed = ident.strip
return nil if trimmed.empty?
trimmed.start_with?("!") ? trimmed : "!#{trimmed}"
end
# Generate the meta description used in SEO tags.
#
# @return [String] combined descriptive sentence.
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
+13 -4
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -17,10 +19,12 @@ module PotatoMesh
module Queries
MAX_QUERY_LIMIT = 1000
# Remove nil or empty values from an API response hash to reduce payload size.
# Remove nil or empty values from an API response hash to reduce payload size
# while preserving legitimate zero-valued measurements.
# Integer keys emitted by SQLite are ignored because the JSON representation
# only exposes symbolic keys. Strings containing only whitespace are treated
# as empty to mirror sanitisation elsewhere in the application.
# as empty to mirror sanitisation elsewhere in the application, and any other
# objects responding to `empty?` are dropped when they contain no data.
#
# @param row [Hash] raw database row to compact.
# @return [Hash] cleaned hash without blank values.
@@ -211,7 +215,7 @@ module PotatoMesh
db&.close
end
def query_messages(limit, node_ref: nil)
def query_messages(limit, node_ref: nil, include_encrypted: false)
limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
@@ -219,11 +223,16 @@ module PotatoMesh
where_clauses = [
"(COALESCE(TRIM(m.text), '') != '' OR COALESCE(TRIM(m.encrypted), '') != '' OR m.reply_id IS NOT NULL OR COALESCE(TRIM(m.emoji), '') != '')",
]
include_encrypted = !!include_encrypted
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
where_clauses << "m.rx_time >= ?"
params << min_rx_time
unless include_encrypted
where_clauses << "COALESCE(TRIM(m.encrypted), '') = ''"
end
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["m.from_id", "m.to_id"])
return [] unless clause
@@ -282,7 +291,7 @@ module PotatoMesh
)
end
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
+11 -2
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -16,6 +18,11 @@ module PotatoMesh
module App
module Routes
module Api
# Register read-only API endpoints that expose cached mesh data and
# instance metadata. Invoked by Sinatra during extension registration.
#
# @param app [Sinatra::Base] application instance receiving the routes.
# @return [void]
def self.registered(app)
app.before "/api/messages*" do
halt 404 if private_mode?
@@ -73,7 +80,8 @@ module PotatoMesh
app.get "/api/messages" do
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_messages(limit).to_json
include_encrypted = coerce_boolean(params["encrypted"]) || false
query_messages(limit, include_encrypted: include_encrypted).to_json
end
app.get "/api/messages/:id" do
@@ -81,7 +89,8 @@ module PotatoMesh
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
limit = [params["limit"]&.to_i || 200, 1000].min
query_messages(limit, node_ref: node_ref).to_json
include_encrypted = coerce_boolean(params["encrypted"]) || false
query_messages(limit, node_ref: node_ref, include_encrypted: include_encrypted).to_json
end
app.get "/api/positions" do
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -16,6 +18,11 @@ module PotatoMesh
module App
module Routes
module Ingest
# Register ingest endpoints used by the Python collector to persist
# nodes, messages, and federation announcements.
#
# @param app [Sinatra::Base] application instance receiving the routes.
# @return [void]
def self.registered(app)
app.post "/api/nodes" do
require_token!
+171 -26
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -16,7 +18,137 @@ module PotatoMesh
module App
module Routes
module Root
module Helpers
# Determine the initial theme from the request cookie and persist
# sanitised values back to the client to avoid invalid states.
#
# @return [String] normalised theme value ('dark' or 'light').
def resolve_initial_theme
raw_theme = request.cookies["theme"]
theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
if raw_theme != theme
response.set_cookie(
"theme",
value: theme,
path: "/",
max_age: 60 * 60 * 24 * 7,
same_site: :lax,
)
end
theme
end
# Render a dashboard-oriented ERB template within the shared layout.
#
# @param template [Symbol] identifier for the ERB template.
# @param view_mode [Symbol, String] logical view identifier for CSS hooks.
# @param extra_locals [Hash] additional locals merged into the rendering context.
# @return [String] rendered ERB output.
def render_root_view(template, view_mode: :dashboard, extra_locals: {})
meta = meta_configuration
config = frontend_app_config
theme = resolve_initial_theme
view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
base_locals = {
site_name: meta[:name],
meta_title: meta[:title],
meta_name: meta[:name],
meta_description: meta[:description],
channel: sanitized_channel,
frequency: sanitized_frequency,
map_center_lat: PotatoMesh::Config.map_center_lat,
map_center_lon: PotatoMesh::Config.map_center_lon,
max_distance_km: PotatoMesh::Config.max_distance_km,
contact_link: sanitized_contact_link,
contact_link_url: sanitized_contact_link_url,
version: display_version(app_constant(:APP_VERSION)),
private_mode: private_mode?,
federation_enabled: federation_enabled?,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
current_view_mode: view_mode_sym,
map_zoom: PotatoMesh::Config.map_zoom,
}
sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {}
merged_locals = base_locals.merge(sanitized_locals)
erb template, layout: :"layouts/app", locals: merged_locals
end
# Remove keys with +nil+ values from the provided hash, returning a
# shallow copy. Hash#compact is only available in newer Ruby
# versions; this helper keeps behaviour consistent across supported
# releases.
#
# @param value [Hash, nil] collection subject to filtering.
# @return [Hash] hash excluding +nil+ values.
def reject_nil_values(value)
return {} unless value.is_a?(Hash)
value.each_with_object({}) do |(key, entry), memo|
memo[key] = entry unless entry.nil?
end
end
# Assemble the payload embedded into the node detail view. The
# payload provides a canonical identifier alongside any cached node,
# telemetry, or position rows that may already exist in the
# database. When no persisted data is available the method returns
# +nil+ so the caller can surface a 404 error.
#
# @param node_ref [Object] raw node identifier from the request.
# @return [Hash, nil] structured node reference payload or nil when
# the node cannot be located.
def build_node_detail_reference(node_ref)
tokens = canonical_node_parts(node_ref)
search_ref = tokens ? tokens.first : node_ref
node_row = query_nodes(1, node_ref: search_ref).first
telemetry_row = query_telemetry(1, node_ref: search_ref).first
position_row = query_positions(1, node_ref: search_ref).first
candidates = [node_row, telemetry_row, position_row].compact
return nil if candidates.empty?
canonical_id = string_or_nil(node_row&.fetch("node_id", nil))
canonical_id ||= string_or_nil(telemetry_row&.fetch("node_id", nil))
canonical_id ||= string_or_nil(position_row&.fetch("node_id", nil))
canonical_id ||= string_or_nil(tokens&.fetch(0, nil))
if canonical_id
canonical_id = canonical_id.start_with?("!") ? canonical_id : "!#{canonical_id}"
end
return nil unless canonical_id
numeric_id = coerce_integer(node_row&.fetch("num", nil))
numeric_id ||= coerce_integer(telemetry_row&.fetch("node_num", nil))
numeric_id ||= coerce_integer(position_row&.fetch("node_num", nil))
numeric_id ||= tokens&.fetch(1, nil)
short_id = string_or_nil(node_row&.fetch("short_name", nil))
short_id ||= string_or_nil(telemetry_row&.fetch("short_name", nil))
short_id ||= string_or_nil(position_row&.fetch("short_name", nil))
short_id ||= tokens&.fetch(2, nil)
fallback_row = node_row || telemetry_row || position_row
fallback = fallback_row ? compact_api_row(fallback_row) : nil
telemetry = telemetry_row ? compact_api_row(telemetry_row) : nil
position = position_row ? compact_api_row(position_row) : nil
{
"nodeId" => canonical_id,
"nodeNum" => numeric_id,
"shortId" => short_id,
"fallback" => fallback,
"telemetry" => telemetry,
"position" => position,
}
end
end
def self.registered(app)
app.helpers Helpers
app.get "/favicon.ico" do
cache_control :public, max_age: PotatoMesh::Config.week_seconds
ico_path = File.join(settings.public_folder, "favicon.ico")
@@ -39,34 +171,47 @@ module PotatoMesh
end
app.get "/" do
meta = meta_configuration
config = frontend_app_config
render_root_view(:index, view_mode: :dashboard)
end
raw_theme = request.cookies["theme"]
theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
if raw_theme != theme
response.set_cookie("theme", value: theme, path: "/", max_age: 60 * 60 * 24 * 7, same_site: :lax)
end
app.get %r{/map/?} do
render_root_view(:map, view_mode: :map)
end
erb :index, locals: {
site_name: meta[:name],
meta_title: meta[:title],
meta_name: meta[:name],
meta_description: meta[:description],
channel: sanitized_channel,
frequency: sanitized_frequency,
map_center_lat: PotatoMesh::Config.map_center_lat,
map_center_lon: PotatoMesh::Config.map_center_lon,
max_distance_km: PotatoMesh::Config.max_distance_km,
contact_link: sanitized_contact_link,
contact_link_url: sanitized_contact_link_url,
version: app_constant(:APP_VERSION),
private_mode: private_mode?,
federation_enabled: federation_enabled?,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
}
app.get %r{/chat/?} do
render_root_view(:chat, view_mode: :chat)
end
app.get %r{/charts/?} do
render_root_view(:charts, view_mode: :charts)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
halt 404, "Not Found" unless reference_payload
fallback = reference_payload["fallback"] || {}
short_name = string_or_nil(fallback["short_name"]) || reference_payload["shortId"]
long_name = string_or_nil(fallback["long_name"])
role = string_or_nil(fallback["role"])
canonical_id = string_or_nil(reference_payload["nodeId"])
render_root_view(
:node_detail,
view_mode: :node_detail,
extra_locals: {
node_reference_json: JSON.generate(reject_nil_values(reference_payload)),
node_page_short_name: short_name,
node_page_long_name: long_name,
node_page_role: role,
node_page_identifier: canonical_id,
},
)
end
app.get %r{/nodes/?} do
render_root_view(:nodes, view_mode: :nodes)
end
app.get "/metrics" do
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
+17 -1
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
@@ -173,7 +175,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"v0.5.4"
"0.5.5"
end
# Default refresh interval for frontend polling routines.
@@ -475,6 +477,20 @@ module PotatoMesh
map_center[:lon]
end
# Retrieve an explicit map zoom override when provided.
#
# @return [Float, nil] positive zoom value or +nil+ when unset.
def map_zoom
raw = fetch_string("MAP_ZOOM", nil)
return nil unless raw
zoom = Float(raw, exception: false)
return nil unless zoom
return nil unless zoom.positive?
zoom
end
# Maximum straight-line distance between nodes before relationships are
# hidden.
#
+14
View File
@@ -1,3 +1,17 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "logger"
+2
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
+2
View File
@@ -1,3 +1,5 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.0",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.0",
"version": "0.5.5",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.5.0",
"version": "0.5.5",
"type": "module",
"private": true,
"scripts": {
@@ -0,0 +1,149 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
fetchAggregatedTelemetry,
initializeChartsPage,
buildMovingAverageSeries,
} from '../charts-page.js';
function createResponse(status, body) {
return {
ok: status >= 200 && status < 300,
status,
async json() {
return body;
},
};
}
test('fetchAggregatedTelemetry requests the latest 1000 telemetry entries', async () => {
const requests = [];
const fetchImpl = async url => {
requests.push(url);
return createResponse(200, [{ rx_time: 1_700_000_000, node_id: '!demo' }]);
};
const snapshots = await fetchAggregatedTelemetry({ fetchImpl });
assert.equal(requests.length, 1);
assert.equal(requests[0], '/api/telemetry?limit=1000');
assert.equal(Array.isArray(snapshots), true);
assert.equal(snapshots[0].node_id, '!demo');
});
test('fetchAggregatedTelemetry validates fetch availability and response codes', async () => {
await assert.rejects(() => fetchAggregatedTelemetry({ fetchImpl: null }), /fetch implementation/i);
const fetchImpl = async () => createResponse(503, []);
await assert.rejects(() => fetchAggregatedTelemetry({ fetchImpl }), /Failed to fetch telemetry/);
});
test('initializeChartsPage renders the telemetry charts when snapshots are available', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById(id) {
return id === 'chartsPage' ? container : null;
},
};
const fetchImpl = async () => createResponse(200, [{ rx_time: 1_700_000_000, temperature: 22.5 }]);
let receivedOptions = null;
const renderCharts = (node, options) => {
receivedOptions = options;
return '<section class="node-detail__charts">Charts</section>';
};
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('node-detail__charts'), true);
assert.ok(receivedOptions);
assert.equal(receivedOptions.chartOptions.windowMs, 86_400_000);
assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
const average = receivedOptions.chartOptions.lineReducer(
[
{ timestamp: 0, value: 0 },
{ timestamp: 1_800_000, value: 10 },
{ timestamp: 3_600_000, value: 20 },
],
);
assert.equal(Array.isArray(average), true);
});
test('initializeChartsPage shows an error message when fetching fails', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById() {
return container;
},
};
const fetchImpl = async () => {
throw new Error('network');
};
const renderCharts = () => '<section>unused</section>';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, false);
assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
});
test('initializeChartsPage handles missing containers and empty telemetry snapshots', async () => {
const documentMissing = { getElementById() { return null; } };
const noneResult = await initializeChartsPage({ document: documentMissing });
assert.equal(noneResult, false);
const container = { innerHTML: '' };
const documentStub = {
getElementById() {
return container;
},
};
const fetchImpl = async () => createResponse(200, []);
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
test('initializeChartsPage shows a status when rendering produces no markup', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById() {
return container;
},
};
const fetchImpl = async () => createResponse(200, [{ rx_time: 1_700_000_000 }]);
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
test('initializeChartsPage validates the document contract', async () => {
await assert.rejects(() => initializeChartsPage({ document: {} }), /getElementById/);
});
test('buildMovingAverageSeries computes a rolling mean across the window', () => {
const points = [
{ timestamp: 0, value: 0 },
{ timestamp: 30 * 60 * 1000, value: 30 },
{ timestamp: 60 * 60 * 1000, value: 60 },
{ timestamp: 90 * 60 * 1000, value: 90 },
];
const averages = buildMovingAverageSeries(points, 60 * 60 * 1000);
assert.equal(averages.length, points.length);
assert.equal(Math.round(averages[0].value), 0);
assert.equal(Math.round(averages[1].value), 15);
assert.equal(Math.round(averages[2].value), 30);
assert.equal(Math.round(averages[3].value), 60);
});
@@ -1,4 +1,6 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -40,6 +40,8 @@ function fixtureNodes() {
function fixtureMessages() {
return [
{ id: 'recent-default', rx_time: NOW - 5, channel: 0, channel_name: ' MediumFast ' },
{ id: 'primary-preset', rx_time: NOW - 8, channel: 0, modem_preset: ' ShortFast ' },
{ id: 'env-default', rx_time: NOW - 12, channel: 0 },
{ id: 'recent-alt', rx_time: NOW - 10, channel_index: '1', channel_name: ' BerlinMesh ' },
{ id: 'stale', rx_time: NOW - WINDOW - 5, channel: 2 },
{ id: 'encrypted', rx_time: NOW - 20, channel: 3, encrypted: true },
@@ -55,6 +57,7 @@ function buildModel(overrides = {}) {
messages: fixtureMessages(),
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: '#EnvDefault',
...overrides
});
}
@@ -72,23 +75,39 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
['recent-node', 'iso-node', 'encrypted']
);
assert.equal(model.channels.length, 3);
const [fallbackChannel, namedPrimaryChannel, secondaryChannel] = model.channels;
assert.equal(model.channels.length, 5);
assert.deepEqual(model.channels.map(channel => channel.label), [
'EnvDefault',
'Fallback',
'MediumFast',
'ShortFast',
'BerlinMesh'
]);
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
const envChannel = channelByLabel.EnvDefault;
assert.equal(envChannel.index, 0);
assert.equal(envChannel.id, 'channel-0-envdefault');
assert.deepEqual(envChannel.entries.map(entry => entry.message.id), ['env-default']);
const fallbackChannel = channelByLabel.Fallback;
assert.equal(fallbackChannel.index, 0);
assert.equal(fallbackChannel.label, 'Fallback');
assert.equal(fallbackChannel.id, 'channel-0-fallback');
assert.equal(fallbackChannel.entries.length, 1);
assert.deepEqual(fallbackChannel.entries.map(entry => entry.message.id), ['no-index']);
const namedPrimaryChannel = channelByLabel.MediumFast;
assert.equal(namedPrimaryChannel.index, 0);
assert.equal(namedPrimaryChannel.label, 'MediumFast');
assert.equal(namedPrimaryChannel.id, 'channel-0-mediumfast');
assert.equal(namedPrimaryChannel.entries.length, 1);
assert.deepEqual(namedPrimaryChannel.entries.map(entry => entry.message.id), ['recent-default']);
const presetChannel = channelByLabel.ShortFast;
assert.equal(presetChannel.index, 0);
assert.equal(presetChannel.id, 'channel-0-shortfast');
assert.deepEqual(presetChannel.entries.map(entry => entry.message.id), ['primary-preset']);
const secondaryChannel = channelByLabel.BerlinMesh;
assert.equal(secondaryChannel.index, 1);
assert.equal(secondaryChannel.label, 'BerlinMesh');
assert.equal(secondaryChannel.id, 'channel-1');
assert.equal(secondaryChannel.entries.length, 2);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
@@ -101,6 +120,19 @@ test('buildChatTabModel always includes channel zero bucket', () => {
assert.equal(model.channels[0].entries.length, 0);
});
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
const model = buildChatTabModel({
nodes: [],
messages: [{ id: 'plain', rx_time: NOW - 5, channel: 0 }],
nowSeconds: NOW,
windowSeconds: WINDOW,
primaryChannelFallbackLabel: ''
});
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].label, '0');
assert.equal(model.channels[0].id, 'channel-0');
});
test('normaliseChannelIndex handles numeric and textual input', () => {
assert.equal(normaliseChannelIndex(2.9), 2);
assert.equal(normaliseChannelIndex(' 7 '), 7);
@@ -152,3 +184,68 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
const lastEntry = model.logEntries[model.logEntries.length - 1];
assert.equal(lastEntry.neighborId, neighborId);
});
test('buildChatTabModel merges dedicated encrypted log feed without altering channels', () => {
const regularMessages = fixtureMessages().filter(message => !message.encrypted);
const encryptedOnly = [
{ id: 'log-only', encrypted: true, rx_time: NOW - 3, channel: 7 }
];
const model = buildChatTabModel({
nodes: [],
messages: regularMessages,
logOnlyMessages: encryptedOnly,
nowSeconds: NOW,
windowSeconds: WINDOW
});
const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED);
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'log-only');
const channelMessageIds = model.channels.reduce((acc, channel) => {
if (!channel || !Array.isArray(channel.entries)) {
return acc;
}
for (const entry of channel.entries) {
if (entry && entry.message && entry.message.id) {
acc.push(entry.message.id);
}
}
return acc;
}, []);
assert.ok(!channelMessageIds.includes('log-only'));
});
test('buildChatTabModel de-duplicates encrypted messages across feeds', () => {
const duplicateMessage = { id: 'dup', encrypted: true, rx_time: NOW - 4 };
const model = buildChatTabModel({
nodes: [],
messages: [duplicateMessage],
logOnlyMessages: [duplicateMessage],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED);
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'dup');
});
test('buildChatTabModel ignores plaintext log-only entries', () => {
const logOnlyMessages = [
{ id: 'plain', encrypted: false, rx_time: NOW - 5 },
{ id: 'enc', encrypted: true, rx_time: NOW - 4 }
];
const model = buildChatTabModel({
nodes: [],
messages: [],
logOnlyMessages,
nowSeconds: NOW,
windowSeconds: WINDOW
});
const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED);
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'enc');
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,4 +1,6 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@@ -79,6 +81,7 @@ test('mergeConfig coerces numeric values and nested objects', () => {
refreshMs: '45000',
mapCenter: { lat: '10.5', lon: '20.1' },
tileFilters: { dark: 'contrast(2)' },
mapZoom: '12',
chatEnabled: 0,
channel: '#Custom',
frequency: '915MHz',
@@ -91,6 +94,7 @@ test('mergeConfig coerces numeric values and nested objects', () => {
assert.equal(result.refreshMs, 45000);
assert.deepEqual(result.mapCenter, { lat: 10.5, lon: 20.1 });
assert.deepEqual(result.tileFilters, { light: DEFAULT_CONFIG.tileFilters.light, dark: 'contrast(2)' });
assert.equal(result.mapZoom, 12);
assert.equal(result.chatEnabled, false);
assert.equal(result.channel, '#Custom');
assert.equal(result.frequency, '915MHz');
@@ -103,12 +107,19 @@ test('mergeConfig falls back to defaults for invalid numeric values', () => {
const result = mergeConfig({
refreshIntervalSeconds: 'NaN',
refreshMs: 'NaN',
maxDistanceKm: 'oops'
maxDistanceKm: 'oops',
mapZoom: 'not-a-number'
});
assert.equal(result.refreshIntervalSeconds, DEFAULT_CONFIG.refreshIntervalSeconds);
assert.equal(result.refreshMs, DEFAULT_CONFIG.refreshMs);
assert.equal(result.maxDistanceKm, DEFAULT_CONFIG.maxDistanceKm);
assert.equal(result.mapZoom, null);
});
test('mergeConfig treats blank mapZoom as null', () => {
const result = mergeConfig({ mapZoom: '' });
assert.equal(result.mapZoom, null);
});
test('document stub returns null for unrelated selectors', () => {
@@ -1,4 +1,6 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,4 +1,6 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
@@ -1,5 +1,5 @@
/**
* Copyright (C) 2025 l5yth
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -0,0 +1,47 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveAutoFitBoundsConfig, __testUtils } from '../map-auto-fit-settings.js';
const { MINIMUM_AUTO_FIT_RANGE_KM, AUTO_FIT_PADDING_FRACTION } = __testUtils;
test('resolveAutoFitBoundsConfig returns defaults without a distance limit', () => {
const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: false, maxDistanceKm: null });
assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION);
assert.equal(config.minimumRangeKm, MINIMUM_AUTO_FIT_RANGE_KM);
});
test('resolveAutoFitBoundsConfig constrains minimum range by the limit radius', () => {
const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: true, maxDistanceKm: 2 });
assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION);
assert.ok(config.minimumRangeKm >= MINIMUM_AUTO_FIT_RANGE_KM);
assert.ok(config.minimumRangeKm <= 2);
});
test('resolveAutoFitBoundsConfig respects small distance limits', () => {
const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: true, maxDistanceKm: 0.1 });
assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION);
assert.equal(config.minimumRangeKm, 0.1);
});
test('resolveAutoFitBoundsConfig tolerates invalid input', () => {
const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: true, maxDistanceKm: -5 });
assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION);
assert.equal(config.minimumRangeKm, MINIMUM_AUTO_FIT_RANGE_KM);
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -0,0 +1,41 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { MESSAGE_LIMIT, normaliseMessageLimit } from '../message-limit.js';
test('normaliseMessageLimit defaults to the message limit for invalid input', () => {
assert.equal(normaliseMessageLimit(undefined), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit(null), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit(''), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit('abc'), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit(-100), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit(0), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit(Number.POSITIVE_INFINITY), MESSAGE_LIMIT);
});
test('normaliseMessageLimit clamps numeric input to the upper bound', () => {
assert.equal(normaliseMessageLimit(MESSAGE_LIMIT + 1), MESSAGE_LIMIT);
assert.equal(normaliseMessageLimit(MESSAGE_LIMIT * 2), MESSAGE_LIMIT);
});
test('normaliseMessageLimit accepts positive finite values', () => {
assert.equal(normaliseMessageLimit(250), 250);
assert.equal(normaliseMessageLimit('750'), 750);
assert.equal(normaliseMessageLimit(42.9), 42);
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -73,3 +73,32 @@ test('resolveReplyPrefix renders reply badge and buildMessageBody joins emoji',
assert.equal(body, 'ESC(Hello) EMOJI(🔥)');
});
test('buildMessageBody suppresses reaction slot markers and formats counts', () => {
const reaction = {
text: ' 1 ',
emoji: '👍',
portnum: 'REACTION_APP',
reply_id: 123,
};
const body = buildMessageBody({
message: reaction,
escapeHtml: value => `ESC(${value})`,
renderEmojiHtml: value => `EMOJI(${value})`
});
assert.equal(body, 'EMOJI(👍)');
const countedReaction = {
text: '2',
emoji: '✨',
reply_id: 123
};
const countedBody = buildMessageBody({
message: countedReaction,
escapeHtml: value => `ESC(${value})`,
renderEmojiHtml: value => `EMOJI(${value})`
});
assert.equal(countedBody, 'ESC(×2) EMOJI(✨)');
});
@@ -0,0 +1,134 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNodeDetailOverlayManager } from '../node-detail-overlay.js';
function createOverlayHarness() {
const overlayListeners = new Map();
const documentListeners = new Map();
const content = { innerHTML: '' };
const closeButton = {
listeners: new Map(),
focusCalled: false,
addEventListener(event, handler) {
this.listeners.set(event, handler);
},
click() {
const handler = this.listeners.get('click');
if (handler) handler({ preventDefault() {} });
},
focus() {
this.focusCalled = true;
},
};
const dialog = {
focusCalled: false,
focus() {
this.focusCalled = true;
},
};
const overlay = {
hidden: true,
style: {},
addEventListener(event, handler) {
overlayListeners.set(event, handler);
},
trigger(event, payload) {
const handler = overlayListeners.get(event);
if (handler) handler(payload);
},
querySelector(selector) {
if (selector === '.node-detail-overlay__dialog') return dialog;
if (selector === '.node-detail-overlay__close') return closeButton;
if (selector === '.node-detail-overlay__content') return content;
return null;
},
};
const body = {
style: {
overflow: '',
removeProperty(prop) {
this[prop] = '';
},
},
};
const document = {
body,
getElementById(id) {
return id === 'nodeDetailOverlay' ? overlay : null;
},
addEventListener(event, handler) {
documentListeners.set(event, handler);
},
removeEventListener(event) {
documentListeners.delete(event);
},
triggerKeydown(key) {
const handler = documentListeners.get('keydown');
if (handler) {
handler({ key, preventDefault() {} });
}
},
};
return { document, overlay, content, closeButton };
}
test('createNodeDetailOverlayManager renders fetched markup and restores focus', async () => {
const { document, overlay, content, closeButton } = createOverlayHarness();
const focusTarget = {
focusCalled: false,
focus() {
this.focusCalled = true;
},
};
const manager = createNodeDetailOverlayManager({
document,
fetchNodeDetail: async reference => `<section class="node-detail">${reference.nodeId}</section>`,
});
assert.ok(manager);
await manager.open({ nodeId: '!alpha' }, { trigger: focusTarget, label: 'Alpha' });
assert.equal(overlay.hidden, false);
assert.equal(content.innerHTML.includes('!alpha'), true);
assert.equal(closeButton.focusCalled, true);
manager.close();
assert.equal(overlay.hidden, true);
assert.equal(focusTarget.focusCalled, true);
});
test('createNodeDetailOverlayManager surfaces errors and supports escape closing', async () => {
const { document, overlay, content } = createOverlayHarness();
const errors = [];
const manager = createNodeDetailOverlayManager({
document,
fetchNodeDetail: async () => {
throw new Error('boom');
},
logger: {
error(err) {
errors.push(err);
},
},
});
assert.ok(manager);
await manager.open({ nodeId: '!fail' });
assert.equal(content.innerHTML.includes('Failed to load node details.'), true);
assert.equal(errors.length, 1);
document.triggerKeydown?.('Escape');
assert.equal(overlay.hidden, true);
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -45,7 +45,7 @@ function createResponse(status, body) {
test('refreshNodeInformation merges telemetry metrics when the base node lacks them', async () => {
const calls = [];
const responses = new Map([
['/api/nodes/!test', createResponse(200, {
['/api/nodes/!test?limit=7', createResponse(200, {
node_id: '!test',
short_name: 'TST',
battery_level: null,
@@ -53,14 +53,14 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
modem_preset: 'MediumFast',
lora_freq: '868.1',
})],
['/api/telemetry/!test?limit=1', createResponse(200, [{
['/api/telemetry/!test?limit=1000', createResponse(200, [{
node_id: '!test',
battery_level: 73.5,
rx_time: 1_200,
telemetry_time: 1_180,
voltage: 4.1,
}])],
['/api/positions/!test?limit=1', createResponse(200, [{
['/api/positions/!test?limit=7', createResponse(200, [{
node_id: '!test',
latitude: 52.5,
longitude: 13.4,
@@ -113,14 +113,38 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
});
});
test('refreshNodeInformation normalizes telemetry aliases for downstream consumers', async () => {
const responses = new Map([
['/api/nodes/!chan?limit=7', createResponse(404, { error: 'not found' })],
['/api/telemetry/!chan?limit=1000', createResponse(200, [{
node_id: '!chan',
channel: '76.5',
air_util_tx: '12.25',
}])],
['/api/positions/!chan?limit=7', createResponse(404, { error: 'not found' })],
['/api/neighbors/!chan?limit=1000', createResponse(404, { error: 'not found' })],
]);
const fetchImpl = async url => responses.get(url) ?? createResponse(404, { error: 'not found' });
const node = await refreshNodeInformation('!chan', { fetchImpl });
assert.equal(node.nodeId, '!chan');
assert.equal(node.channel_utilization, 76.5);
assert.equal(node.channelUtilization, 76.5);
assert.equal(node.channel, 76.5);
assert.equal(node.air_util_tx, 12.25);
assert.equal(node.airUtil, 12.25);
assert.equal(node.airUtilTx, 12.25);
});
test('refreshNodeInformation preserves fallback metrics when telemetry is unavailable', async () => {
const responses = new Map([
['/api/nodes/42', createResponse(200, {
['/api/nodes/42?limit=7', createResponse(200, {
node_id: '!num',
short_name: 'NUM',
})],
['/api/telemetry/42?limit=1', createResponse(404, { error: 'not found' })],
['/api/positions/42?limit=1', createResponse(404, { error: 'not found' })],
['/api/telemetry/42?limit=1000', createResponse(404, { error: 'not found' })],
['/api/positions/42?limit=7', createResponse(404, { error: 'not found' })],
['/api/neighbors/42?limit=1000', createResponse(404, { error: 'not found' })],
]);
const fetchImpl = async (url, options) => {
@@ -147,15 +171,15 @@ test('refreshNodeInformation requires a node identifier', async () => {
test('refreshNodeInformation handles missing node records by falling back to telemetry data', async () => {
const responses = new Map([
['/api/nodes/!missing', createResponse(404, { error: 'not found' })],
['/api/telemetry/!missing?limit=1', createResponse(200, [{
['/api/nodes/!missing?limit=7', createResponse(404, { error: 'not found' })],
['/api/telemetry/!missing?limit=1000', createResponse(200, [{
node_id: '!missing',
node_num: 77,
battery_level: 66,
rx_time: 2_000,
telemetry_time: 1_950,
}])],
['/api/positions/!missing?limit=1', createResponse(200, [{
['/api/positions/!missing?limit=7', createResponse(200, [{
node_id: '!missing',
latitude: 1.23,
longitude: 3.21,
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -0,0 +1,649 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { initializeNodeDetailPage, fetchNodeDetailHtml, __testUtils } from '../node-page.js';
const {
stringOrNull,
numberOrNull,
formatFrequency,
formatBattery,
formatVoltage,
formatUptime,
formatTimestamp,
formatMessageTimestamp,
formatHardwareModel,
formatCoordinate,
formatRelativeSeconds,
formatDurationSeconds,
formatSnr,
padTwo,
normalizeNodeId,
registerRoleCandidate,
lookupRole,
lookupNeighborDetails,
seedNeighborRoleIndex,
buildNeighborRoleIndex,
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
renderTelemetryCharts,
renderMessages,
renderNodeDetailHtml,
parseReferencePayload,
resolveRenderShortHtml,
fetchMessages,
} = __testUtils;
test('format helpers normalise values as expected', () => {
assert.equal(stringOrNull(' foo '), 'foo');
assert.equal(stringOrNull(''), null);
assert.equal(numberOrNull('42'), 42);
assert.equal(numberOrNull('abc'), null);
assert.equal(formatFrequency(915), '915.000 MHz');
assert.equal(formatFrequency('2400000'), '2.400 MHz');
assert.equal(formatFrequency('custom'), 'custom');
assert.equal(formatBattery(87.135), '87.1%');
assert.equal(formatVoltage(4.105), '4.11 V');
assert.equal(formatUptime(3661), '1h 1m 1s');
assert.match(formatTimestamp(1_700_000_000), /T/);
assert.equal(padTwo(3), '03');
assert.equal(normalizeNodeId('!NODE'), '!node');
const messageTimestamp = formatMessageTimestamp(1_700_000_000);
assert.match(messageTimestamp, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
});
test('role lookup helpers normalise identifiers and register candidates', () => {
const index = { byId: new Map(), byNum: new Map() };
registerRoleCandidate(index, {
identifier: '!NODE',
numericId: 77,
role: 'ROUTER',
shortName: 'NODE',
longName: 'Node Long',
});
assert.equal(index.byId.get('!node'), 'ROUTER');
assert.equal(index.byNum.get(77), 'ROUTER');
assert.equal(lookupRole(index, { identifier: '!node' }), 'ROUTER');
assert.equal(lookupRole(index, { identifier: '!NODE' }), 'ROUTER');
assert.equal(lookupRole(index, { numericId: 77 }), 'ROUTER');
assert.equal(lookupRole(index, { identifier: '!missing' }), null);
const metadata = lookupNeighborDetails(index, { identifier: '!node', numericId: 77 });
assert.deepEqual(metadata, { role: 'ROUTER', shortName: 'NODE', longName: 'Node Long' });
});
test('seedNeighborRoleIndex captures known roles and missing identifiers', () => {
const index = { byId: new Map(), byNum: new Map() };
const missing = seedNeighborRoleIndex(index, [
{ neighbor_id: '!ALLY', neighbor_role: 'CLIENT', neighbor_short_name: 'ALLY' },
{ node_id: '!self', node_role: 'ROUTER' },
{ neighbor_id: '!unknown' },
]);
assert.equal(index.byId.get('!ally'), 'CLIENT');
assert.equal(index.byId.get('!self'), 'ROUTER');
assert.equal(missing.has('!unknown'), true);
const allyDetails = lookupNeighborDetails(index, { identifier: '!ally' });
assert.equal(allyDetails.shortName, 'ALLY');
});
test('additional format helpers provide table friendly output', () => {
assert.equal(formatHardwareModel('UNSET'), '');
assert.equal(formatHardwareModel('T-Beam'), 'T-Beam');
assert.equal(formatCoordinate(52.123456), '52.12346');
assert.equal(formatCoordinate(null), '');
assert.equal(formatRelativeSeconds(1_000, 1_060), '1m');
assert.equal(formatRelativeSeconds(1_000, 1_120), '2m');
assert.equal(formatRelativeSeconds(1_000, 1_000 + 3_700), '1h 1m');
assert.equal(formatRelativeSeconds(1_000, 1_000 + 90_000).startsWith('1d'), true);
assert.equal(formatDurationSeconds(59), '59s');
assert.equal(formatDurationSeconds(61), '1m 1s');
assert.equal(formatDurationSeconds(3_661), '1h 1m');
assert.equal(formatDurationSeconds(172_800), '2d');
assert.equal(formatSnr(12.345), '12.3 dB');
assert.equal(formatSnr(null), '');
const renderShortHtml = (short, role) => `<span class="short-name" data-role="${role}">${short}</span>`;
const nodeContext = {
shortName: 'NODE',
longName: 'Node Long',
role: 'CLIENT',
nodeId: '!node',
nodeNum: 77,
rawSources: { node: { node_id: '!node', role: 'CLIENT', short_name: 'NODE' } },
};
const messagesHtml = renderMessages(
[
{
text: 'hello',
rx_time: 1_700_000_400,
region_frequency: 868,
modem_preset: 'MediumFast',
channel_name: 'Primary',
node: { short_name: 'SRCE', role: 'ROUTER', node_id: '!src' },
},
{ emoji: '😊', rx_time: 1_700_000_401 },
],
renderShortHtml,
nodeContext,
);
assert.equal(messagesHtml.includes('hello'), true);
assert.equal(messagesHtml.includes('😊'), true);
assert.match(messagesHtml, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\[868\]/);
assert.equal(messagesHtml.includes('[868]'), true);
assert.equal(messagesHtml.includes('[MF]'), true);
assert.equal(messagesHtml.includes('[Primary]'), true);
assert.equal(messagesHtml.includes('data-role="ROUTER"'), true);
assert.equal(messagesHtml.includes('&nbsp;&nbsp;&nbsp;'), true);
assert.equal(messagesHtml.includes('&nbsp;&nbsp;'), true);
assert.equal(messagesHtml.includes('data-role="CLIENT"'), true);
assert.equal(messagesHtml.includes(', hello'), false);
});
test('categoriseNeighbors splits inbound and outbound records', () => {
const node = { nodeId: '!self', nodeNum: 42 };
const neighbors = [
{ node_id: '!self', neighbor_id: '!ally-one' },
{ node_id: '!peer', neighbor_id: '!SELF' },
{ node_num: 42, neighbor_id: '!ally-two' },
{ node_id: '!friend', neighbor_num: 42 },
null,
];
const { heardBy, weHear } = categoriseNeighbors(node, neighbors);
assert.equal(heardBy.length, 2);
assert.equal(weHear.length, 2);
});
test('renderNeighborGroups renders grouped neighbour lists', () => {
const node = { nodeId: '!self', nodeNum: 77 };
const neighbors = [
{
node_id: '!peer',
node_short_name: 'PEER',
neighbor_id: '!self',
snr: 9.5,
node: { short_name: 'PEER', role: 'ROUTER' },
},
{
node_id: '!self',
neighbor_id: '!ally',
neighbor_short_name: 'ALLY',
snr: 5.25,
neighbor: { short_name: 'ALLY', role: 'REPEATER' },
},
];
const html = renderNeighborGroups(
node,
neighbors,
(short, role) => `<span class="badge" data-role="${role}">${short}</span>`,
);
assert.equal(html.includes('Neighbors'), true);
assert.equal(html.includes('Heard by'), true);
assert.equal(html.includes('We hear'), true);
assert.equal(html.includes('PEER'), true);
assert.equal(html.includes('ALLY'), true);
assert.equal(html.includes('9.5 dB'), true);
assert.equal(html.includes('5.3 dB'), true);
assert.equal(html.includes('data-role="ROUTER"'), true);
assert.equal(html.includes('data-role="REPEATER"'), true);
});
test('buildNeighborRoleIndex fetches missing neighbor metadata from the API', async () => {
const neighbors = [
{ neighbor_id: '!ally', neighbor_short_name: 'ALLY' },
];
const calls = [];
const fetchImpl = async url => {
calls.push(url);
return {
status: 200,
ok: true,
json: async () => ({ node_id: '!ally', role: 'ROUTER', node_num: 99, short_name: 'ALLY-API' }),
};
};
const index = await buildNeighborRoleIndex({ nodeId: '!self', role: 'CLIENT' }, neighbors, { fetchImpl });
assert.equal(index.byId.get('!self'), 'CLIENT');
assert.equal(index.byId.get('!ally'), 'ROUTER');
assert.equal(index.byNum.get(99), 'ROUTER');
assert.equal(calls.some(url => url.startsWith('/api/nodes/')), true);
const allyMetadata = lookupNeighborDetails(index, { identifier: '!ally', numericId: 99 });
assert.equal(allyMetadata.shortName, 'ALLY-API');
});
test('renderSingleNodeTable renders a condensed table for the node', () => {
const node = {
shortName: 'NODE',
longName: 'Example Node',
nodeId: '!abcd',
role: 'CLIENT',
hwModel: 'T-Beam',
battery: 66,
voltage: 4.12,
uptime: 3_700,
channel_utilization: 1.23,
airUtil: 0.45,
temperature: 22.5,
humidity: 55.5,
pressure: 1_013.2,
latitude: 52.52,
longitude: 13.405,
altitude: 40,
lastHeard: 9_900,
positionTime: 9_850,
rawSources: { node: { node_id: '!abcd', role: 'CLIENT' } },
};
const html = renderSingleNodeTable(
node,
(short, role) => `<span class="short-name" data-role="${role}">${short}</span>`,
10_000,
);
assert.equal(html.includes('<table'), true);
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">Example Node<\/a>/);
assert.equal(html.includes('66.0%'), true);
assert.equal(html.includes('1.230%'), true);
assert.equal(html.includes('52.52000'), true);
assert.equal(html.includes('1m 40s'), true);
assert.equal(html.includes('2m 30s'), true);
});
test('renderTelemetryCharts renders condensed scatter charts when telemetry exists', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: {
battery_level: 80,
voltage: 4.1,
channel_utilization: 40,
air_util_tx: 22,
},
environment_metrics: {
temperature: 19.5,
relative_humidity: 55,
barometric_pressure: 995,
gas_resistance: 1500,
},
},
{
rx_time: nowSeconds - 3_600,
deviceMetrics: {
batteryLevel: 78,
voltage: 4.05,
channelUtilization: 35,
airUtilTx: 20,
},
environmentMetrics: {
temperature: 18.4,
relativeHumidity: 52,
barometricPressure: 1000,
gasResistance: 2000,
},
},
],
},
},
};
const html = renderTelemetryCharts(node, { nowMs });
const fmt = new Date(nowMs);
const expectedDate = String(fmt.getDate()).padStart(2, '0');
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
assert.equal(html.includes('Environmental telemetry'), true);
assert.equal(html.includes('Battery (0-100%)'), true);
assert.equal(html.includes('Voltage (0-6V)'), true);
assert.equal(html.includes('Channel utilization (%)'), true);
assert.equal(html.includes('Air util TX (%)'), true);
assert.equal(html.includes('Utilization (%)'), true);
assert.equal(html.includes('Gas resistance (10-100k Ω)'), true);
assert.equal(html.includes('Temperature (-20-40°C)'), true);
assert.equal(html.includes(expectedDate), true);
assert.equal(html.includes('node-detail__chart-point'), true);
});
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
const html = renderNodeDetailHtml(
{
shortName: 'NODE',
longName: 'Example Node',
nodeId: '!abcd',
nodeNum: 77,
role: 'CLIENT',
battery: 60,
voltage: 4.1,
uptime: 1_000,
latitude: 52.5,
longitude: 13.4,
altitude: 40,
},
{
neighbors: [
{ node_id: '!peer', node_short_name: 'PEER', neighbor_id: '!abcd', snr: 7.5 },
{ node_id: '!abcd', neighbor_id: '!ally', neighbor_short_name: 'ALLY', snr: 5.1 },
],
messages: [{ text: 'Hello', rx_time: 1_700_000_111 }],
renderShortHtml: (short, role) => `<span class="short-name" data-role="${role}">${short}</span>`,
},
);
assert.equal(html.includes('node-detail__table'), true);
assert.equal(html.includes('Neighbors'), true);
assert.equal(html.includes('Heard by'), true);
assert.equal(html.includes('We hear'), true);
assert.equal(html.includes('Messages'), true);
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">Example Node<\/a>/);
assert.equal(html.includes('PEER'), true);
assert.equal(html.includes('ALLY'), true);
assert.match(html, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\[/);
assert.equal(html.includes('data-role="CLIENT"'), true);
});
test('renderNodeDetailHtml embeds telemetry charts when snapshots are present', () => {
const nowMs = Date.UTC(2025, 0, 8, 7, 0, 0);
const node = {
shortName: 'NODE',
nodeId: '!abcd',
role: 'CLIENT',
rawSources: {
node: { node_id: '!abcd', role: 'CLIENT', short_name: 'NODE' },
telemetry: {
snapshots: [
{
rx_time: Math.floor(nowMs / 1000) - 120,
battery_level: 75,
voltage: 4.08,
channel_utilization: 30,
temperature: 20,
relative_humidity: 45,
barometric_pressure: 990,
gas_resistance: 1800,
},
],
},
},
};
const html = renderNodeDetailHtml(node, {
renderShortHtml: short => `<span class="short-name">${short}</span>`,
chartNowMs: nowMs,
});
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
});
test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
const reference = { nodeId: '!alpha' };
let fetchCalls = 0;
const fetchImpl = async url => {
fetchCalls += 1;
assert.match(url, /\/api\/messages\/!alpha/);
return {
ok: true,
status: 200,
async json() {
return [{ text: 'Overlay hello', rx_time: 1_700_000_000 }];
},
};
};
const refreshImpl = async () => ({
nodeId: '!alpha',
nodeNum: 1,
shortName: 'ALPH',
longName: 'Example Alpha',
role: 'CLIENT',
neighbors: [],
rawSources: { node: { node_id: '!alpha', role: 'CLIENT', short_name: 'ALPH' } },
});
const html = await fetchNodeDetailHtml(reference, {
refreshImpl,
fetchImpl,
renderShortHtml: short => `<span class="short-name">${short}</span>`,
});
assert.equal(fetchCalls, 1);
assert.equal(html.includes('Example Alpha'), true);
assert.equal(html.includes('Overlay hello'), true);
assert.equal(html.includes('node-detail__table'), true);
});
test('fetchNodeDetailHtml requires a node identifier reference', async () => {
await assert.rejects(
() => fetchNodeDetailHtml({}, { refreshImpl: async () => ({}) }),
/identifier/i,
);
});
test('parseReferencePayload returns null for invalid JSON', () => {
assert.equal(parseReferencePayload('{'), null);
assert.deepEqual(parseReferencePayload('{"nodeId":"!abc"}'), { nodeId: '!abc' });
});
test('resolveRenderShortHtml prefers global implementation when available', async () => {
const original = globalThis.PotatoMesh;
try {
globalThis.PotatoMesh = { renderShortHtml: () => '<span>ok</span>' };
const fn = await resolveRenderShortHtml();
assert.equal(fn('X'), '<span>ok</span>');
} finally {
globalThis.PotatoMesh = original;
}
});
test('resolveRenderShortHtml falls back when no implementation is exposed', async () => {
const original = globalThis.PotatoMesh;
try {
delete globalThis.PotatoMesh;
const fn = await resolveRenderShortHtml();
assert.equal(typeof fn, 'function');
assert.equal(fn('AB'), '<span class="short-name">AB</span>');
} finally {
globalThis.PotatoMesh = original;
}
});
test('fetchMessages handles HTTP responses and uses defaults', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return {
status: 200,
ok: true,
json: async () => [{ text: 'hi', rx_time: 1 }],
};
};
const messages = await fetchMessages('!node', { fetchImpl });
assert.equal(messages.length, 1);
assert.equal(calls[0].options.cache, 'no-store');
});
test('fetchMessages returns an empty list when the endpoint is missing', async () => {
const fetchImpl = async () => ({ status: 404, ok: false, json: async () => ({}) });
const messages = await fetchMessages('!node', { fetchImpl });
assert.deepEqual(messages, []);
});
test('initializeNodeDetailPage hydrates the container with node data', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }),
privateMode: 'false',
},
innerHTML: '',
};
const documentStub = {
querySelector: selector => (selector === '#nodeDetail' ? element : null),
};
const refreshImpl = async reference => {
assert.equal(reference.nodeId, '!node');
return {
shortName: 'NODE',
longName: 'Node Long',
nodeId: '!node',
role: 'CLIENT',
modemPreset: 'LongFast',
loraFreq: 915,
battery: 66,
voltage: 4.1,
uptime: 100,
latitude: 52.5,
longitude: 13.4,
altitude: 42,
neighbors: [{ node_id: '!node', neighbor_id: '!ally', snr: 5.5 }],
rawSources: { node: { node_id: '!node', role: 'CLIENT' } },
};
};
const fetchImpl = async url => {
if (url.startsWith('/api/messages/')) {
return {
status: 200,
ok: true,
json: async () => [{ text: 'hello', rx_time: 1_700_000_222 }],
};
}
if (url.startsWith('/api/nodes/')) {
return {
status: 200,
ok: true,
json: async () => ({ node_id: '!ally', role: 'ROUTER', short_name: 'ALLY-API' }),
};
}
return { status: 404, ok: false, json: async () => ({}) };
};
const renderShortHtml = short => `<span class="short-name">${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
fetchImpl,
renderShortHtml,
});
assert.equal(result, true);
assert.equal(element.innerHTML.includes('Node Long'), true);
assert.equal(element.innerHTML.includes('node-detail__table'), true);
assert.equal(element.innerHTML.includes('Neighbors'), true);
assert.equal(element.innerHTML.includes('Messages'), true);
assert.equal(element.innerHTML.includes('ALLY-API'), true);
});
test('initializeNodeDetailPage removes legacy filter controls when supported', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }),
privateMode: 'false',
},
innerHTML: '',
};
const filterContainer = {
removed: false,
remove() {
this.removed = true;
},
};
const documentStub = {
querySelector: selector => {
if (selector === '#nodeDetail') return element;
if (selector === '.filter-input') return filterContainer;
return null;
},
};
const refreshImpl = async () => ({
shortName: 'NODE',
nodeId: '!node',
role: 'CLIENT',
neighbors: [],
rawSources: { node: { node_id: '!node', role: 'CLIENT' } },
});
const fetchImpl = async () => ({ status: 404, ok: false });
const renderShortHtml = short => `<span class="short-name">${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
fetchImpl,
renderShortHtml,
});
assert.equal(result, true);
assert.equal(filterContainer.removed, true);
});
test('initializeNodeDetailPage hides legacy filter controls when removal is unavailable', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }),
privateMode: 'false',
},
innerHTML: '',
};
const filterContainer = { hidden: false };
const documentStub = {
querySelector: selector => {
if (selector === '#nodeDetail') return element;
if (selector === '.filter-input') return filterContainer;
return null;
},
};
const refreshImpl = async () => ({
shortName: 'NODE',
nodeId: '!node',
role: 'CLIENT',
neighbors: [],
rawSources: { node: { node_id: '!node', role: 'CLIENT' } },
});
const fetchImpl = async () => ({ status: 404, ok: false });
const renderShortHtml = short => `<span class="short-name">${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
fetchImpl,
renderShortHtml,
});
assert.equal(result, true);
assert.equal(filterContainer.hidden, true);
});
test('initializeNodeDetailPage reports an error when refresh fails', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!missing' }),
privateMode: 'false',
},
innerHTML: '',
};
const documentStub = { querySelector: () => element };
const refreshImpl = async () => {
throw new Error('boom');
};
const renderShortHtml = short => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
renderShortHtml,
});
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Failed to load'), true);
});
test('initializeNodeDetailPage handles missing reference payloads', async () => {
const element = {
dataset: {},
innerHTML: '',
};
const documentStub = { querySelector: () => element };
const renderShortHtml = short => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({ document: documentStub, renderShortHtml });
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Node reference unavailable'), true);
});
@@ -0,0 +1,73 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeNodeSnapshot, normalizeNodeCollection, __testUtils } from '../node-snapshot-normalizer.js';
const { normalizeNumber, normalizeString } = __testUtils;
test('normalizeNodeSnapshot synchronises telemetry aliases', () => {
const node = {
node_id: '!test',
channel: '56.2',
airUtil: 13.5,
battery_level: 45.5,
relativeHumidity: 24.3,
lastHeard: '1234',
};
const normalised = normalizeNodeSnapshot(node);
assert.equal(normalised.nodeId, '!test');
assert.equal(normalised.channel_utilization, 56.2);
assert.equal(normalised.channelUtilization, 56.2);
assert.equal(normalised.channel, 56.2);
assert.equal(normalised.air_util_tx, 13.5);
assert.equal(normalised.airUtilTx, 13.5);
assert.equal(normalised.airUtil, 13.5);
assert.equal(normalised.battery, 45.5);
assert.equal(normalised.batteryLevel, 45.5);
assert.equal(normalised.relative_humidity, 24.3);
assert.equal(normalised.humidity, 24.3);
assert.equal(normalised.last_heard, 1234);
});
test('normalizeNodeCollection applies canonical forms to all nodes', () => {
const nodes = [
{ short_name: ' AAA ', voltage: '3.7' },
{ shortName: 'BBB', uptime_seconds: '3600', airUtilTx: '5.5' },
];
normalizeNodeCollection(nodes);
assert.equal(nodes[0].shortName, 'AAA');
assert.equal(nodes[0].short_name, 'AAA');
assert.equal(nodes[0].voltage, 3.7);
assert.equal(nodes[1].uptime, 3600);
assert.equal(nodes[1].air_util_tx, 5.5);
});
test('normaliser helpers coerce primitive values consistently', () => {
assert.equal(normalizeNumber('42.1'), 42.1);
assert.equal(normalizeNumber('not-a-number'), null);
assert.equal(normalizeNumber(Infinity), null);
assert.equal(normalizeString(' hello '), 'hello');
assert.equal(normalizeString(''), null);
assert.equal(normalizeString(null), null);
});
@@ -0,0 +1,119 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { enhanceCoordinateCell, __testUtils } from '../nodes-coordinate-links.js';
const { toFiniteCoordinate } = __testUtils;
test('enhanceCoordinateCell renders an interactive link for valid coordinates', () => {
const cell = {
replacedChildren: null,
replaceChildren(...children) {
this.replacedChildren = children;
}
};
const linkStub = {
dataset: {},
attributes: new Map(),
listeners: new Map(),
href: null,
setAttribute(name, value) {
this.attributes.set(name, value);
},
addEventListener(name, handler) {
this.listeners.set(name, handler);
}
};
const documentStub = {
createElement(tagName) {
assert.equal(tagName, 'a');
return linkStub;
}
};
const activations = [];
const link = enhanceCoordinateCell({
cell,
document: documentStub,
displayText: '51.50000',
formattedLatitude: '51.50000',
formattedLongitude: '-0.12000',
lat: '51.5',
lon: '-0.12',
nodeName: 'Alpha',
onActivate: (lat, lon) => activations.push({ lat, lon })
});
assert.equal(link, linkStub);
assert.deepEqual(cell.replacedChildren, [linkStub]);
assert.equal(linkStub.textContent, '51.50000');
assert.equal(linkStub.dataset.lat, '51.5');
assert.equal(linkStub.dataset.lon, '-0.12');
assert.equal(linkStub.className, 'nodes-coordinate-link');
assert.equal(linkStub.attributes.get('aria-label'), 'Center map on Alpha at 51.50000, -0.12000');
assert.equal(linkStub.attributes.get('href'), '#');
const clickHandler = linkStub.listeners.get('click');
assert.equal(typeof clickHandler, 'function');
const event = {
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopPropagation() {
this.stopped = true;
}
};
clickHandler(event);
assert.equal(event.prevented, true);
assert.equal(event.stopped, true);
assert.deepEqual(activations, [{ lat: 51.5, lon: -0.12 }]);
});
test('enhanceCoordinateCell ignores invalid input data', () => {
const cell = {
replaceChildren() {
assert.fail('replaceChildren should not be called for invalid data');
}
};
const resultEmpty = enhanceCoordinateCell({
cell,
document: {},
displayText: '',
lat: 0,
lon: 0
});
assert.equal(resultEmpty, null);
const resultInvalid = enhanceCoordinateCell({
cell,
document: {},
displayText: 'value',
lat: 'north',
lon: 5
});
assert.equal(resultInvalid, null);
});
test('toFiniteCoordinate returns finite numbers and rejects NaN', () => {
assert.equal(toFiniteCoordinate('12.34'), 12.34);
assert.equal(toFiniteCoordinate(56.78), 56.78);
assert.equal(toFiniteCoordinate('NaN'), null);
assert.equal(toFiniteCoordinate(undefined), null);
});
@@ -0,0 +1,112 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM, __testUtils } from '../nodes-map-focus.js';
const { toFiniteCoordinate } = __testUtils;
test('createMapFocusHandler recentres the map using Leaflet setView', () => {
let interactions = 0;
const autoFitController = {
handleUserInteraction() {
interactions += 1;
}
};
const map = {
calls: [],
setView(target, zoom, options) {
this.calls.push({ target, zoom, options });
}
};
const centers = [];
const handler = createMapFocusHandler({
getMap: () => map,
autoFitController,
leaflet: {
latLng(lat, lon) {
return { lat, lng: lon, source: 'leaflet' };
}
},
defaultZoom: 11,
setMapCenter: value => centers.push(value)
});
const result = handler('51.5', '-0.12');
assert.equal(result, true);
assert.equal(interactions, 1);
assert.equal(map.calls.length, 1);
assert.deepEqual(map.calls[0], { target: [51.5, -0.12], zoom: 11, options: { animate: true } });
assert.deepEqual(centers, [{ lat: 51.5, lng: -0.12, source: 'leaflet' }]);
});
test('createMapFocusHandler supports panTo fallback and numeric centres', () => {
const panCalls = [];
const zoomCalls = [];
const map = {
panTo(target, options) {
panCalls.push({ target, options });
},
setZoom(value) {
zoomCalls.push(value);
}
};
const centers = [];
const handler = createMapFocusHandler({
getMap: () => map,
leaflet: {
latLng() {
throw new Error('Leaflet latLng unavailable');
}
},
defaultZoom: DEFAULT_NODE_FOCUS_ZOOM,
setMapCenter: value => centers.push(value)
});
const result = handler(40.7128, -74.006, { zoom: 9, animate: false });
assert.equal(result, true);
assert.deepEqual(panCalls, [{ target: [40.7128, -74.006], options: { animate: false } }]);
assert.deepEqual(zoomCalls, [9]);
assert.deepEqual(centers, [{ lat: 40.7128, lon: -74.006 }]);
});
test('createMapFocusHandler validates inputs and map availability', () => {
assert.throws(() => {
createMapFocusHandler({ getMap: null });
}, /getMap/);
const missingMapHandler = createMapFocusHandler({ getMap: () => null });
assert.equal(missingMapHandler(10, 20), false);
const map = {
setView() {}
};
const handler = createMapFocusHandler({ getMap: () => map });
assert.equal(handler(null, 2), false);
assert.equal(handler(1, undefined), false);
assert.equal(handler(1, 2, { zoom: -5 }), false);
});
test('toFiniteCoordinate converts valid strings and rejects invalid values', () => {
assert.equal(toFiniteCoordinate('42.5'), 42.5);
assert.equal(toFiniteCoordinate(19), 19);
assert.equal(toFiniteCoordinate('abc'), null);
assert.equal(toFiniteCoordinate(null), null);
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -89,7 +89,7 @@ test('collectTelemetryMetrics prefers latest nested telemetry values over stale
air_util_tx: 0.0091,
},
telemetry: {
channel: 0.563,
channel_utilization: 0.563,
},
raw: {
device_metrics: {
@@ -0,0 +1,121 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SNAPSHOT_WINDOW,
aggregateSnapshots,
aggregateNodeSnapshots,
aggregateTelemetrySnapshots,
aggregatePositionSnapshots,
aggregateNeighborSnapshots,
} from '../snapshot-aggregator.js';
const SAMPLE_NODE_ID = '!node';
function keyById(entry) {
return entry && typeof entry.id === 'string' ? entry.id : null;
}
test('aggregateSnapshots merges snapshots in chronological order', () => {
const snapshots = [
{ id: 'alpha', metric: null, label: 'latest', ts: 30, ignored: Number.NaN },
{ id: 'alpha', metric: 5, label: null, ts: 20 },
{ id: 'alpha', metric: 1, legacy: 'keep', ts: 10 },
];
const aggregated = aggregateSnapshots(snapshots, { keySelector: keyById, limit: 3 });
assert.equal(aggregated.length, 1);
const record = aggregated[0];
assert.equal(record.metric, 5);
assert.equal(record.label, 'latest');
assert.equal(record.legacy, 'keep');
assert.deepEqual(record.snapshots.map(s => s.ts), [10, 20, 30]);
assert.equal(record.latestSnapshot.ts, 30);
assert.equal(Object.prototype.propertyIsEnumerable.call(record, 'snapshots'), false);
assert.equal(Object.prototype.propertyIsEnumerable.call(record, 'latestSnapshot'), false);
assert.equal('ignored' in record, false);
});
test('aggregateSnapshots enforces key selectors and respects limits', () => {
assert.throws(() => aggregateSnapshots([{ id: 'noop' }], {}), /keySelector/);
const snapshots = [
{ id: 'beta', value: 'newest', ts: 30 },
{ id: 'beta', value: 'mid', ts: 20 },
{ id: 'beta', value: 'oldest', ts: 10 },
];
const aggregated = aggregateSnapshots(snapshots, { keySelector: keyById, limit: 2 });
assert.equal(aggregated[0].snapshots.length, 2);
assert.deepEqual(aggregated[0].snapshots.map(s => s.ts), [20, 30]);
});
test('aggregateNodeSnapshots reconciles identifiers and fills missing values', () => {
const entries = [
{ nodeId: SAMPLE_NODE_ID, voltage: 4.2, battery_level: null, rx_time: 250 },
{ node_id: SAMPLE_NODE_ID, node_num: 42, battery_level: 20, rx_time: 200 },
{ node_num: 42, short_name: 'Legacy', battery_level: 15, rx_time: 100 },
];
const aggregated = aggregateNodeSnapshots(entries, { limit: SNAPSHOT_WINDOW });
assert.equal(aggregated.length, 1);
const node = aggregated[0];
assert.equal(node.node_id, SAMPLE_NODE_ID);
assert.equal(node.node_num, 42);
assert.equal(node.short_name, 'Legacy');
assert.equal(node.battery_level, 20);
assert.equal(node.voltage, 4.2);
assert.equal(node.snapshots.length, 3);
});
test('aggregateTelemetrySnapshots and aggregatePositionSnapshots mirror node aggregation', () => {
const telemetryEntries = [
{ node_id: SAMPLE_NODE_ID, node_num: 5, temperature: null, rx_time: 20 },
{ node_num: 5, temperature: 21.5, humidity: 52, rx_time: 10 },
];
const positionEntries = [
{ node_id: SAMPLE_NODE_ID, node_num: 5, longitude: 13.4, rx_time: 25 },
{ node_num: 5, latitude: 52.5, rx_time: 15 },
];
const telemetryAggregated = aggregateTelemetrySnapshots(telemetryEntries, { limit: 3 });
const positionAggregated = aggregatePositionSnapshots(positionEntries, { limit: 3 });
assert.equal(telemetryAggregated.length, 1);
assert.equal(positionAggregated.length, 1);
assert.equal(telemetryAggregated[0].temperature, 21.5);
assert.equal(telemetryAggregated[0].humidity, 52);
assert.equal(positionAggregated[0].latitude, 52.5);
assert.equal(positionAggregated[0].longitude, 13.4);
});
test('aggregateNeighborSnapshots groups by node pairs', () => {
const neighborSnapshots = [
{ node_id: '!src', node_num: 101, neighbor_id: '!dst', neighbor_num: 202, snr: null, rx_time: 180 },
{ node_id: '!src', node_num: 101, neighbor_id: '!dst', neighbor_num: 202, snr: -5, rx_time: 150 },
{ node_num: 101, neighbor_num: 202, snr: -11, rx_time: 100 },
null,
];
const aggregated = aggregateNeighborSnapshots(neighborSnapshots, { limit: 5 });
assert.equal(aggregated.length, 1);
const connection = aggregated[0];
assert.equal(connection.node_id, '!src');
assert.equal(connection.neighbor_id, '!dst');
assert.equal(connection.snr, -5);
assert.equal(connection.snapshots.length, 3);
});
test('aggregateSnapshots returns an empty array when no entries are provided', () => {
assert.deepEqual(aggregateSnapshots(null, { keySelector: () => 'noop' }), []);
assert.deepEqual(aggregateNodeSnapshots([], {}), []);
});
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 l5yth
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.

Some files were not shown because too many files have changed in this diff Show More