mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-09 14:55:08 +02:00
Compare commits
26 Commits
v0.5.4-rc1
...
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
| e1d43cec57 | |||
| cd7bced827 | |||
| b298f2f22c | |||
| 9304a99745 | |||
| 4a03e17886 | |||
| e502ddd436 | |||
| 12f1801ed2 | |||
| a6a63bf12e | |||
| 631455237f | |||
| 382e2609c9 | |||
| 05efbc5f20 | |||
| 9a45430321 | |||
| cb843d5774 | |||
| c823347175 | |||
| d87c0cc226 | |||
| 9c957a4a14 | |||
| 16442bab08 | |||
| e479983d38 | |||
| 70fca17230 | |||
| 2107d6790d | |||
| 8823b7cb48 | |||
| e40c0d9078 | |||
| 8b090cb238 | |||
| 2bb8e3fd66 | |||
| deb7263c3e | |||
| 3daadc4f68 |
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -69,3 +69,10 @@ ai_docs/
|
||||
|
||||
# Generated credentials for the instance
|
||||
web/.config
|
||||
|
||||
# JavaScript dependencies
|
||||
node_modules/
|
||||
web/node_modules/
|
||||
|
||||
# Debug symbols
|
||||
ignored.txt
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,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,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,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,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 = {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
# Development overrides for docker-compose.yml
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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,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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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(' '), true);
|
||||
assert.equal(messagesHtml.includes(' '), 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
Reference in New Issue
Block a user