Compare commits

..

8 Commits

Author SHA1 Message Date
l5y 22a31b6c80 Ensure node overlays appear above fullscreen map (#333)
* Increase overlay z-index to surface node info

* Ensure short info overlays attach to fullscreen host

* Ensure info overlay participates in fullscreen mode
2025-10-14 15:52:26 +02:00
l5y b7ef0bbfcd Adjust node table columns responsively (#332) 2025-10-14 14:59:47 +02:00
l5y 03b5a10fe4 Add LoRa metadata fields to nodes and messages (#331)
* Add LoRa metadata fields to nodes and messages

* Filter numeric SQLite keys from message rows
2025-10-14 14:51:28 +02:00
l5y e97498d09f Add channel metadata capture for message tagging (#329) 2025-10-13 23:10:01 +02:00
l5y 7db76ec2fc Capture radio metadata for ingestor payloads (#327)
* Capture radio metadata and tag ingestor payloads

* Log captured LoRa metadata when initializing radio config
2025-10-13 22:35:06 +02:00
l5y 63beb2ea6b Avoid mutating frozen node query results (#324) 2025-10-13 17:22:34 +02:00
l5y ffad84f18a Ensure frontend reports git-aware version strings (#321)
* Ensure frontend reports git-aware version strings

* Keep footer fixed across viewport widths
2025-10-13 16:26:57 +02:00
l5y 2642ff7a95 Fix web Docker image to include application code (#322) 2025-10-13 16:25:44 +02:00
23 changed files with 1457 additions and 173 deletions
+4 -2
View File
@@ -21,7 +21,7 @@ import threading as threading # re-exported for compatibility
import sys
import types
from . import config, daemon, handlers, interfaces, queue, serialization
from . import channels, config, daemon, handlers, interfaces, queue, serialization
__all__: list[str] = []
@@ -40,7 +40,7 @@ def _export_constants() -> None:
__all__.extend(["json", "urllib", "glob", "threading", "signal"])
for _module in (daemon, handlers, interfaces, queue, serialization):
for _module in (channels, daemon, handlers, interfaces, queue, serialization):
_reexport(_module)
_export_constants()
@@ -52,6 +52,8 @@ _CONFIG_ATTRS = {
"DEBUG",
"INSTANCE",
"API_TOKEN",
"LORA_FREQ",
"MODEM_PRESET",
"_RECONNECT_INITIAL_DELAY_SECS",
"_RECONNECT_MAX_DELAY_SECS",
"_CLOSE_TIMEOUT_SECS",
+223
View File
@@ -0,0 +1,223 @@
# Copyright (C) 2025 l5yth
#
# 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.
"""Helpers for capturing and exposing mesh channel metadata."""
from __future__ import annotations
import os
from typing import Any, Iterable, Iterator
from . import config
try: # pragma: no cover - optional dependency for enum introspection
from meshtastic.protobuf import channel_pb2
except Exception: # pragma: no cover - exercised in environments without protobufs
channel_pb2 = None # type: ignore[assignment]
_ROLE_PRIMARY = 1
_ROLE_SECONDARY = 2
if channel_pb2 is not None: # pragma: no branch - evaluated once at import time
try:
_ROLE_PRIMARY = int(channel_pb2.Channel.Role.PRIMARY)
_ROLE_SECONDARY = int(channel_pb2.Channel.Role.SECONDARY)
except Exception: # pragma: no cover - defensive, version specific
_ROLE_PRIMARY = 1
_ROLE_SECONDARY = 2
_CHANNEL_MAPPINGS: tuple[tuple[int, str], ...] = ()
_CHANNEL_LOOKUP: dict[int, str] = {}
def _iter_channel_objects(channels_obj: Any) -> Iterator[Any]:
"""Yield channel descriptors from ``channels_obj``.
The real Meshtastic API exposes channels via protobuf containers that are
list-like. This helper converts the container into a deterministic iterator
while avoiding runtime errors if an unexpected type is supplied.
"""
if channels_obj is None:
return iter(())
if isinstance(channels_obj, dict):
return iter(channels_obj.values())
if isinstance(channels_obj, Iterable):
return iter(list(channels_obj))
length_fn = getattr(channels_obj, "__len__", None)
getitem = getattr(channels_obj, "__getitem__", None)
if callable(length_fn) and callable(getitem):
try:
length = int(length_fn())
except Exception: # pragma: no cover - defensive only
length = None
if length is not None and length >= 0:
snapshot = []
for index in range(length):
try:
snapshot.append(getitem(index))
except Exception: # pragma: no cover - best effort copy
break
return iter(snapshot)
return iter(())
def _primary_channel_name() -> str | None:
"""Return the name to use for the primary channel when available."""
preset = getattr(config, "MODEM_PRESET", None)
if isinstance(preset, str) and preset.strip():
return preset
env_name = os.environ.get("CHANNEL", "").strip()
if env_name:
return env_name
return None
def _normalize_role(role: Any) -> int | None:
"""Convert a channel role descriptor into an integer value."""
if isinstance(role, int):
return role
if isinstance(role, str):
value = role.strip().upper()
if value == "PRIMARY":
return _ROLE_PRIMARY
if value == "SECONDARY":
return _ROLE_SECONDARY
try:
return int(value)
except ValueError:
return None
name_attr = getattr(role, "name", None)
if isinstance(name_attr, str):
return _normalize_role(name_attr)
value_attr = getattr(role, "value", None)
if isinstance(value_attr, int):
return value_attr
try:
return int(role) # type: ignore[arg-type]
except Exception:
return None
def _channel_tuple(channel_obj: Any) -> tuple[int, str] | None:
"""Return ``(index, name)`` for ``channel_obj`` when resolvable."""
role_value = _normalize_role(getattr(channel_obj, "role", None))
if role_value == _ROLE_PRIMARY:
channel_index = 0
channel_name = _primary_channel_name()
elif role_value == _ROLE_SECONDARY:
raw_index = getattr(channel_obj, "index", None)
try:
channel_index = int(raw_index)
except Exception:
channel_index = None
settings = getattr(channel_obj, "settings", None)
channel_name = getattr(settings, "name", None) if settings else None
else:
return None
if not isinstance(channel_index, int):
return None
if isinstance(channel_name, str):
channel_name = channel_name.strip()
else:
channel_name = None
if not channel_name:
return None
return channel_index, channel_name
def capture_from_interface(iface: Any) -> None:
"""Populate the channel cache by inspecting ``iface`` when possible."""
global _CHANNEL_MAPPINGS, _CHANNEL_LOOKUP
if iface is None or _CHANNEL_MAPPINGS:
return
try:
wait_for_config = getattr(iface, "waitForConfig", None)
if callable(wait_for_config):
wait_for_config()
except Exception: # pragma: no cover - hardware dependent safeguard
pass
local_node = getattr(iface, "localNode", None)
channels_obj = getattr(local_node, "channels", None) if local_node else None
channel_entries: list[tuple[int, str]] = []
seen_indices: set[int] = set()
for candidate in _iter_channel_objects(channels_obj):
result = _channel_tuple(candidate)
if result is None:
continue
index, name = result
if index in seen_indices:
continue
channel_entries.append((index, name))
seen_indices.add(index)
if not channel_entries:
return
_CHANNEL_MAPPINGS = tuple(channel_entries)
_CHANNEL_LOOKUP = {index: name for index, name in _CHANNEL_MAPPINGS}
config._debug_log(
"Captured channel metadata",
context="channels.capture",
severity="info",
always=True,
channels=_CHANNEL_MAPPINGS,
)
def channel_mappings() -> tuple[tuple[int, str], ...]:
"""Return the cached ``(index, name)`` channel tuples."""
return _CHANNEL_MAPPINGS
def channel_name(channel_index: int | None) -> str | None:
"""Return the channel name for ``channel_index`` when known."""
if channel_index is None:
return None
return _CHANNEL_LOOKUP.get(int(channel_index))
def _reset_channel_cache() -> None:
"""Clear cached channel data. Intended for use in tests only."""
global _CHANNEL_MAPPINGS, _CHANNEL_LOOKUP
_CHANNEL_MAPPINGS = ()
_CHANNEL_LOOKUP = {}
__all__ = [
"capture_from_interface",
"channel_mappings",
"channel_name",
"_reset_channel_cache",
]
+9
View File
@@ -64,6 +64,13 @@ DEBUG = os.environ.get("DEBUG") == "1"
INSTANCE = os.environ.get("POTATOMESH_INSTANCE", "").rstrip("/")
API_TOKEN = os.environ.get("API_TOKEN", "")
ENERGY_SAVING = os.environ.get("ENERGY_SAVING") == "1"
"""When ``True``, enables the ingestor's energy saving mode."""
LORA_FREQ: int | None = None
"""Frequency of the local node's configured LoRa region in MHz."""
MODEM_PRESET: str | None = None
"""CamelCase modem preset name reported by the local node."""
_RECONNECT_INITIAL_DELAY_SECS = DEFAULT_RECONNECT_INITIAL_DELAY_SECS
_RECONNECT_MAX_DELAY_SECS = DEFAULT_RECONNECT_MAX_DELAY_SECS
@@ -118,6 +125,8 @@ __all__ = [
"INSTANCE",
"API_TOKEN",
"ENERGY_SAVING",
"LORA_FREQ",
"MODEM_PRESET",
"_RECONNECT_INITIAL_DELAY_SECS",
"_RECONNECT_MAX_DELAY_SECS",
"_CLOSE_TIMEOUT_SECS",
+2
View File
@@ -241,6 +241,8 @@ def main() -> None:
else:
iface, resolved_target = interfaces._create_default_interface()
active_candidate = resolved_target
interfaces._ensure_radio_metadata(iface)
interfaces._ensure_channel_metadata(iface)
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
initial_snapshot_sent = False
if not announced_target and resolved_target:
+87 -15
View File
@@ -21,7 +21,7 @@ import json
import time
from collections.abc import Mapping
from . import config, queue
from . import channels, config, queue
from .serialization import (
_canonical_node_id,
_coerce_float,
@@ -42,6 +42,55 @@ from .serialization import (
)
def _radio_metadata_fields() -> dict[str, object]:
"""Return the shared radio metadata fields for payload enrichment."""
metadata: dict[str, object] = {}
freq = getattr(config, "LORA_FREQ", None)
if freq is not None:
metadata["lora_freq"] = freq
preset = getattr(config, "MODEM_PRESET", None)
if preset is not None:
metadata["modem_preset"] = preset
return metadata
def _apply_radio_metadata(payload: dict) -> dict:
"""Augment ``payload`` with radio metadata when available."""
metadata = _radio_metadata_fields()
if metadata:
payload.update(metadata)
return payload
def _is_encrypted_flag(value) -> bool:
"""Return ``True`` when ``value`` represents an encrypted payload."""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"", "0", "false", "no"}:
return False
return True
return bool(value)
def _apply_radio_metadata_to_nodes(payload: dict) -> dict:
"""Attach radio metadata to each node entry stored in ``payload``."""
metadata = _radio_metadata_fields()
if not metadata:
return payload
for value in payload.values():
if isinstance(value, dict):
value.update(metadata)
return payload
def upsert_node(node_id, node) -> None:
"""Schedule an upsert for a single node.
@@ -53,7 +102,7 @@ def upsert_node(node_id, node) -> None:
``None``. The payload is forwarded to the shared HTTP queue.
"""
payload = upsert_payload(node_id, node)
payload = _apply_radio_metadata_to_nodes(upsert_payload(node_id, node))
_queue_post_json("/api/nodes", payload, priority=queue._NODE_POST_PRIORITY)
if config.DEBUG:
@@ -243,7 +292,9 @@ def store_position_packet(packet: Mapping, decoded: Mapping) -> None:
position_payload["raw"] = raw_payload
_queue_post_json(
"/api/positions", position_payload, priority=queue._POSITION_POST_PRIORITY
"/api/positions",
_apply_radio_metadata(position_payload),
priority=queue._POSITION_POST_PRIORITY,
)
if config.DEBUG:
@@ -445,7 +496,9 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None:
telemetry_payload["barometric_pressure"] = barometric_pressure
_queue_post_json(
"/api/telemetry", telemetry_payload, priority=queue._TELEMETRY_POST_PRIORITY
"/api/telemetry",
_apply_radio_metadata(telemetry_payload),
priority=queue._TELEMETRY_POST_PRIORITY,
)
if config.DEBUG:
@@ -607,7 +660,9 @@ def store_nodeinfo_packet(packet: Mapping, decoded: Mapping) -> None:
pass
_queue_post_json(
"/api/nodes", {node_id: node_payload}, priority=queue._NODE_POST_PRIORITY
"/api/nodes",
_apply_radio_metadata_to_nodes({node_id: node_payload}),
priority=queue._NODE_POST_PRIORITY,
)
if config.DEBUG:
@@ -720,7 +775,11 @@ def store_neighborinfo_packet(packet: Mapping, decoded: Mapping) -> None:
if last_sent_by_id is not None:
payload["last_sent_by_id"] = last_sent_by_id
_queue_post_json("/api/neighbors", payload, priority=queue._NEIGHBOR_POST_PRIORITY)
_queue_post_json(
"/api/neighbors",
_apply_radio_metadata(payload),
priority=queue._NEIGHBOR_POST_PRIORITY,
)
if config.DEBUG:
config._debug_log(
@@ -813,6 +872,8 @@ def store_packet_dict(packet: Mapping) -> None:
rssi = _first(packet, "rssi", "rx_rssi", "rxRssi", default=None)
hop = _first(packet, "hopLimit", "hop_limit", default=None)
encrypted_flag = _is_encrypted_flag(encrypted)
message_payload = {
"id": int(pkt_id),
"rx_time": rx_time,
@@ -827,22 +888,33 @@ def store_packet_dict(packet: Mapping) -> None:
"rssi": int(rssi) if rssi is not None else None,
"hop_limit": int(hop) if hop is not None else None,
}
channel_name_value = None
if not encrypted_flag:
channel_name_value = channels.channel_name(channel)
if channel_name_value:
message_payload["channel_name"] = channel_name_value
_queue_post_json(
"/api/messages", message_payload, priority=queue._MESSAGE_POST_PRIORITY
"/api/messages",
_apply_radio_metadata(message_payload),
priority=queue._MESSAGE_POST_PRIORITY,
)
if config.DEBUG:
from_label = _canonical_node_id(from_id) or from_id
to_label = _canonical_node_id(to_id) or to_id
payload_desc = "Encrypted" if text is None and encrypted else text
config._debug_log(
"Queued message payload",
context="handlers.store_packet_dict",
from_id=from_label,
to_id=to_label,
channel=channel,
payload=payload_desc,
)
log_kwargs = {
"context": "handlers.store_packet_dict",
"from_id": from_label,
"to_id": to_label,
"channel": channel,
"channel_display": channel_name_value or channel,
"payload": payload_desc,
}
if channel_name_value:
log_kwargs["channel_name"] = channel_name_value
config._debug_log("Queued message payload", **log_kwargs)
_last_packet_monotonic: float | None = None
+187 -2
View File
@@ -21,12 +21,12 @@ import ipaddress
import re
import urllib.parse
from collections.abc import Mapping
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from meshtastic.serial_interface import SerialInterface
from meshtastic.tcp_interface import TCPInterface
from . import config, serialization
from . import channels, config, serialization
if TYPE_CHECKING: # pragma: no cover - import only used for type checking
from meshtastic.ble_interface import BLEInterface as _BLEInterface
@@ -169,6 +169,189 @@ def _patch_meshtastic_ble_receive_loop() -> None:
_patch_meshtastic_ble_receive_loop()
def _has_field(message: Any, field_name: str) -> bool:
"""Return ``True`` when ``message`` advertises ``field_name`` via ``HasField``."""
if message is None:
return False
has_field = getattr(message, "HasField", None)
if callable(has_field):
try:
return bool(has_field(field_name))
except Exception: # pragma: no cover - defensive guard
return False
return hasattr(message, field_name)
def _enum_name_from_field(message: Any, field_name: str, value: Any) -> str | None:
"""Return the enum name for ``value`` using ``message`` descriptors."""
descriptor = getattr(message, "DESCRIPTOR", None)
if descriptor is None:
return None
fields_by_name = getattr(descriptor, "fields_by_name", {})
field_desc = fields_by_name.get(field_name)
if field_desc is None:
return None
enum_type = getattr(field_desc, "enum_type", None)
if enum_type is None:
return None
enum_values = getattr(enum_type, "values_by_number", {})
enum_value = enum_values.get(value)
if enum_value is None:
return None
return getattr(enum_value, "name", None)
def _resolve_lora_message(local_config: Any) -> Any | None:
"""Return the LoRa configuration sub-message from ``local_config``."""
if local_config is None:
return None
if _has_field(local_config, "lora"):
candidate = getattr(local_config, "lora", None)
if candidate is not None:
return candidate
radio_section = getattr(local_config, "radio", None)
if radio_section is not None:
if _has_field(radio_section, "lora"):
return getattr(radio_section, "lora", None)
if hasattr(radio_section, "lora"):
return getattr(radio_section, "lora")
if hasattr(local_config, "lora"):
return getattr(local_config, "lora")
return None
def _region_frequency(lora_message: Any) -> int | None:
"""Derive the LoRa region frequency in MHz from ``lora_message``."""
if lora_message is None:
return None
region_value = getattr(lora_message, "region", None)
if region_value is None:
return None
enum_name = _enum_name_from_field(lora_message, "region", region_value)
if enum_name:
digits = re.findall(r"\d+", enum_name)
for token in digits:
try:
freq = int(token)
except ValueError: # pragma: no cover - regex guarantees digits
continue
if freq >= 100:
return freq
for token in reversed(digits):
try:
return int(token)
except ValueError: # pragma: no cover - defensive only
continue
if isinstance(region_value, int) and region_value >= 100:
return region_value
return None
def _camelcase_enum_name(name: str | None) -> str | None:
"""Convert ``name`` from ``SCREAMING_SNAKE`` to ``CamelCase``."""
if not name:
return None
parts = re.split(r"[^0-9A-Za-z]+", name.strip())
camel_parts = [part.capitalize() for part in parts if part]
if not camel_parts:
return None
return "".join(camel_parts)
def _modem_preset(lora_message: Any) -> str | None:
"""Return the CamelCase modem preset configured on ``lora_message``."""
if lora_message is None:
return None
descriptor = getattr(lora_message, "DESCRIPTOR", None)
fields_by_name = getattr(descriptor, "fields_by_name", {}) if descriptor else {}
if "modem_preset" in fields_by_name:
preset_field = "modem_preset"
elif "preset" in fields_by_name:
preset_field = "preset"
elif hasattr(lora_message, "modem_preset"):
preset_field = "modem_preset"
elif hasattr(lora_message, "preset"):
preset_field = "preset"
else:
return None
preset_value = getattr(lora_message, preset_field, None)
if preset_value is None:
return None
enum_name = _enum_name_from_field(lora_message, preset_field, preset_value)
if isinstance(enum_name, str) and enum_name:
return _camelcase_enum_name(enum_name)
if isinstance(preset_value, str) and preset_value:
return _camelcase_enum_name(preset_value)
return None
def _ensure_radio_metadata(iface: Any) -> None:
"""Populate cached LoRa metadata by inspecting ``iface`` when available."""
if iface is None:
return
try:
wait_for_config = getattr(iface, "waitForConfig", None)
if callable(wait_for_config):
wait_for_config()
except Exception: # pragma: no cover - hardware dependent guard
pass
local_node = getattr(iface, "localNode", None)
local_config = getattr(local_node, "localConfig", None) if local_node else None
lora_message = _resolve_lora_message(local_config)
if lora_message is None:
return
frequency = _region_frequency(lora_message)
preset = _modem_preset(lora_message)
updated = False
if frequency is not None and getattr(config, "LORA_FREQ", None) is None:
config.LORA_FREQ = frequency
updated = True
if preset is not None and getattr(config, "MODEM_PRESET", None) is None:
config.MODEM_PRESET = preset
updated = True
if updated:
config._debug_log(
"Captured LoRa radio metadata",
context="interfaces.ensure_radio_metadata",
severity="info",
always=True,
lora_freq=frequency,
modem_preset=preset,
)
def _ensure_channel_metadata(iface: Any) -> None:
"""Capture channel metadata by inspecting ``iface`` once per runtime."""
if iface is None:
return
try:
channels.capture_from_interface(iface)
except Exception as exc: # pragma: no cover - defensive instrumentation
config._debug_log(
"Failed to capture channel metadata",
context="interfaces.ensure_channel_metadata",
severity="warn",
error_class=exc.__class__.__name__,
error_message=str(exc),
)
_DEFAULT_TCP_PORT = 4403
_DEFAULT_TCP_TARGET = "http://127.0.0.1"
@@ -416,6 +599,8 @@ def _create_default_interface() -> tuple[object, str]:
__all__ = [
"BLEInterface",
"NoAvailableMeshInterface",
"_ensure_channel_metadata",
"_ensure_radio_metadata",
"_DummySerialInterface",
"_DEFAULT_TCP_PORT",
"_DEFAULT_TCP_TARGET",
+4 -1
View File
@@ -24,7 +24,10 @@ CREATE TABLE IF NOT EXISTS messages (
encrypted TEXT,
snr REAL,
rssi INTEGER,
hop_limit INTEGER
hop_limit INTEGER,
lora_freq INTEGER,
modem_preset TEXT,
channel_name TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time);
@@ -0,0 +1,22 @@
-- Copyright (C) 2025 l5yth
--
-- 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.
--
-- 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;
ALTER TABLE messages ADD COLUMN lora_freq INTEGER;
ALTER TABLE messages ADD COLUMN modem_preset TEXT;
ALTER TABLE messages ADD COLUMN channel_name TEXT;
COMMIT;
+3 -1
View File
@@ -39,7 +39,9 @@ CREATE TABLE IF NOT EXISTS nodes (
precision_bits INTEGER,
latitude REAL,
longitude REAL,
altitude REAL
altitude REAL,
lora_freq INTEGER,
modem_preset TEXT
);
CREATE INDEX IF NOT EXISTS idx_nodes_last_heard ON nodes(last_heard);
+6
View File
@@ -11,6 +11,9 @@
"rssi": -121,
"hop_limit": 1,
"snr": -13.25,
"lora_freq": 915,
"modem_preset": "LONG_FAST",
"channel_name": "SpecChannel",
"node": {
"snr": -13.25,
"node_id": "!bba83318",
@@ -50,6 +53,9 @@
"rssi": -117,
"hop_limit": 3,
"snr": -12.0,
"lora_freq": 868,
"modem_preset": "MEDIUM_SLOW",
"channel_name": "SpecChannel",
"node": {
"snr": -12.0,
"node_id": "!43b6e530",
+6 -2
View File
@@ -20,7 +20,9 @@
"last_seen_iso": "2025-09-16T12:05:30Z",
"pos_time_iso": "2025-09-16T12:05:30Z",
"location_source": "LOC_FIXTURE_0",
"precision_bits": 10
"precision_bits": 10,
"lora_freq": 915,
"modem_preset": "LONG_FAST"
},
{
"node_id": "!d1edc388",
@@ -65,7 +67,9 @@
"last_seen_iso": "2025-09-16T12:05:05Z",
"pos_time_iso": "2025-09-16T12:05:05Z",
"location_source": "LOC_FIXTURE_2",
"precision_bits": 12
"precision_bits": 12,
"lora_freq": 868,
"modem_preset": "MEDIUM_SLOW"
},
{
"node_id": "!33602324",
+299
View File
@@ -179,6 +179,14 @@ def mesh_module(monkeypatch):
if hasattr(module, "_clear_post_queue"):
module._clear_post_queue()
# Ensure radio metadata starts unset for each test run.
module.config.LORA_FREQ = None
module.config.MODEM_PRESET = None
for attr in ("LORA_FREQ", "MODEM_PRESET"):
if attr in module.__dict__:
delattr(module, attr)
module.channels._reset_channel_cache()
yield module
# Ensure a clean import for the next test
@@ -293,6 +301,166 @@ def test_create_serial_interface_ble(mesh_module, monkeypatch):
assert iface.nodes == {}
def test_ensure_radio_metadata_extracts_config(mesh_module, capsys):
mesh = mesh_module
class DummyEnumValue:
def __init__(self, name: str) -> None:
self.name = name
class DummyEnum:
def __init__(self, mapping: dict[int, str]) -> None:
self.values_by_number = {
number: DummyEnumValue(name) for number, name in mapping.items()
}
class DummyField:
def __init__(self, enum_type=None) -> None:
self.enum_type = enum_type
class DummyDescriptor:
def __init__(self, fields: dict[str, DummyField]) -> None:
self.fields_by_name = fields
def make_lora(
region_value: int,
region_name: str,
preset_value: int,
preset_name: str,
*,
preset_field: str = "modem_preset",
):
descriptor = DummyDescriptor(
{
"region": DummyField(DummyEnum({region_value: region_name})),
preset_field: DummyField(DummyEnum({preset_value: preset_name})),
}
)
class DummyLora:
DESCRIPTOR = descriptor
def __init__(self) -> None:
self.region = region_value
setattr(self, preset_field, preset_value)
def HasField(self, name: str) -> bool: # noqa: D401 - simple proxy
return hasattr(self, name)
return DummyLora()
class DummyRadio:
def __init__(self, lora) -> None:
self.lora = lora
def HasField(self, name: str) -> bool:
return hasattr(self, name)
class DummyConfig:
def __init__(self, lora, *, expose_direct: bool) -> None:
if expose_direct:
self.lora = lora
else:
self.radio = DummyRadio(lora)
def HasField(self, name: str) -> bool: # noqa: D401 - mimics protobuf API
return hasattr(self, name)
class DummyLocalNode:
def __init__(self, config) -> None:
self.localConfig = config
class DummyInterface:
def __init__(self, local_config) -> None:
self.localNode = DummyLocalNode(local_config)
self.wait_calls = 0
def waitForConfig(self) -> None: # noqa: D401 - matches Meshtastic API
self.wait_calls += 1
primary_lora = make_lora(3, "EU_868", 4, "MEDIUM_FAST")
iface = DummyInterface(DummyConfig(primary_lora, expose_direct=False))
mesh._ensure_radio_metadata(iface)
first_log = capsys.readouterr().out
assert iface.wait_calls == 1
assert mesh.config.LORA_FREQ == 868
assert mesh.config.MODEM_PRESET == "MediumFast"
assert "Captured LoRa radio metadata" in first_log
assert "lora_freq=868" in first_log
assert "modem_preset='MediumFast'" in first_log
secondary_lora = make_lora(7, "US_915", 2, "LONG_FAST", preset_field="preset")
second_iface = DummyInterface(DummyConfig(secondary_lora, expose_direct=True))
mesh._ensure_radio_metadata(second_iface)
second_log = capsys.readouterr().out
assert second_iface.wait_calls == 1
assert mesh.config.LORA_FREQ == 868
assert mesh.config.MODEM_PRESET == "MediumFast"
assert second_log == ""
def test_capture_channels_from_interface_records_metadata(mesh_module, capsys):
mesh = mesh_module
mesh.config.MODEM_PRESET = "MediumFast"
class DummyInterface:
def __init__(self) -> None:
self.wait_calls = 0
primary = SimpleNamespace(role=1, settings=SimpleNamespace())
secondary = SimpleNamespace(
role="SECONDARY",
index="7",
settings=SimpleNamespace(name="TestChannel"),
)
self.localNode = SimpleNamespace(channels=[primary, secondary])
def waitForConfig(self) -> None: # noqa: D401 - matches interface contract
self.wait_calls += 1
iface = DummyInterface()
mesh.channels.capture_from_interface(iface)
log_output = capsys.readouterr().out
assert iface.wait_calls == 1
assert mesh.channels.channel_mappings() == ((0, "MediumFast"), (7, "TestChannel"))
assert mesh.channels.channel_name(7) == "TestChannel"
assert "Captured channel metadata" in log_output
assert "channels=((0, 'MediumFast'), (7, 'TestChannel'))" in log_output
mesh.channels.capture_from_interface(SimpleNamespace(localNode=None))
assert mesh.channels.channel_mappings() == ((0, "MediumFast"), (7, "TestChannel"))
def test_capture_channels_primary_falls_back_to_env(mesh_module, monkeypatch, capsys):
mesh = mesh_module
mesh.config.MODEM_PRESET = None
monkeypatch.setenv("CHANNEL", "FallbackName")
class DummyInterface:
def __init__(self) -> None:
self.localNode = SimpleNamespace(
channels={"primary": SimpleNamespace(role="PRIMARY")}
)
def waitForConfig(self) -> None: # noqa: D401 - placeholder
return None
mesh.channels._reset_channel_cache()
mesh.channels.capture_from_interface(DummyInterface())
log_output = capsys.readouterr().out
assert mesh.channels.channel_mappings() == ((0, "FallbackName"),)
assert mesh.channels.channel_name(0) == "FallbackName"
assert "FallbackName" in log_output
def test_create_default_interface_falls_back_to_tcp(mesh_module, monkeypatch):
mesh = mesh_module
attempts = []
@@ -371,6 +539,9 @@ def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 123,
"rxTime": 1_700_000_000,
@@ -403,6 +574,8 @@ def test_store_packet_dict_posts_text_message(mesh_module, monkeypatch):
assert payload["hop_limit"] == 3
assert payload["snr"] == pytest.approx(1.25)
assert payload["rssi"] == -70
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
assert priority == mesh._MESSAGE_POST_PRIORITY
@@ -415,6 +588,9 @@ def test_store_packet_dict_posts_position(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 200498337,
"rxTime": 1_758_624_186,
@@ -479,6 +655,8 @@ def test_store_packet_dict_posts_position(mesh_module, monkeypatch):
payload["payload_b64"]
== "DQDATR8VAMATCBjw//////////8BJb150mgoAljTAXgCgAEAmAEHuAER"
)
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
assert payload["raw"]["time"] == 1_758_624_189
@@ -491,6 +669,9 @@ def test_store_packet_dict_posts_neighborinfo(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 2049886869,
"rxTime": 1_758_884_186,
@@ -532,6 +713,8 @@ def test_store_packet_dict_posts_neighborinfo(mesh_module, monkeypatch):
assert neighbors[1]["snr"] == pytest.approx(-2.75)
assert neighbors[2]["neighbor_id"] == "!0badc0de"
assert neighbors[2]["neighbor_num"] == 0x0BAD_C0DE
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
def test_store_packet_dict_handles_nodeinfo_packet(mesh_module, monkeypatch):
@@ -543,6 +726,9 @@ def test_store_packet_dict_handles_nodeinfo_packet(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
from meshtastic.protobuf import config_pb2, mesh_pb2
node_info = mesh_pb2.NodeInfo()
@@ -602,6 +788,8 @@ def test_store_packet_dict_handles_nodeinfo_packet(mesh_module, monkeypatch):
assert node_entry["position"]["latitude"] == pytest.approx(52.5)
assert node_entry["position"]["longitude"] == pytest.approx(13.4)
assert node_entry["position"]["time"] == 1_700_000_050
assert node_entry["lora_freq"] == 868
assert node_entry["modem_preset"] == "MediumFast"
def test_store_packet_dict_handles_user_only_nodeinfo(mesh_module, monkeypatch):
@@ -613,6 +801,9 @@ def test_store_packet_dict_handles_user_only_nodeinfo(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
from meshtastic.protobuf import mesh_pb2
user_msg = mesh_pb2.User()
@@ -645,6 +836,8 @@ def test_store_packet_dict_handles_user_only_nodeinfo(mesh_module, monkeypatch):
assert node_entry["lastHeard"] == 1_234
assert node_entry["user"]["longName"] == "Test Node"
assert "deviceMetrics" not in node_entry
assert node_entry["lora_freq"] == 868
assert node_entry["modem_preset"] == "MediumFast"
def test_store_packet_dict_nodeinfo_merges_proto_user(mesh_module, monkeypatch):
@@ -656,6 +849,9 @@ def test_store_packet_dict_nodeinfo_merges_proto_user(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
from meshtastic.protobuf import mesh_pb2
user_msg = mesh_pb2.User()
@@ -686,6 +882,8 @@ def test_store_packet_dict_nodeinfo_merges_proto_user(mesh_module, monkeypatch):
assert node_entry["lastHeard"] == 5_000
assert node_entry["user"]["shortName"] == "Proto"
assert node_entry["user"]["longName"] == "Proto User"
assert node_entry["lora_freq"] == 868
assert node_entry["modem_preset"] == "MediumFast"
def test_store_packet_dict_nodeinfo_sanitizes_nested_proto(mesh_module, monkeypatch):
@@ -697,6 +895,9 @@ def test_store_packet_dict_nodeinfo_sanitizes_nested_proto(mesh_module, monkeypa
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
from meshtastic.protobuf import mesh_pb2
user_msg = mesh_pb2.User()
@@ -730,6 +931,8 @@ def test_store_packet_dict_nodeinfo_sanitizes_nested_proto(mesh_module, monkeypa
assert node_entry["user"]["shortName"] == "Nested"
assert isinstance(node_entry["user"]["raw"], dict)
assert node_entry["user"]["raw"]["id"] == "!55667788"
assert node_entry["lora_freq"] == 868
assert node_entry["modem_preset"] == "MediumFast"
def test_store_packet_dict_nodeinfo_uses_from_id_when_user_missing(
@@ -743,6 +946,9 @@ def test_store_packet_dict_nodeinfo_uses_from_id_when_user_missing(
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
from meshtastic.protobuf import mesh_pb2
node_info = mesh_pb2.NodeInfo()
@@ -766,6 +972,8 @@ def test_store_packet_dict_nodeinfo_uses_from_id_when_user_missing(
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_store_packet_dict_ignores_non_text(mesh_module, monkeypatch):
@@ -1081,6 +1289,9 @@ def test_store_packet_dict_uses_top_level_channel(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": "789",
"rxTime": 123456,
@@ -1100,6 +1311,8 @@ def test_store_packet_dict_uses_top_level_channel(mesh_module, monkeypatch):
assert payload["text"] == "hi"
assert payload["encrypted"] is None
assert payload["snr"] is None and payload["rssi"] is None
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
assert priority == mesh._MESSAGE_POST_PRIORITY
@@ -1112,6 +1325,9 @@ def test_store_packet_dict_handles_invalid_channel(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 321,
"rxTime": 999,
@@ -1130,9 +1346,69 @@ def test_store_packet_dict_handles_invalid_channel(mesh_module, monkeypatch):
assert path == "/api/messages"
assert payload["channel"] == 0
assert payload["encrypted"] is None
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
assert priority == mesh._MESSAGE_POST_PRIORITY
def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys):
mesh = mesh_module
mesh.channels._reset_channel_cache()
mesh.config.MODEM_PRESET = "MediumFast"
class DummyInterface:
def __init__(self) -> None:
self.localNode = SimpleNamespace(
channels=[
SimpleNamespace(role=1, settings=SimpleNamespace()),
SimpleNamespace(
role=2,
index=5,
settings=SimpleNamespace(name="Chat"),
),
]
)
def waitForConfig(self) -> None: # noqa: D401 - matches interface contract
return None
mesh.channels.capture_from_interface(DummyInterface())
capsys.readouterr()
captured = []
monkeypatch.setattr(
mesh,
"_queue_post_json",
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
monkeypatch.setattr(mesh, "DEBUG", True)
packet = {
"id": "789",
"rxTime": 123456,
"from": "!abc",
"to": "!def",
"channel": 5,
"decoded": {"text": "hi", "portnum": 1},
}
mesh.store_packet_dict(packet)
assert captured, "Expected message to be stored"
path, payload, priority = captured[0]
assert path == "/api/messages"
assert payload["channel_name"] == "Chat"
assert payload["channel"] == 5
assert payload["text"] == "hi"
assert payload["encrypted"] is None
assert priority == mesh._MESSAGE_POST_PRIORITY
log_output = capsys.readouterr().out
assert "channel_name='Chat'" in log_output
assert "channel_display='Chat'" in log_output
def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
@@ -1142,6 +1418,9 @@ def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 555,
"rxTime": 111,
@@ -1160,6 +1439,9 @@ def test_store_packet_dict_includes_encrypted_payload(mesh_module, monkeypatch):
assert payload["text"] is None
assert payload["from_id"] == 2988082812
assert payload["to_id"] == "!receiver"
assert "channel_name" not in payload
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
assert priority == mesh._MESSAGE_POST_PRIORITY
@@ -1172,6 +1454,9 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 1_256_091_342,
"rxTime": 1_758_024_300,
@@ -1219,6 +1504,8 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
assert payload["channel_utilization"] == pytest.approx(0.59666663)
assert payload["air_util_tx"] == pytest.approx(0.03908333)
assert payload["uptime_seconds"] == 305044
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatch):
@@ -1230,6 +1517,9 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {
"id": 2_817_720_548,
"rxTime": 1_758_024_400,
@@ -1259,6 +1549,8 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc
assert payload["temperature"] == pytest.approx(21.98)
assert payload["relative_humidity"] == pytest.approx(39.475586)
assert payload["barometric_pressure"] == pytest.approx(1017.8353)
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
def test_post_queue_prioritises_messages(mesh_module, monkeypatch):
@@ -1771,6 +2063,9 @@ def test_store_position_packet_defaults(mesh_module, monkeypatch):
lambda path, payload, *, priority: captured.append((path, payload, priority)),
)
mesh.config.LORA_FREQ = 868
mesh.config.MODEM_PRESET = "MediumFast"
packet = {"id": "7", "rxTime": "", "from": "!abcd", "to": "", "decoded": {}}
mesh.store_position_packet(packet, {})
@@ -1782,6 +2077,8 @@ def test_store_position_packet_defaults(mesh_module, monkeypatch):
assert payload["to_id"] is None
assert payload["latitude"] is None
assert payload["longitude"] is None
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
def test_store_nodeinfo_packet_debug(mesh_module, monkeypatch, capsys):
@@ -1877,6 +2174,8 @@ def test_store_packet_dict_debug_message(mesh_module, monkeypatch, capsys):
out = capsys.readouterr().out
assert "context=handlers.store_packet_dict" in out
assert "Queued message payload" in out
assert "channel_display=0" in out
assert "channel_name=" not in out
def test_on_receive_skips_seen_packets(mesh_module):
+9 -3
View File
@@ -44,10 +44,16 @@ WORKDIR /app
# Copy installed gems from builder stage
COPY --from=builder /usr/local/bundle /usr/local/bundle
# Copy application code (exclude Dockerfile from web directory)
COPY --chown=potatomesh:potatomesh web/app.rb web/app.sh web/Gemfile web/Gemfile.lock* web/spec/ ./
# Copy application code (excluding the Dockerfile which is not required at runtime)
COPY --chown=potatomesh:potatomesh web/app.rb ./
COPY --chown=potatomesh:potatomesh web/app.sh ./
COPY --chown=potatomesh:potatomesh web/Gemfile ./
COPY --chown=potatomesh:potatomesh web/Gemfile.lock* ./
COPY --chown=potatomesh:potatomesh web/lib ./lib
COPY --chown=potatomesh:potatomesh web/spec ./spec
COPY --chown=potatomesh:potatomesh web/public ./public
COPY --chown=potatomesh:potatomesh web/views/ ./views/
COPY --chown=potatomesh:potatomesh web/views ./views
COPY --chown=potatomesh:potatomesh web/scripts ./scripts
# Copy SQL schema files from data directory
COPY --chown=potatomesh:potatomesh data/*.sql /data/
@@ -220,6 +220,9 @@ module PotatoMesh
update_prometheus_metrics(node_id, user, role, met, pos)
lora_freq = coerce_integer(n["lora_freq"] || n["loraFrequency"])
modem_preset = string_or_nil(n["modem_preset"] || n["modemPreset"])
row = [
node_id,
node_num,
@@ -250,13 +253,15 @@ module PotatoMesh
pos["latitude"],
pos["longitude"],
pos["altitude"],
lora_freq,
modem_preset,
]
with_busy_retry do
db.execute <<~SQL, row
INSERT INTO nodes(node_id,num,short_name,long_name,macaddr,hw_model,role,public_key,is_unmessagable,is_favorite,
hops_away,snr,last_heard,first_heard,battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,
position_time,location_source,precision_bits,latitude,longitude,altitude)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
position_time,location_source,precision_bits,latitude,longitude,altitude,lora_freq,modem_preset)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(node_id) DO UPDATE SET
num=excluded.num, short_name=excluded.short_name, long_name=excluded.long_name, macaddr=excluded.macaddr,
hw_model=excluded.hw_model, role=excluded.role, public_key=excluded.public_key, is_unmessagable=excluded.is_unmessagable,
@@ -265,7 +270,7 @@ module PotatoMesh
battery_level=excluded.battery_level, voltage=excluded.voltage, channel_utilization=excluded.channel_utilization,
air_util_tx=excluded.air_util_tx, uptime_seconds=excluded.uptime_seconds, position_time=excluded.position_time,
location_source=excluded.location_source, precision_bits=excluded.precision_bits, latitude=excluded.latitude, longitude=excluded.longitude,
altitude=excluded.altitude
altitude=excluded.altitude, lora_freq=excluded.lora_freq, modem_preset=excluded.modem_preset
WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0)
SQL
end
@@ -941,6 +946,10 @@ module PotatoMesh
source: :message,
)
lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"])
modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"])
channel_name = string_or_nil(message["channel_name"] || message["channelName"])
row = [
msg_id,
rx_time,
@@ -954,11 +963,14 @@ module PotatoMesh
message["snr"],
message["rssi"],
message["hop_limit"],
lora_freq,
modem_preset,
channel_name,
]
with_busy_retry do
existing = db.get_first_row(
"SELECT from_id, to_id, encrypted FROM messages WHERE id = ?",
"SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name FROM messages WHERE id = ?",
[msg_id],
)
if existing
@@ -988,6 +1000,27 @@ module PotatoMesh
updates["encrypted"] = encrypted if should_update
end
unless lora_freq.nil?
existing_lora = existing.is_a?(Hash) ? existing["lora_freq"] : existing[3]
updates["lora_freq"] = lora_freq if existing_lora != lora_freq
end
if modem_preset
existing_preset = existing.is_a?(Hash) ? existing["modem_preset"] : existing[4]
existing_preset_str = existing_preset&.to_s
should_update = existing_preset_str.nil? || existing_preset_str.strip.empty?
should_update ||= existing_preset != modem_preset
updates["modem_preset"] = modem_preset if should_update
end
if channel_name
existing_channel = existing.is_a?(Hash) ? existing["channel_name"] : existing[5]
existing_channel_str = existing_channel&.to_s
should_update = existing_channel_str.nil? || existing_channel_str.strip.empty?
should_update ||= existing_channel != channel_name
updates["channel_name"] = channel_name if should_update
end
unless updates.empty?
assignments = updates.keys.map { |column| "#{column} = ?" }.join(", ")
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", updates.values + [msg_id])
@@ -997,14 +1030,17 @@ module PotatoMesh
begin
db.execute <<~SQL, row
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
SQL
rescue SQLite3::ConstraintException
fallback_updates = {}
fallback_updates["from_id"] = from_id if from_id
fallback_updates["to_id"] = to_id if to_id
fallback_updates["encrypted"] = encrypted if encrypted
fallback_updates["lora_freq"] = lora_freq unless lora_freq.nil?
fallback_updates["modem_preset"] = modem_preset if modem_preset
fallback_updates["channel_name"] = channel_name if channel_name
unless fallback_updates.empty?
assignments = fallback_updates.keys.map { |column| "#{column} = ?" }.join(", ")
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", fallback_updates.values + [msg_id])
@@ -89,6 +89,29 @@ module PotatoMesh
node_columns = db.execute("PRAGMA table_info(nodes)").map { |row| row[1] }
unless node_columns.include?("precision_bits")
db.execute("ALTER TABLE nodes ADD COLUMN precision_bits INTEGER")
node_columns << "precision_bits"
end
unless node_columns.include?("lora_freq")
db.execute("ALTER TABLE nodes ADD COLUMN lora_freq INTEGER")
end
unless node_columns.include?("modem_preset")
db.execute("ALTER TABLE nodes ADD COLUMN modem_preset TEXT")
end
message_columns = db.execute("PRAGMA table_info(messages)").map { |row| row[1] }
unless message_columns.include?("lora_freq")
db.execute("ALTER TABLE messages ADD COLUMN lora_freq INTEGER")
end
unless message_columns.include?("modem_preset")
db.execute("ALTER TABLE messages ADD COLUMN modem_preset TEXT")
end
unless message_columns.include?("channel_name")
db.execute("ALTER TABLE messages ADD COLUMN channel_name TEXT")
end
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='instances'").flatten
+26 -4
View File
@@ -19,9 +19,8 @@ module PotatoMesh
#
# @return [String] semantic version compatible identifier.
def determine_app_version
repo_root = File.expand_path("../../..", __dir__)
git_dir = File.join(repo_root, ".git")
return PotatoMesh::Config.version_fallback unless File.directory?(git_dir)
repo_root = locate_git_repo_root(File.expand_path("../../..", __dir__))
return PotatoMesh::Config.version_fallback unless repo_root
stdout, status = Open3.capture2("git", "-C", repo_root, "describe", "--tags", "--long", "--abbrev=7")
return PotatoMesh::Config.version_fallback unless status.success?
@@ -42,6 +41,29 @@ module PotatoMesh
PotatoMesh::Config.version_fallback
end
# Discover the root directory of the git repository containing the
# application by traversing parent directories until a ``.git`` entry is
# located. This supports both traditional repositories where ``.git`` is a
# directory and worktree checkouts where it is a plain file.
#
# @param start_dir [String] absolute path where the search should begin.
# @return [String, nil] absolute path to the repository root when found,
# otherwise ``nil``.
def locate_git_repo_root(start_dir)
current = File.expand_path(start_dir)
loop do
git_entry = File.join(current, ".git")
return current if File.exist?(git_entry)
parent = File.dirname(current)
break if parent == current
current = parent
end
nil
end
# Load the persisted instance private key or generate a new one when absent.
#
# @return [Array<OpenSSL::PKey::RSA, Boolean>] tuple of key and generation flag.
@@ -111,7 +133,7 @@ module PotatoMesh
end
end
private :migrate_legacy_keyfile_for_identity!
private :migrate_legacy_keyfile_for_identity!, :locate_git_repo_root
# Return the directory used to store well-known documents.
#
+33 -9
View File
@@ -148,7 +148,7 @@ module PotatoMesh
battery_level, voltage, last_heard, first_heard,
uptime_seconds, channel_utilization, air_util_tx,
position_time, location_source, precision_bits,
latitude, longitude, altitude
latitude, longitude, altitude, lora_freq, modem_preset
FROM nodes
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
@@ -159,7 +159,7 @@ module PotatoMesh
params << limit
rows = db.execute(sql, params)
rows.select! do |r|
rows = rows.select do |r|
last_candidate = [r["last_heard"], r["position_time"], r["first_heard"]]
.map { |value| coerce_integer(value) }
.compact
@@ -203,7 +203,28 @@ module PotatoMesh
end
sql = <<~SQL
SELECT m.*, n.*, m.snr AS msg_snr
SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel,
m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit,
m.lora_freq AS msg_lora_freq, m.modem_preset AS msg_modem_preset,
m.channel_name AS msg_channel_name, m.snr AS msg_snr,
n.node_id AS node_node_id, n.num AS node_num,
n.short_name AS node_short_name, n.long_name AS node_long_name,
n.macaddr AS node_macaddr, n.hw_model AS node_hw_model,
n.role AS node_role, n.public_key AS node_public_key,
n.is_unmessagable AS node_is_unmessagable,
n.is_favorite AS node_is_favorite,
n.hops_away AS node_hops_away, n.snr AS node_snr,
n.last_heard AS node_last_heard, n.first_heard AS node_first_heard,
n.battery_level AS node_battery_level, n.voltage AS node_voltage,
n.channel_utilization AS node_channel_utilization,
n.air_util_tx AS node_air_util_tx,
n.uptime_seconds AS node_uptime_seconds,
n.position_time AS node_position_time,
n.location_source AS node_location_source,
n.precision_bits AS node_precision_bits,
n.latitude AS node_latitude, n.longitude AS node_longitude,
n.altitude AS node_altitude,
n.lora_freq AS node_lora_freq, n.modem_preset AS node_modem_preset
FROM messages m
LEFT JOIN nodes n ON (
m.from_id IS NOT NULL AND TRIM(m.from_id) <> '' AND (
@@ -220,8 +241,12 @@ module PotatoMesh
SQL
params << limit
rows = db.execute(sql, params)
msg_fields = %w[id rx_time rx_iso from_id to_id channel portnum text encrypted msg_snr rssi hop_limit]
rows.each do |r|
r.delete_if { |key, _| key.is_a?(Integer) }
r["lora_freq"] = r.delete("msg_lora_freq")
r["modem_preset"] = r.delete("msg_modem_preset")
r["channel_name"] = r.delete("msg_channel_name")
snr_value = r.delete("msg_snr")
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
debug_log(
@@ -238,11 +263,11 @@ module PotatoMesh
)
end
node = {}
r.keys.each do |k|
next if msg_fields.include?(k)
node[k] = r.delete(k)
r.keys.grep(/^node_/).each do |k|
attribute = k.delete_prefix("node_")
node[attribute] = r.delete(k)
end
r["snr"] = r.delete("msg_snr")
r["snr"] = snr_value
references = [r["from_id"]].compact
if references.any? && (node["node_id"].nil? || node["node_id"].to_s.empty?)
lookup_keys = []
@@ -260,7 +285,6 @@ module PotatoMesh
if fallback
fallback.each do |key, value|
next unless key.is_a?(String)
next if msg_fields.include?(key)
node[key] = value if node[key].nil?
end
end
@@ -238,6 +238,7 @@ class StubElement {
function createStubDom() {
const body = new StubElement('body');
body.contains = body.contains.bind(body);
const listenerMap = new Map();
const document = {
body,
documentElement: { clientWidth: 640, clientHeight: 480 },
@@ -247,6 +248,26 @@ function createStubDom() {
getElementById() {
return null;
},
addEventListener(event, handler) {
if (!listenerMap.has(event)) {
listenerMap.set(event, new Set());
}
listenerMap.get(event).add(handler);
},
removeEventListener(event, handler) {
if (!listenerMap.has(event)) {
return;
}
listenerMap.get(event).delete(handler);
},
_dispatch(event) {
if (!listenerMap.has(event)) {
return;
}
for (const handler of Array.from(listenerMap.get(event))) {
handler();
}
},
};
const window = {
scrollX: 10,
@@ -282,6 +303,7 @@ test('render opens overlays and positions them relative to anchors', () => {
assert.equal(open.length, 1);
const overlay = open[0].element;
assert.equal(overlay.parentNode, body);
assert.equal(overlay.style.position, 'absolute');
const content = overlay.querySelector('.short-info-content');
assert.ok(content);
assert.equal(content.innerHTML, '<strong>Node</strong>');
@@ -340,6 +362,31 @@ test('containsNode recognises overlay descendants', () => {
assert.equal(stack.containsNode(stray), false);
});
test('overlays migrate into and out of fullscreen hosts', () => {
const { document, window, factory, anchor, body } = createStubDom();
const fullscreenRoot = document.createElement('div');
body.appendChild(fullscreenRoot);
const stack = createShortInfoOverlayStack({ document, window, factory });
stack.render(anchor, 'Fullscreen');
const [entry] = stack.getOpenOverlays();
assert.equal(entry.element.parentNode, body);
assert.equal(entry.element.style.position, 'absolute');
document.fullscreenElement = fullscreenRoot;
document._dispatch('fullscreenchange');
assert.equal(entry.element.parentNode, fullscreenRoot);
assert.equal(entry.element.style.position, 'fixed');
assert.equal(entry.element.style.left, '40px');
assert.equal(entry.element.style.top, '50px');
document.fullscreenElement = null;
document._dispatch('fullscreenchange');
assert.equal(entry.element.parentNode, body);
assert.equal(entry.element.style.position, 'absolute');
assert.equal(entry.element.style.left, '50px');
assert.equal(entry.element.style.top, '70px');
});
test('rendered overlays do not swallow click events by default', () => {
const { document, window, factory, anchor } = createStubDom();
const stack = createShortInfoOverlayStack({ document, window, factory });
+81 -18
View File
@@ -58,6 +58,9 @@ export function initializeApp(config) {
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
const infoOverlayHome = infoOverlay
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
: null;
/**
* Column sorter configuration for the node table.
*
@@ -498,6 +501,62 @@ export function initializeApp(config) {
});
});
/**
* Append the informational modal overlay to the fullscreen container when active.
*
* @returns {void}
*/
function attachInfoOverlayToFullscreenHost() {
if (!infoOverlay || !fullscreenContainer) return;
if (infoOverlay.parentNode !== fullscreenContainer) {
fullscreenContainer.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.add('info-overlay--fullscreen');
}
}
/**
* Restore the informational overlay to its original DOM position.
*
* @returns {void}
*/
function restoreInfoOverlayToHome() {
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
if (infoOverlay.parentNode === infoOverlayHome.parent) {
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
return;
}
if (
infoOverlayHome.nextSibling &&
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
typeof infoOverlayHome.parent.insertBefore === 'function'
) {
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
infoOverlayHome.parent.appendChild(infoOverlay);
}
if (infoOverlay.classList) {
infoOverlay.classList.remove('info-overlay--fullscreen');
}
}
/**
* Ensure the informational overlay participates in the active fullscreen subtree.
*
* @returns {void}
*/
function syncInfoOverlayHost() {
if (!infoOverlay) return;
if (isMapInFullscreen()) {
attachInfoOverlayToFullscreenHost();
} else {
restoreInfoOverlayToHome();
}
}
/**
* Respond to fullscreen change events originating from the browser.
*
@@ -528,6 +587,7 @@ export function initializeApp(config) {
mapContainer.style.minHeight = '';
}
}
syncInfoOverlayHost();
updateFullscreenToggleState();
refreshMapSize();
}
@@ -548,6 +608,8 @@ export function initializeApp(config) {
}
}
syncInfoOverlayHost();
// Firmware 2.7.10 / Android 2.7.0 roles and colors (see issue #177)
const roleColors = Object.freeze({
CLIENT_HIDDEN: '#A9CBE8',
@@ -1355,6 +1417,7 @@ export function initializeApp(config) {
*/
function openInfoOverlay() {
if (!infoOverlay || !infoDialog) return;
syncInfoOverlayHost();
lastFocusBeforeInfo = document.activeElement;
infoOverlay.hidden = false;
document.body.style.setProperty('overflow', 'hidden');
@@ -2581,24 +2644,24 @@ export function initializeApp(config) {
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
tr.innerHTML = `
<td class="mono">${n.node_id || ""}</td>
<td>${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
<td>${n.long_name || ""}</td>
<td>${timeAgo(n.last_heard, nowSec)}</td>
<td>${n.role || "CLIENT"}</td>
<td>${fmtHw(n.hw_model)}</td>
<td>${fmtAlt(n.battery_level, "%")}</td>
<td>${fmtAlt(n.voltage, "V")}</td>
<td>${timeHum(n.uptime_seconds)}</td>
<td>${fmtTx(n.channel_utilization)}</td>
<td>${fmtTx(n.air_util_tx)}</td>
<td>${fmtTemperature(n.temperature)}</td>
<td>${fmtHumidity(n.relative_humidity)}</td>
<td>${fmtPressure(n.barometric_pressure)}</td>
<td>${fmtCoords(n.latitude)}</td>
<td>${fmtCoords(n.longitude)}</td>
<td>${fmtAlt(n.altitude, "m")}</td>
<td class="mono">${lastPositionCell}</td>`;
<td class="mono nodes-col nodes-col--node-id">${n.node_id || ""}</td>
<td class="nodes-col nodes-col--short-name">${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
<td class="nodes-col nodes-col--long-name">${n.long_name || ""}</td>
<td class="nodes-col nodes-col--last-seen">${timeAgo(n.last_heard, nowSec)}</td>
<td class="nodes-col nodes-col--role">${n.role || "CLIENT"}</td>
<td class="nodes-col nodes-col--hw-model">${fmtHw(n.hw_model)}</td>
<td class="nodes-col nodes-col--battery">${fmtAlt(n.battery_level, "%")}</td>
<td class="nodes-col nodes-col--voltage">${fmtAlt(n.voltage, "V")}</td>
<td class="nodes-col nodes-col--uptime">${timeHum(n.uptime_seconds)}</td>
<td class="nodes-col nodes-col--channel-util">${fmtTx(n.channel_utilization)}</td>
<td class="nodes-col nodes-col--air-util-tx">${fmtTx(n.air_util_tx)}</td>
<td class="nodes-col nodes-col--temperature">${fmtTemperature(n.temperature)}</td>
<td class="nodes-col nodes-col--humidity">${fmtHumidity(n.relative_humidity)}</td>
<td class="nodes-col nodes-col--pressure">${fmtPressure(n.barometric_pressure)}</td>
<td class="nodes-col nodes-col--latitude">${fmtCoords(n.latitude)}</td>
<td class="nodes-col nodes-col--longitude">${fmtCoords(n.longitude)}</td>
<td class="nodes-col nodes-col--altitude">${fmtAlt(n.altitude, "m")}</td>
<td class="mono nodes-col nodes-col--last-position">${lastPositionCell}</td>`;
frag.appendChild(tr);
}
tb.replaceChildren(frag);
@@ -15,6 +15,61 @@
*/
const DEFAULT_TEMPLATE_ID = 'shortInfoOverlayTemplate';
const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
];
/**
* Resolve the element currently presented in fullscreen mode.
*
* @param {Document} doc Host document reference.
* @returns {?Element} Fullscreen element or ``null`` when fullscreen is inactive.
*/
function getFullscreenElement(doc) {
if (!doc) return null;
return (
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement ||
null
);
}
/**
* Determine the container that should host overlays.
*
* @param {Document} doc Host document reference.
* @returns {?Element} Preferred overlay host element.
*/
function resolveOverlayHost(doc) {
const fullscreenElement = getFullscreenElement(doc);
if (fullscreenElement && typeof fullscreenElement.appendChild === 'function') {
return fullscreenElement;
}
return doc && doc.body && typeof doc.body.appendChild === 'function' ? doc.body : null;
}
/**
* Update overlay positioning mode based on fullscreen state.
*
* @param {Element} element Overlay DOM node.
* @param {Document} doc Host document reference.
* @returns {void}
*/
function applyOverlayPositioning(element, doc) {
if (!element || !element.style) {
return;
}
const fullscreenElement = getFullscreenElement(doc);
const desired = fullscreenElement ? 'fixed' : 'absolute';
if (element.style.position !== desired) {
element.style.position = desired;
}
}
/**
* Determine whether a value behaves like a DOM element that can host overlays.
@@ -150,6 +205,49 @@ export function createShortInfoOverlayStack(options = {}) {
const overlayStates = new Map();
const overlayOrder = [];
/**
* Retrieve the active overlay host element.
*
* @returns {?Element} Host element capable of containing overlays.
*/
function getOverlayHost() {
return resolveOverlayHost(doc);
}
/**
* Append ``element`` to the preferred overlay host when necessary.
*
* @param {Element} element Overlay root element.
* @returns {void}
*/
function ensureOverlayAttached(element) {
if (!element) return;
const host = getOverlayHost();
if (!host) return;
if (element.parentNode !== host) {
host.appendChild(element);
}
applyOverlayPositioning(element, doc);
}
/**
* React to fullscreen transitions by reattaching overlays to the active host.
*
* @returns {void}
*/
function handleFullscreenChange() {
for (const state of overlayStates.values()) {
ensureOverlayAttached(state.element);
}
positionAll();
}
if (doc && typeof doc.addEventListener === 'function') {
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
doc.addEventListener(eventName, handleFullscreenChange);
}
}
/**
* Remove an overlay element from the DOM tree.
*
@@ -215,9 +313,7 @@ export function createShortInfoOverlayStack(options = {}) {
});
}
if (typeof doc.body.appendChild === 'function') {
doc.body.appendChild(overlayEl);
}
ensureOverlayAttached(overlayEl);
state = {
anchor,
@@ -276,24 +372,27 @@ export function createShortInfoOverlayStack(options = {}) {
(win && typeof win.innerHeight === 'number' ? win.innerHeight : 0);
const scrollX = (win && typeof win.scrollX === 'number' ? win.scrollX : 0) || 0;
const scrollY = (win && typeof win.scrollY === 'number' ? win.scrollY : 0) || 0;
const fullscreenElement = getFullscreenElement(doc);
const offsetX = fullscreenElement ? 0 : scrollX;
const offsetY = fullscreenElement ? 0 : scrollY;
let left = rect.left + scrollX;
let top = rect.top + scrollY;
let left = rect.left + offsetX;
let top = rect.top + offsetY;
if (viewportWidth > 0) {
const maxLeft = scrollX + viewportWidth - overlayRect.width - 8;
left = Math.max(scrollX + 8, Math.min(left, maxLeft));
const maxLeft = offsetX + viewportWidth - overlayRect.width - 8;
left = Math.max(offsetX + 8, Math.min(left, maxLeft));
}
if (viewportHeight > 0) {
const maxTop = scrollY + viewportHeight - overlayRect.height - 8;
top = Math.max(scrollY + 8, Math.min(top, maxTop));
const maxTop = offsetY + viewportHeight - overlayRect.height - 8;
top = Math.max(offsetY + 8, Math.min(top, maxTop));
}
if (state.element.style) {
applyOverlayPositioning(state.element, doc);
state.element.style.left = `${left}px`;
state.element.style.top = `${top}px`;
state.element.style.visibility = 'visible';
state.element.style.position = state.element.style.position || 'absolute';
}
}
@@ -328,6 +427,7 @@ export function createShortInfoOverlayStack(options = {}) {
if (!state) {
return;
}
ensureOverlayAttached(state.element);
if (state.content && typeof state.content.innerHTML === 'string') {
state.content.innerHTML = html;
}
+105 -47
View File
@@ -126,7 +126,7 @@ tbody tr:nth-child(even) td {
body {
font-family: system-ui, Segoe UI, Roboto, Ubuntu, Arial, sans-serif;
margin: var(--pad);
padding-bottom: 32px;
padding-bottom: 96px;
--map-tiles-filter: var(--map-tile-filter-light);
}
@@ -446,7 +446,7 @@ th {
line-height: 1.4;
min-width: 200px;
max-width: 240px;
z-index: 2000;
z-index: 12000;
}
.short-info-overlay[hidden] {
@@ -740,16 +740,66 @@ input[type="radio"] {
font-size: 12px;
}
footer {
.app-footer {
position: fixed;
bottom: 0;
left: var(--pad);
width: calc(100% - 2 * var(--pad));
inset-block-end: 0;
inset-inline: 0;
width: 100%;
background: #fafafa;
border-top: 1px solid #ddd;
text-align: center;
font-size: 12px;
padding: 4px 0;
z-index: 4100;
}
.app-footer .footer-content {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
margin: 0 auto;
width: 100%;
max-width: 960px;
padding: 6px var(--pad);
text-align: center;
box-sizing: border-box;
}
.app-footer .footer-separator {
margin: 0 4px;
}
.app-footer .footer-links {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
}
.app-footer .footer-brand {
font-weight: 600;
}
.app-footer a {
color: inherit;
}
@media (max-width: 600px) {
.app-footer .footer-content {
padding: 10px 12px;
justify-content: center;
gap: 4px 8px;
}
.app-footer .footer-links {
flex-direction: column;
gap: 4px;
}
.app-footer .footer-separator {
display: none;
}
}
.info-overlay {
@@ -760,7 +810,7 @@ footer {
align-items: center;
justify-content: center;
padding: var(--pad);
z-index: 4000;
z-index: 13000;
}
.info-overlay[hidden] {
@@ -832,19 +882,6 @@ footer {
word-break: break-word;
}
@media (max-width: 1280px) {
#nodes th:nth-child(15),
#nodes td:nth-child(15),
#nodes th:nth-child(16),
#nodes td:nth-child(16),
#nodes th:nth-child(17),
#nodes td:nth-child(17),
#nodes th:nth-child(18),
#nodes td:nth-child(18) {
display: none;
}
}
@media (max-width: 1024px) {
.row {
flex-direction: column;
@@ -910,36 +947,57 @@ footer {
height: 30vh;
}
#nodes th:nth-child(1),
#nodes td:nth-child(1),
#nodes th:nth-child(5),
#nodes td:nth-child(5),
#nodes th:nth-child(6),
#nodes td:nth-child(6),
#nodes th:nth-child(9),
#nodes td:nth-child(9),
#nodes th:nth-child(11),
#nodes td:nth-child(11),
#nodes th:nth-child(13),
#nodes td:nth-child(13),
#nodes th:nth-child(14),
#nodes td:nth-child(14),
#nodes th:nth-child(15),
#nodes td:nth-child(15),
#nodes th:nth-child(16),
#nodes td:nth-child(16),
#nodes th:nth-child(17),
#nodes td:nth-child(17),
#nodes th:nth-child(18),
#nodes td:nth-child(18) {
display: none;
}
.legend {
max-width: min(240px, 80vw);
}
}
@media (max-width: 1679px) {
.nodes-col--node-id {
display: none;
}
}
@media (max-width: 1559px) {
.nodes-col--temperature,
.nodes-col--humidity,
.nodes-col--pressure {
display: none;
}
}
@media (max-width: 1319px) {
.nodes-col--latitude,
.nodes-col--longitude,
.nodes-col--last-position {
display: none;
}
}
@media (max-width: 1109px) {
.nodes-col--voltage,
.nodes-col--air-util-tx,
.nodes-col--altitude {
display: none;
}
}
@media (max-width: 899px) {
.nodes-col--uptime,
.nodes-col--frequency,
.nodes-col--modem-preset {
display: none;
}
}
@media (max-width: 659px) {
.nodes-col--battery,
.nodes-col--channel-util,
.nodes-col--hw-model {
display: none;
}
}
body.dark {
background: #111;
color: #eee;
+89 -22
View File
@@ -122,6 +122,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
)
payload["position"] = position unless position.empty?
payload["lora_freq"] = node["lora_freq"] if node.key?("lora_freq")
payload["modem_preset"] = node["modem_preset"] if node.key?("modem_preset")
payload
end
@@ -159,6 +162,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
"latitude" => node["latitude"],
"longitude" => node["longitude"],
"altitude" => node["altitude"],
"lora_freq" => node["lora_freq"],
"modem_preset" => node["modem_preset"],
}
end
@@ -408,65 +413,106 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe "#determine_app_version" do
let(:repo_root) { File.expand_path("..", __dir__) }
let(:git_dir) { File.join(repo_root, ".git") }
describe ".locate_git_repo_root" do
it "returns nil when a git directory cannot be found" do
nested_dir = Dir.mktmpdir("potato-mesh-no-git-")
begin
deep_dir = File.join(nested_dir, "a", "b", "c")
FileUtils.mkdir_p(deep_dir)
before do
allow(File).to receive(:directory?).and_call_original
result = application_class.send(:locate_git_repo_root, deep_dir)
expect(result).to be_nil
ensure
FileUtils.remove_entry(nested_dir)
end
end
it "returns the fallback when the git directory is missing" do
allow(File).to receive(:directory?).with(git_dir).and_return(false)
it "locates a git directory" do
nested_dir = Dir.mktmpdir("potato-mesh-with-git-")
begin
repo_root = File.join(nested_dir, "repo")
FileUtils.mkdir_p(File.join(repo_root, ".git"))
deep_dir = File.join(repo_root, "lib", "potato")
FileUtils.mkdir_p(deep_dir)
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
result = application_class.send(:locate_git_repo_root, deep_dir)
expect(result).to eq(repo_root)
ensure
FileUtils.remove_entry(nested_dir)
end
end
it "recognises git worktree files" do
nested_dir = Dir.mktmpdir("potato-mesh-worktree-")
begin
repo_root = File.join(nested_dir, "worktree")
FileUtils.mkdir_p(repo_root)
File.write(File.join(repo_root, ".git"), "gitdir: /tmp/worktree")
deep_dir = File.join(repo_root, "app", "lib")
FileUtils.mkdir_p(deep_dir)
result = application_class.send(:locate_git_repo_root, deep_dir)
expect(result).to eq(repo_root)
ensure
FileUtils.remove_entry(nested_dir)
end
end
end
describe "#determine_app_version" do
let(:repo_root) { File.expand_path("..", __dir__) }
it "returns the fallback when the git directory is missing" do
allow(application_class).to receive(:locate_git_repo_root).and_return(nil)
expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
it "returns the fallback when git describe fails" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root)
status = instance_double(Process::Status, success?: false)
allow(Open3).to receive(:capture2).and_return(["ignored", status])
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
it "returns the fallback when git describe output is empty" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root)
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture2).and_return(["\n", status])
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
it "returns the original describe output when the format is unexpected" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root)
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture2).and_return(["weird-output", status])
expect(determine_app_version).to eq("weird-output")
expect(application_class.determine_app_version).to eq("weird-output")
end
it "normalises the version when no commits are ahead of the tag" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root)
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture2).and_return(["v1.2.3-0-gabcdef1", status])
expect(determine_app_version).to eq("v1.2.3")
expect(application_class.determine_app_version).to eq("v1.2.3")
end
it "includes commit metadata when ahead of the tag" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root)
status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture2).and_return(["v1.2.3-5-gabcdef1", status])
expect(determine_app_version).to eq("v1.2.3+5-abcdef1")
expect(application_class.determine_app_version).to eq("v1.2.3+5-abcdef1")
end
it "returns the fallback when git describe raises an error" do
allow(File).to receive(:directory?).with(git_dir).and_return(true)
allow(application_class).to receive(:locate_git_repo_root).and_return(repo_root)
allow(Open3).to receive(:capture2).and_raise(StandardError, "boom")
expect(determine_app_version).to eq(PotatoMesh::Config.version_fallback)
expect(application_class.determine_app_version).to eq(PotatoMesh::Config.version_fallback)
end
end
@@ -764,6 +810,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include("#{APP_VERSION}")
end
it "renders the responsive footer container" do
get "/"
expect(last_response.body).to include('<footer class="app-footer">')
expect(last_response.body).to include('class="footer-content"')
end
it "includes SEO metadata from configuration" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Spec Mesh Title")
allow(PotatoMesh::Config).to receive(:channel).and_return("#SpecChannel")
@@ -1103,7 +1156,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
SELECT node_id, short_name, long_name, hw_model, role, snr,
battery_level, voltage, last_heard, first_heard,
uptime_seconds, channel_utilization, air_util_tx,
position_time, latitude, longitude, altitude
position_time, location_source, precision_bits,
latitude, longitude, altitude, lora_freq, modem_preset
FROM nodes
ORDER BY node_id
SQL
@@ -1125,9 +1179,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(row["channel_utilization"], expected["channel_utilization"])
expect_same_value(row["air_util_tx"], expected["air_util_tx"])
expect_same_value(row["position_time"], expected["position_time"])
expect(row["location_source"]).to eq(expected["location_source"])
expect_same_value(row["precision_bits"], expected["precision_bits"])
expect_same_value(row["latitude"], expected["latitude"])
expect_same_value(row["longitude"], expected["longitude"])
expect_same_value(row["altitude"], expected["altitude"])
expect_same_value(row["lora_freq"], expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
end
end
end
@@ -1360,7 +1418,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
db.results_as_hash = true
rows = db.execute(<<~SQL)
SELECT id, rx_time, rx_iso, from_id, to_id, channel,
portnum, text, snr, rssi, hop_limit
portnum, text, snr, rssi, hop_limit,
lora_freq, modem_preset, channel_name
FROM messages
ORDER BY id
SQL
@@ -1379,6 +1438,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(row["snr"], expected["snr"])
expect(row["rssi"]).to eq(expected["rssi"])
expect(row["hop_limit"]).to eq(expected["hop_limit"])
expect(row["lora_freq"]).to eq(expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
expect(row["channel_name"]).to eq(expected["channel_name"])
end
end
end
@@ -2371,6 +2433,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(actual_row["snr"], expected["snr"])
expect(actual_row["rssi"]).to eq(expected["rssi"])
expect(actual_row["hop_limit"]).to eq(expected["hop_limit"])
expect(actual_row["lora_freq"]).to eq(expected["lora_freq"])
expect(actual_row["modem_preset"]).to eq(expected["modem_preset"])
expect(actual_row["channel_name"]).to eq(expected["channel_name"])
if expected["from_id"]
lookup_id = expected["from_id"]
@@ -2390,6 +2455,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(node_actual["long_name"]).to eq(node_expected["long_name"])
expect(node_actual["role"]).to eq(node_expected["role"])
expect_same_value(node_actual["snr"], node_expected["snr"])
expect(node_actual["lora_freq"]).to eq(node_expected["lora_freq"])
expect(node_actual["modem_preset"]).to eq(node_expected["modem_preset"])
expect_same_value(node_actual["battery_level"], node_expected["battery_level"])
expect_same_value(node_actual["voltage"], node_expected["voltage"])
expected_last_heard = node_expected["last_heard"]
+40 -31
View File
@@ -166,24 +166,24 @@
<table id="nodes">
<thead>
<tr>
<th><button type="button" class="sort-button" data-sort-key="node_id" data-sort-label="Node ID">Node ID <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="short_name" data-sort-label="Short Name">Short <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="long_name" data-sort-label="Long Name">Long Name <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="last_heard" data-sort-label="Last Seen">Last Seen <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="role" data-sort-label="Role">Role <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="hw_model" data-sort-label="Hardware Model">HW Model <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="battery_level" data-sort-label="Battery Level">Battery <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="voltage" data-sort-label="Voltage">Voltage <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="uptime_seconds" data-sort-label="Uptime">Uptime <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="channel_utilization" data-sort-label="Channel Utilization">Channel Util <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="air_util_tx" data-sort-label="Air Utilization (Tx)">Air Util Tx <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="temperature" data-sort-label="Temperature">Temperature <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="relative_humidity" data-sort-label="Humidity">Humidity <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="barometric_pressure" data-sort-label="Barometric Pressure">Pressure <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="altitude" data-sort-label="Altitude">Altitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort-key="position_time" data-sort-label="Last Position">Last Position <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--node-id"><button type="button" class="sort-button" data-sort-key="node_id" data-sort-label="Node ID">Node ID <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--short-name"><button type="button" class="sort-button" data-sort-key="short_name" data-sort-label="Short Name">Short <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--long-name"><button type="button" class="sort-button" data-sort-key="long_name" data-sort-label="Long Name">Long Name <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--last-seen"><button type="button" class="sort-button" data-sort-key="last_heard" data-sort-label="Last Seen">Last Seen <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--role"><button type="button" class="sort-button" data-sort-key="role" data-sort-label="Role">Role <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--hw-model"><button type="button" class="sort-button" data-sort-key="hw_model" data-sort-label="Hardware Model">HW Model <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--battery"><button type="button" class="sort-button" data-sort-key="battery_level" data-sort-label="Battery Level">Battery <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--voltage"><button type="button" class="sort-button" data-sort-key="voltage" data-sort-label="Voltage">Voltage <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--uptime"><button type="button" class="sort-button" data-sort-key="uptime_seconds" data-sort-label="Uptime">Uptime <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--channel-util"><button type="button" class="sort-button" data-sort-key="channel_utilization" data-sort-label="Channel Utilization">Channel Util <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--air-util-tx"><button type="button" class="sort-button" data-sort-key="air_util_tx" data-sort-label="Air Utilization (Tx)">Air Util Tx <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--temperature"><button type="button" class="sort-button" data-sort-key="temperature" data-sort-label="Temperature">Temperature <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--humidity"><button type="button" class="sort-button" data-sort-key="relative_humidity" data-sort-label="Humidity">Humidity <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--pressure"><button type="button" class="sort-button" data-sort-key="barometric_pressure" data-sort-label="Barometric Pressure">Pressure <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--latitude"><button type="button" class="sort-button" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--longitude"><button type="button" class="sort-button" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--altitude"><button type="button" class="sort-button" data-sort-key="altitude" data-sort-label="Altitude">Altitude <span class="sort-indicator" aria-hidden="true"></span></button></th>
<th class="nodes-col nodes-col--last-position"><button type="button" class="sort-button" data-sort-key="position_time" data-sort-label="Last Position">Last Position <span class="sort-indicator" aria-hidden="true"></span></button></th>
</tr>
</thead>
<tbody></tbody>
@@ -196,20 +196,29 @@
</div>
</template>
<footer>
PotatoMesh
<% if version && !version.empty? %>
<span class="mono"><%= version %></span> —
<% end %>
GitHub: <a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<% if contact_link && !contact_link.empty? %>
— <%= site_name %> chat:
<% if contact_link_url %>
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
<% else %>
<%= contact_link %>
<footer class="app-footer">
<div class="footer-content">
<span class="footer-brand">PotatoMesh</span>
<% if version && !version.empty? %>
<span class="mono"><%= version %></span>
<% end %>
<% end %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-links">
GitHub:
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
<% if contact_link && !contact_link.empty? %>
<span class="footer-separator" aria-hidden="true">—</span>
<span class="footer-contact">
<%= site_name %> chat:
<% if contact_link_url %>
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
<% else %>
<%= contact_link %>
<% end %>
</span>
<% end %>
</span>
</div>
</footer>