mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-17 14:55:50 +02:00
Compare commits
8 Commits
v0.5.0-rc1
..
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 22a31b6c80 | |||
| b7ef0bbfcd | |||
| 03b5a10fe4 | |||
| e97498d09f | |||
| 7db76ec2fc | |||
| 63beb2ea6b | |||
| ffad84f18a | |||
| 2642ff7a95 |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user