mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-09 14:55:08 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c75fae44 |
@@ -20,7 +20,6 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'web/**'
|
||||
- 'tests/**'
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'app/**'
|
||||
- 'tests/**'
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
flake-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v30
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- name: Run flake checks
|
||||
run: nix flake check
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'data/**'
|
||||
- 'tests/**'
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- '.github/**'
|
||||
- 'web/**'
|
||||
- 'tests/**'
|
||||
|
||||
@@ -35,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
ruby-version: ['3.4', '4.0']
|
||||
ruby-version: ['3.3', '3.4']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -53,7 +53,6 @@ Additional environment variables are optional:
|
||||
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom (disables the auto-fit checkbox when set). |
|
||||
| `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. |
|
||||
| `DEBUG` | `0` | Enables verbose logging across services when set to `1`. |
|
||||
| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; other channels are skipped before hidden filters. |
|
||||
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor skips when forwarding packets. |
|
||||
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
|
||||
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
|
||||
|
||||
@@ -92,7 +92,6 @@ The web app can be configured with environment variables (defaults shown):
|
||||
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. |
|
||||
| `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. |
|
||||
| `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. |
|
||||
| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; when set, all other channels are skipped before hidden filters. |
|
||||
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor will ignore when forwarding packets. |
|
||||
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
|
||||
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
|
||||
@@ -202,52 +201,11 @@ Run the script with `INSTANCE_DOMAIN` and `API_TOKEN` to keep updating
|
||||
node records and parsing new incoming messages. Enable debug output with `DEBUG=1`,
|
||||
specify the connection target with `CONNECTION` (default `/dev/ttyACM0`) or set it to
|
||||
an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses in MAC format (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) or UUID format for macOS (e.g., `C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E`)
|
||||
and the script attempts a BLE connection if available. To keep
|
||||
ingestion limited, set `ALLOWED_CHANNELS` to a comma-separated whitelist (for
|
||||
example `ALLOWED_CHANNELS="Chat,Ops"`); packets on other channels are discarded.
|
||||
Use `HIDDEN_CHANNELS` to block specific channels from the web UI even when they
|
||||
appear in the allowlist.
|
||||
|
||||
## Nix
|
||||
|
||||
For the dev shell, run:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
The shell provides Ruby plus the Python ingestor dependencies (including `meshtastic`
|
||||
and `protobuf`). To sanity-check that the ingestor starts, run `python -m data.mesh`
|
||||
with the usual environment variables (`INSTANCE_DOMAIN`, `API_TOKEN`, `CONNECTION`).
|
||||
|
||||
To run the packaged apps directly:
|
||||
|
||||
```bash
|
||||
nix run .#web
|
||||
nix run .#ingestor
|
||||
```
|
||||
|
||||
Minimal NixOS module snippet:
|
||||
|
||||
```nix
|
||||
services.potato-mesh = {
|
||||
enable = true;
|
||||
apiTokenFile = config.sops.secrets.potato-mesh-api-token.path;
|
||||
dataDir = "/var/lib/potato-mesh";
|
||||
port = 41447;
|
||||
instanceDomain = "https://mesh.me";
|
||||
siteName = "Nix Mesh";
|
||||
contactLink = "homeserver.mx";
|
||||
mapCenter = "28.96,-13.56";
|
||||
frequency = "868MHz";
|
||||
ingestor = {
|
||||
enable = true;
|
||||
connection = "192.168.X.Y:4403";
|
||||
};
|
||||
};
|
||||
```
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. To keep
|
||||
private channels out of the web UI, set `HIDDEN_CHANNELS` to a comma-separated
|
||||
list of channel names (for example `HIDDEN_CHANNELS="Secret,Ops"`); packets on
|
||||
those channels are discarded instead of being sent to `/api/messages`.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.9</string>
|
||||
<string>0.5.8</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.5.9</string>
|
||||
<string>0.5.8</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: potato_mesh_reader
|
||||
description: Meshtastic Reader — read-only view for PotatoMesh messages.
|
||||
publish_to: "none"
|
||||
version: 0.5.9
|
||||
version: 0.5.8
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
|
||||
@@ -77,7 +77,6 @@ FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' ||
|
||||
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
|
||||
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
|
||||
HIDDEN_CHANNELS=$(grep "^HIDDEN_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
ALLOWED_CHANNELS=$(grep "^ALLOWED_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833")
|
||||
MAP_ZOOM=$(grep "^MAP_ZOOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42")
|
||||
@@ -128,9 +127,6 @@ echo "-------------------"
|
||||
echo "Private mode hides public mesh messages from unauthenticated visitors."
|
||||
echo "Set to 1 to hide public feeds or 0 to keep them visible."
|
||||
read_with_default "Enable private mode (1=yes, 0=no)" "$PRIVATE" PRIVATE
|
||||
echo "Provide a comma-separated whitelist of channel names to ingest (optional)."
|
||||
echo "When set, only listed channels are ingested unless explicitly hidden below."
|
||||
read_with_default "Allowed channels" "$ALLOWED_CHANNELS" ALLOWED_CHANNELS
|
||||
echo "Provide a comma-separated list of channel names to hide from the web UI (optional)."
|
||||
read_with_default "Hidden channels" "$HIDDEN_CHANNELS" HIDDEN_CHANNELS
|
||||
|
||||
@@ -203,11 +199,6 @@ update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG"
|
||||
update_env "FEDERATION" "$FEDERATION"
|
||||
update_env "PRIVATE" "$PRIVATE"
|
||||
update_env "CONNECTION" "$CONNECTION"
|
||||
if [ -n "$ALLOWED_CHANNELS" ]; then
|
||||
update_env "ALLOWED_CHANNELS" "\"$ALLOWED_CHANNELS\""
|
||||
else
|
||||
sed -i.bak '/^ALLOWED_CHANNELS=.*/d' .env
|
||||
fi
|
||||
if [ -n "$HIDDEN_CHANNELS" ]; then
|
||||
update_env "HIDDEN_CHANNELS" "\"$HIDDEN_CHANNELS\""
|
||||
else
|
||||
@@ -261,7 +252,6 @@ echo " API Token: ${API_TOKEN:0:8}..."
|
||||
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
|
||||
echo " Docker Image Tag: $POTATOMESH_IMAGE_TAG"
|
||||
echo " Private Mode: ${PRIVATE}"
|
||||
echo " Allowed Channels: ${ALLOWED_CHANNELS:-'All'}"
|
||||
echo " Hidden Channels: ${HIDDEN_CHANNELS:-'None'}"
|
||||
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
|
||||
if [ "${FEDERATION:-1}" = "0" ]; then
|
||||
|
||||
@@ -50,8 +50,6 @@ USER potatomesh
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
API_TOKEN=""
|
||||
|
||||
@@ -77,8 +75,6 @@ USER ContainerUser
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
API_TOKEN=""
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
|
||||
message information before forwarding it to the accompanying web application.
|
||||
"""
|
||||
|
||||
VERSION = "0.5.9"
|
||||
VERSION = "0.5.8"
|
||||
"""Semantic version identifier shared with the dashboard and front-end."""
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
@@ -70,7 +70,6 @@ _CONFIG_ATTRS = {
|
||||
"DEBUG",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"ALLOWED_CHANNELS",
|
||||
"HIDDEN_CHANNELS",
|
||||
"LORA_FREQ",
|
||||
"MODEM_PRESET",
|
||||
|
||||
@@ -228,33 +228,6 @@ def hidden_channel_names() -> tuple[str, ...]:
|
||||
return tuple(getattr(config, "HIDDEN_CHANNELS", ()))
|
||||
|
||||
|
||||
def allowed_channel_names() -> tuple[str, ...]:
|
||||
"""Return the configured set of explicitly allowed channel names."""
|
||||
|
||||
return tuple(getattr(config, "ALLOWED_CHANNELS", ()))
|
||||
|
||||
|
||||
def is_allowed_channel(channel_name_value: str | None) -> bool:
|
||||
"""Return ``True`` when ``channel_name_value`` is permitted by policy."""
|
||||
|
||||
allowed = getattr(config, "ALLOWED_CHANNELS", ())
|
||||
if not allowed:
|
||||
return True
|
||||
|
||||
if channel_name_value is None:
|
||||
return False
|
||||
|
||||
normalized = channel_name_value.strip()
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
normalized_casefold = normalized.casefold()
|
||||
for allowed_name in allowed:
|
||||
if normalized_casefold == allowed_name.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_hidden_channel(channel_name_value: str | None) -> bool:
|
||||
"""Return ``True`` when ``channel_name_value`` is configured as hidden."""
|
||||
|
||||
@@ -282,9 +255,7 @@ __all__ = [
|
||||
"capture_from_interface",
|
||||
"channel_mappings",
|
||||
"channel_name",
|
||||
"allowed_channel_names",
|
||||
"hidden_channel_names",
|
||||
"is_allowed_channel",
|
||||
"is_hidden_channel",
|
||||
"_reset_channel_cache",
|
||||
]
|
||||
|
||||
@@ -66,8 +66,8 @@ CHANNEL_INDEX = int(os.environ.get("CHANNEL_INDEX", str(DEFAULT_CHANNEL_INDEX)))
|
||||
DEBUG = os.environ.get("DEBUG") == "1"
|
||||
|
||||
|
||||
def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of channel names.
|
||||
def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of hidden channel names.
|
||||
|
||||
Parameters:
|
||||
raw_value: Raw environment string containing channel names separated by
|
||||
@@ -96,18 +96,9 @@ def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]:
|
||||
return tuple(normalized_entries)
|
||||
|
||||
|
||||
def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Compatibility wrapper that parses hidden channel names."""
|
||||
|
||||
return _parse_channel_names(raw_value)
|
||||
|
||||
|
||||
HIDDEN_CHANNELS = _parse_hidden_channels(os.environ.get("HIDDEN_CHANNELS"))
|
||||
"""Channel names configured to be ignored by the ingestor."""
|
||||
|
||||
ALLOWED_CHANNELS = _parse_channel_names(os.environ.get("ALLOWED_CHANNELS"))
|
||||
"""Explicitly permitted channel names; when set, other channels are ignored."""
|
||||
|
||||
|
||||
def _resolve_instance_domain() -> str:
|
||||
"""Resolve the configured instance domain from the environment.
|
||||
@@ -192,7 +183,6 @@ __all__ = [
|
||||
"CHANNEL_INDEX",
|
||||
"DEBUG",
|
||||
"HIDDEN_CHANNELS",
|
||||
"ALLOWED_CHANNELS",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"ENERGY_SAVING",
|
||||
|
||||
@@ -100,41 +100,6 @@ from .serialization import (
|
||||
)
|
||||
|
||||
|
||||
def _portnum_candidates(name: str) -> set[int]:
|
||||
"""Return Meshtastic port number candidates for ``name``.
|
||||
|
||||
Parameters:
|
||||
name: Port name to look up in Meshtastic ``PortNum`` enums.
|
||||
|
||||
Returns:
|
||||
Set of integer port numbers resolved from Meshtastic modules.
|
||||
"""
|
||||
|
||||
candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup(name))
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
constant_value = getattr(module, name, None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def register_host_node_id(node_id: str | None) -> None:
|
||||
"""Record the canonical identifier for the connected host device.
|
||||
|
||||
@@ -1315,7 +1280,28 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
traceroute_section = (
|
||||
decoded.get("traceroute") if isinstance(decoded, Mapping) else None
|
||||
)
|
||||
traceroute_port_ints = _portnum_candidates("TRACEROUTE_APP")
|
||||
traceroute_port_ints: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("TRACEROUTE_APP"))
|
||||
if candidate is not None:
|
||||
traceroute_port_ints.add(candidate)
|
||||
constant_value = getattr(module, "TRACEROUTE_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
traceroute_port_ints.add(candidate)
|
||||
|
||||
if (
|
||||
portnum == "TRACEROUTE_APP"
|
||||
@@ -1373,43 +1359,36 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
if emoji_text:
|
||||
emoji = emoji_text
|
||||
|
||||
routing_section = decoded.get("routing") if isinstance(decoded, Mapping) else None
|
||||
routing_port_candidates = _portnum_candidates("ROUTING_APP")
|
||||
if text is None and (
|
||||
portnum == "ROUTING_APP"
|
||||
or (portnum_int is not None and portnum_int in routing_port_candidates)
|
||||
or isinstance(routing_section, Mapping)
|
||||
):
|
||||
routing_payload = _first(decoded, "payload", "data", default=None)
|
||||
if routing_payload is not None:
|
||||
if isinstance(routing_payload, bytes):
|
||||
text = base64.b64encode(routing_payload).decode("ascii")
|
||||
elif isinstance(routing_payload, str):
|
||||
text = routing_payload
|
||||
else:
|
||||
try:
|
||||
text = json.dumps(routing_payload, ensure_ascii=True)
|
||||
except TypeError:
|
||||
text = str(routing_payload)
|
||||
if isinstance(text, str):
|
||||
text = text.strip() or None
|
||||
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP", "ROUTING_APP"}
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"}
|
||||
allowed_port_ints = {1}
|
||||
|
||||
reaction_port_candidates = _portnum_candidates("REACTION_APP")
|
||||
reaction_port_candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("REACTION_APP"))
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
constant_value = getattr(module, "REACTION_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
|
||||
for candidate in reaction_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
for candidate in routing_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
if isinstance(routing_section, Mapping) and portnum_int is not None:
|
||||
allowed_port_ints.add(portnum_int)
|
||||
allowed_port_values.add(str(portnum_int))
|
||||
|
||||
is_reaction_packet = portnum == "REACTION_APP" or (
|
||||
reply_id is not None and emoji is not None
|
||||
)
|
||||
@@ -1482,18 +1461,6 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
_record_ignored_packet(packet, reason="skipped-direct-message")
|
||||
return
|
||||
|
||||
if not channels.is_allowed_channel(channel_name_value):
|
||||
_record_ignored_packet(packet, reason="disallowed-channel")
|
||||
if config.DEBUG:
|
||||
config._debug_log(
|
||||
"Ignored packet on disallowed channel",
|
||||
context="handlers.store_packet_dict",
|
||||
channel=channel,
|
||||
channel_name=channel_name_value,
|
||||
allowed_channels=channels.allowed_channel_names(),
|
||||
)
|
||||
return
|
||||
|
||||
if channels.is_hidden_channel(channel_name_value):
|
||||
_record_ignored_packet(packet, reason="hidden-channel")
|
||||
if config.DEBUG:
|
||||
|
||||
@@ -628,13 +628,7 @@ _DEFAULT_SERIAL_PATTERNS = (
|
||||
"/dev/cu.usbserial*",
|
||||
)
|
||||
|
||||
# Support both MAC addresses (Linux/Windows) and UUIDs (macOS)
|
||||
_BLE_ADDRESS_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}|" # MAC address format
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" # UUID format
|
||||
r")$"
|
||||
)
|
||||
_BLE_ADDRESS_RE = re.compile(r"^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
|
||||
|
||||
|
||||
class _DummySerialInterface:
|
||||
@@ -648,13 +642,13 @@ class _DummySerialInterface:
|
||||
|
||||
|
||||
def _parse_ble_target(value: str) -> str | None:
|
||||
"""Return a normalized BLE address (MAC or UUID) when ``value`` matches the format.
|
||||
"""Return an uppercase BLE MAC address when ``value`` matches the format.
|
||||
|
||||
Parameters:
|
||||
value: User-provided target string.
|
||||
|
||||
Returns:
|
||||
The normalised MAC address or UUID, or ``None`` when validation fails.
|
||||
The normalised MAC address or ``None`` when validation fails.
|
||||
"""
|
||||
|
||||
if not value:
|
||||
@@ -778,13 +772,10 @@ def _create_serial_interface(port: str) -> tuple[object, str]:
|
||||
return _DummySerialInterface(), "mock"
|
||||
ble_target = _parse_ble_target(port_value)
|
||||
if ble_target:
|
||||
# Determine if it's a MAC address or UUID
|
||||
address_type = "MAC" if ":" in ble_target else "UUID"
|
||||
config._debug_log(
|
||||
"Using BLE interface",
|
||||
context="interfaces.ble",
|
||||
address=ble_target,
|
||||
address_type=address_type,
|
||||
)
|
||||
return _load_ble_interface()(address=ble_target), ble_target
|
||||
network_target = _parse_network_target(port_value)
|
||||
|
||||
@@ -49,7 +49,6 @@ x-ingestor-base: &ingestor-base
|
||||
environment:
|
||||
CONNECTION: ${CONNECTION:-/dev/ttyACM0}
|
||||
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
|
||||
ALLOWED_CHANNELS: ${ALLOWED_CHANNELS:-""}
|
||||
HIDDEN_CHANNELS: ${HIDDEN_CHANNELS:-""}
|
||||
API_TOKEN: ${API_TOKEN}
|
||||
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
|
||||
|
||||
Generated
-61
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766070988,
|
||||
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
{
|
||||
description = "PotatoMesh - A federated, Meshtastic-powered node dashboard";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Python environment for the ingestor
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
meshtastic
|
||||
protobuf
|
||||
requests
|
||||
]);
|
||||
|
||||
# Web app wrapper script
|
||||
webApp = pkgs.writeShellApplication {
|
||||
name = "potato-mesh-web";
|
||||
runtimeInputs = [ pkgs.ruby pkgs.bundler pkgs.sqlite pkgs.git pkgs.gnumake pkgs.gcc ];
|
||||
text = ''
|
||||
if [ -n "''${XDG_DATA_HOME:-}" ]; then
|
||||
BASEDIR="$XDG_DATA_HOME"
|
||||
else
|
||||
BASEDIR="$HOME/.local/share/potato-mesh"
|
||||
fi
|
||||
WORKDIR="$BASEDIR/web"
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
# Copy app files if not present or outdated
|
||||
APP_SRC="${./web}"
|
||||
DATA_SRC="${./data}"
|
||||
if [ ! -f "$WORKDIR/.installed" ] || [ "$APP_SRC" != "$(cat "$WORKDIR/.src_path" 2>/dev/null)" ]; then
|
||||
# Copy web app
|
||||
cp -rT "$APP_SRC" "$WORKDIR/"
|
||||
chmod -R u+w "$WORKDIR"
|
||||
# Copy data directory (contains SQL schemas)
|
||||
mkdir -p "$BASEDIR/data"
|
||||
cp -rT "$DATA_SRC" "$BASEDIR/data/"
|
||||
chmod -R u+w "$BASEDIR/data"
|
||||
echo "$APP_SRC" > "$WORKDIR/.src_path"
|
||||
rm -f "$WORKDIR/.installed"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
# Install gems if needed
|
||||
if [ ! -f ".installed" ]; then
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
bundle install
|
||||
touch .installed
|
||||
fi
|
||||
|
||||
exec bundle exec ruby app.rb -p "''${PORT:-41447}" -o "''${HOST:-0.0.0.0}"
|
||||
'';
|
||||
};
|
||||
|
||||
# Ingestor wrapper script
|
||||
ingestor = pkgs.writeShellApplication {
|
||||
name = "potato-mesh-ingestor";
|
||||
runtimeInputs = [ pythonEnv ];
|
||||
text = ''
|
||||
# The ingestor needs to run from parent directory with data/ folder
|
||||
if [ -n "''${XDG_DATA_HOME:-}" ]; then
|
||||
BASEDIR="$XDG_DATA_HOME"
|
||||
else
|
||||
BASEDIR="$HOME/.local/share/potato-mesh"
|
||||
fi
|
||||
if [ ! -d "$BASEDIR/data" ]; then
|
||||
mkdir -p "$BASEDIR"
|
||||
cp -rT "${./data}" "$BASEDIR/data/"
|
||||
chmod -R u+w "$BASEDIR/data"
|
||||
fi
|
||||
cd "$BASEDIR"
|
||||
exec python -m data.mesh
|
||||
'';
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
web = webApp;
|
||||
ingestor = ingestor;
|
||||
default = webApp;
|
||||
};
|
||||
|
||||
apps = {
|
||||
web = {
|
||||
type = "app";
|
||||
program = "${webApp}/bin/potato-mesh-web";
|
||||
};
|
||||
ingestor = {
|
||||
type = "app";
|
||||
program = "${ingestor}/bin/potato-mesh-ingestor";
|
||||
};
|
||||
default = self.apps.${system}.web;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.ruby
|
||||
pkgs.bundler
|
||||
pythonEnv
|
||||
pkgs.sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "PotatoMesh development shell"
|
||||
echo " - Ruby: $(ruby --version)"
|
||||
echo " - Python: $(python --version)"
|
||||
echo ""
|
||||
echo "To run the web app: cd web && bundle install && ./app.sh"
|
||||
echo "To run the ingestor: cd data && python mesh.py"
|
||||
'';
|
||||
};
|
||||
|
||||
checks.potato-mesh-nixos = pkgs.testers.nixosTest {
|
||||
name = "potato-mesh-data-dir";
|
||||
nodes.machine = { lib, ... }: {
|
||||
imports = [ self.nixosModules.default ];
|
||||
services.potato-mesh = {
|
||||
enable = true;
|
||||
apiToken = "test-token";
|
||||
dataDir = "/var/lib/potato-mesh";
|
||||
ingestor.enable = true;
|
||||
};
|
||||
systemd.services.potato-mesh-ingestor.wantedBy = lib.mkForce [];
|
||||
};
|
||||
testScript = ''
|
||||
machine.start
|
||||
machine.succeed("grep -q 'XDG_DATA_HOME=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-web.service")
|
||||
machine.succeed("grep -q 'XDG_DATA_HOME=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-ingestor.service")
|
||||
machine.succeed("grep -q 'WorkingDirectory=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-web.service")
|
||||
machine.succeed("grep -q 'WorkingDirectory=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-ingestor.service")
|
||||
'';
|
||||
};
|
||||
}
|
||||
) // {
|
||||
# NixOS module
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.potato-mesh;
|
||||
in {
|
||||
options.services.potato-mesh = {
|
||||
enable = lib.mkEnableOption "PotatoMesh web dashboard";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.web;
|
||||
description = "The potato-mesh web package to use";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 41447;
|
||||
description = "Port to listen on";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Host to bind to";
|
||||
};
|
||||
|
||||
apiToken = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Shared secret that authorizes ingestors and API clients making POST requests. Warning: visible in nix store. Prefer apiTokenFile for production.";
|
||||
};
|
||||
|
||||
apiTokenFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing API_TOKEN=<secret> (recommended for production)";
|
||||
};
|
||||
|
||||
instanceDomain = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Public hostname used for metadata, federation, and generated API links";
|
||||
};
|
||||
|
||||
siteName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "PotatoMesh Demo";
|
||||
description = "Title and header displayed in the UI";
|
||||
};
|
||||
|
||||
channel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "#LongFast";
|
||||
description = "Default channel name displayed in the UI";
|
||||
};
|
||||
|
||||
frequency = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "915MHz";
|
||||
description = "Default frequency description displayed in the UI";
|
||||
};
|
||||
|
||||
contactLink = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "#potatomesh:dod.ngo";
|
||||
description = "Chat link or Matrix alias rendered in the footer and overlays";
|
||||
};
|
||||
|
||||
mapCenter = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "38.761944,-27.090833";
|
||||
description = "Latitude and longitude that centre the map on load";
|
||||
};
|
||||
|
||||
mapZoom = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
description = "Fixed Leaflet zoom applied on first load; disables auto-fit when provided";
|
||||
};
|
||||
|
||||
maxDistance = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 42;
|
||||
description = "Maximum distance (km) before node relationships are hidden on the map";
|
||||
};
|
||||
|
||||
debug = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable verbose logging";
|
||||
};
|
||||
|
||||
allowedChannels = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Comma-separated channel names the ingestor accepts";
|
||||
};
|
||||
|
||||
hiddenChannels = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Comma-separated channel names the ingestor will ignore";
|
||||
};
|
||||
|
||||
federation = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Announce instance and crawl peers";
|
||||
};
|
||||
|
||||
private = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Hide chat UI, disable message APIs, and exclude hidden clients from public listings";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/potato-mesh";
|
||||
description = "Directory to store database and configuration";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "potato-mesh";
|
||||
description = "User to run the service as";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "potato-mesh";
|
||||
description = "Group to run the service as";
|
||||
};
|
||||
|
||||
# Ingestor options
|
||||
ingestor = {
|
||||
enable = lib.mkEnableOption "PotatoMesh Python ingestor";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.ingestor;
|
||||
description = "The potato-mesh ingestor package to use";
|
||||
};
|
||||
|
||||
connection = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/dev/ttyACM0";
|
||||
description = "Connection target: serial port, IP:port for TCP, or Bluetooth address for BLE";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = {};
|
||||
|
||||
systemd.services.potato-mesh-web = {
|
||||
description = "PotatoMesh Web Dashboard";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
environment = {
|
||||
RACK_ENV = "production";
|
||||
APP_ENV = "production";
|
||||
PORT = toString cfg.port;
|
||||
HOST = cfg.host;
|
||||
SITE_NAME = cfg.siteName;
|
||||
CHANNEL = cfg.channel;
|
||||
FREQUENCY = cfg.frequency;
|
||||
CONTACT_LINK = cfg.contactLink;
|
||||
MAP_CENTER = cfg.mapCenter;
|
||||
MAX_DISTANCE = toString cfg.maxDistance;
|
||||
DEBUG = if cfg.debug then "1" else "0";
|
||||
FEDERATION = if cfg.federation then "1" else "0";
|
||||
PRIVATE = if cfg.private then "1" else "0";
|
||||
XDG_DATA_HOME = cfg.dataDir;
|
||||
XDG_CONFIG_HOME = "${cfg.dataDir}/config";
|
||||
} // lib.optionalAttrs (cfg.instanceDomain != null) {
|
||||
INSTANCE_DOMAIN = cfg.instanceDomain;
|
||||
} // lib.optionalAttrs (cfg.mapZoom != null) {
|
||||
MAP_ZOOM = toString cfg.mapZoom;
|
||||
} // lib.optionalAttrs (cfg.allowedChannels != null) {
|
||||
ALLOWED_CHANNELS = cfg.allowedChannels;
|
||||
} // lib.optionalAttrs (cfg.hiddenChannels != null) {
|
||||
HIDDEN_CHANNELS = cfg.hiddenChannels;
|
||||
} // lib.optionalAttrs (cfg.apiToken != null) {
|
||||
API_TOKEN = cfg.apiToken;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${cfg.package}/bin/potato-mesh-web";
|
||||
Restart = "always";
|
||||
RestartSec = 5;
|
||||
} // lib.optionalAttrs (cfg.apiTokenFile != null) {
|
||||
EnvironmentFile = cfg.apiTokenFile;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.potato-mesh-ingestor = lib.mkIf cfg.ingestor.enable {
|
||||
description = "PotatoMesh Python Ingestor";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "potato-mesh-web.service" ];
|
||||
requires = [ "potato-mesh-web.service" ];
|
||||
|
||||
environment = {
|
||||
INSTANCE_DOMAIN = "http://127.0.0.1:${toString cfg.port}";
|
||||
CONNECTION = cfg.ingestor.connection;
|
||||
DEBUG = if cfg.debug then "1" else "0";
|
||||
XDG_DATA_HOME = cfg.dataDir;
|
||||
} // lib.optionalAttrs (cfg.allowedChannels != null) {
|
||||
ALLOWED_CHANNELS = cfg.allowedChannels;
|
||||
} // lib.optionalAttrs (cfg.hiddenChannels != null) {
|
||||
HIDDEN_CHANNELS = cfg.hiddenChannels;
|
||||
} // lib.optionalAttrs (cfg.apiToken != null) {
|
||||
API_TOKEN = cfg.apiToken;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${cfg.ingestor.package}/bin/potato-mesh-ingestor";
|
||||
Restart = "always";
|
||||
RestartSec = 10;
|
||||
} // lib.optionalAttrs (cfg.apiTokenFile != null) {
|
||||
EnvironmentFile = cfg.apiTokenFile;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Generated
+1
-128
@@ -11,56 +11,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
@@ -135,52 +85,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.0.0"
|
||||
@@ -413,12 +317,6 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -674,12 +572,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -829,12 +721,6 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
@@ -928,10 +814,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.9"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -1424,12 +1309,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -1802,12 +1681,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
||||
+1
-2
@@ -14,7 +14,7 @@
|
||||
|
||||
[package]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.9"
|
||||
version = "0.5.8"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -27,7 +27,6 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
urlencoding = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
+1
-63
@@ -54,9 +54,7 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration can come from TOML, CLI flags, and environment variables. The TOML
|
||||
file is optional as long as every required setting is supplied via CLI/env/secret
|
||||
overrides.
|
||||
All configuration is in `Config.toml` in the project root.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -82,66 +80,6 @@ room_id = "!yourroomid:example.org"
|
||||
state_file = "bridge_state.json"
|
||||
````
|
||||
|
||||
### CLI Overrides
|
||||
|
||||
Run `potatomesh-matrix-bridge --help` for the full list. The most common flags:
|
||||
|
||||
- `--config` (or `--config-path`) to point at a TOML file
|
||||
- `--state-file`
|
||||
- `--potatomesh-base-url`
|
||||
- `--potatomesh-poll-interval-secs`
|
||||
- `--matrix-homeserver`
|
||||
- `--matrix-as-token`
|
||||
- `--matrix-server-name`
|
||||
- `--matrix-room-id`
|
||||
- `--container-defaults` / `--no-container-defaults`
|
||||
|
||||
### Environment Overrides
|
||||
|
||||
Environment variables override CLI and TOML values:
|
||||
|
||||
- `POTATOMESH_BASE_URL`
|
||||
- `POTATOMESH_POLL_INTERVAL_SECS`
|
||||
- `MATRIX_HOMESERVER`
|
||||
- `MATRIX_AS_TOKEN`
|
||||
- `MATRIX_SERVER_NAME`
|
||||
- `MATRIX_ROOM_ID`
|
||||
- `STATE_FILE`
|
||||
- `POTATOMESH_CONFIG_PATH` (optional TOML path)
|
||||
- `POTATOMESH_CONTAINER_DEFAULTS` (`1/0`, `true/false`)
|
||||
- `POTATOMESH_SECRETS_DIR` (default secrets directory)
|
||||
- `CONTAINER` (container detection hint)
|
||||
|
||||
### Docker Secrets
|
||||
|
||||
Every env var above supports a `*_FILE` companion (for example, `MATRIX_AS_TOKEN_FILE`).
|
||||
When present, the bridge reads the file contents and uses them instead of the plain env var.
|
||||
If `POTATOMESH_SECRETS_DIR` is set (or container defaults are enabled), the bridge also
|
||||
checks for files named after the env vars (for example, `/run/secrets/MATRIX_AS_TOKEN`)
|
||||
even when the `*_FILE` variable is not set.
|
||||
|
||||
### Precedence
|
||||
|
||||
From highest to lowest:
|
||||
|
||||
1. `*_FILE` secret values (explicit or default secrets directory)
|
||||
2. Environment variables
|
||||
3. CLI flags
|
||||
4. TOML config
|
||||
5. Built-in defaults
|
||||
|
||||
### Container Defaults
|
||||
|
||||
When container defaults are enabled (auto-detected or forced):
|
||||
|
||||
- Default config path: `/app/Config.toml`
|
||||
- Default state file: `/app/bridge_state.json`
|
||||
- Default secrets directory: `/run/secrets`
|
||||
- Default poll interval: 120 seconds
|
||||
|
||||
Disable container defaults with `--no-container-defaults` or set
|
||||
`POTATOMESH_CONTAINER_DEFAULTS=0`.
|
||||
|
||||
### PotatoMesh API
|
||||
|
||||
The bridge assumes:
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Surface container detection for the bridge and set default secret directory.
|
||||
export CONTAINER="${CONTAINER:-1}"
|
||||
export POTATOMESH_CONTAINER_DEFAULTS="${POTATOMESH_CONTAINER_DEFAULTS:-1}"
|
||||
export POTATOMESH_SECRETS_DIR="${POTATOMESH_SECRETS_DIR:-/run/secrets}"
|
||||
|
||||
# Default state file path from Config.toml unless overridden.
|
||||
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
|
||||
STATE_DIR="$(dirname "$STATE_FILE")"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::config::{
|
||||
BootstrapOverrides, ConfigOverrides, MatrixOverrides, PotatomeshOverrides, StateOverrides,
|
||||
};
|
||||
|
||||
/// Command-line overrides for the Matrix bridge.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "potatomesh-matrix-bridge", version)]
|
||||
pub struct Cli {
|
||||
/// TOML config path (optional, defaults to Config.toml or /app/Config.toml in containers).
|
||||
#[arg(long = "config", alias = "config-path")]
|
||||
pub config_path: Option<String>,
|
||||
|
||||
/// Override the state file path.
|
||||
#[arg(long)]
|
||||
pub state_file: Option<String>,
|
||||
|
||||
/// Override the PotatoMesh base URL.
|
||||
#[arg(long)]
|
||||
pub potatomesh_base_url: Option<String>,
|
||||
|
||||
/// Override the PotatoMesh poll interval in seconds.
|
||||
#[arg(long)]
|
||||
pub potatomesh_poll_interval_secs: Option<u64>,
|
||||
|
||||
/// Override the Matrix homeserver URL.
|
||||
#[arg(long)]
|
||||
pub matrix_homeserver: Option<String>,
|
||||
|
||||
/// Override the Matrix appservice access token.
|
||||
#[arg(long)]
|
||||
pub matrix_as_token: Option<String>,
|
||||
|
||||
/// Override the Matrix server name.
|
||||
#[arg(long)]
|
||||
pub matrix_server_name: Option<String>,
|
||||
|
||||
/// Override the Matrix room ID.
|
||||
#[arg(long)]
|
||||
pub matrix_room_id: Option<String>,
|
||||
|
||||
/// Force container defaults on even if container detection is false.
|
||||
#[arg(long, conflicts_with = "no_container_defaults")]
|
||||
pub container_defaults: bool,
|
||||
|
||||
/// Disable container defaults even if a container is detected.
|
||||
#[arg(long, conflicts_with = "container_defaults")]
|
||||
pub no_container_defaults: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
/// Convert CLI flags to bootstrap overrides for config loading.
|
||||
pub fn into_overrides(self) -> BootstrapOverrides {
|
||||
let container_defaults = if self.container_defaults {
|
||||
Some(true)
|
||||
} else if self.no_container_defaults {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
BootstrapOverrides {
|
||||
config_path: self.config_path,
|
||||
container_defaults,
|
||||
values: ConfigOverrides {
|
||||
potatomesh: PotatomeshOverrides {
|
||||
base_url: self.potatomesh_base_url,
|
||||
poll_interval_secs: self.potatomesh_poll_interval_secs,
|
||||
},
|
||||
matrix: MatrixOverrides {
|
||||
homeserver: self.matrix_homeserver,
|
||||
as_token: self.matrix_as_token,
|
||||
server_name: self.matrix_server_name,
|
||||
room_id: self.matrix_room_id,
|
||||
},
|
||||
state: StateOverrides {
|
||||
state_file: self.state_file,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cli_overrides_map_to_config() {
|
||||
let cli = Cli::parse_from([
|
||||
"bridge",
|
||||
"--config",
|
||||
"/tmp/Config.toml",
|
||||
"--state-file",
|
||||
"/tmp/state.json",
|
||||
"--potatomesh-base-url",
|
||||
"https://potato.example/",
|
||||
"--potatomesh-poll-interval-secs",
|
||||
"15",
|
||||
"--matrix-homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--matrix-as-token",
|
||||
"token",
|
||||
"--matrix-server-name",
|
||||
"example.org",
|
||||
"--matrix-room-id",
|
||||
"!room:example.org",
|
||||
"--container-defaults",
|
||||
]);
|
||||
|
||||
let overrides = cli.into_overrides();
|
||||
assert_eq!(overrides.config_path.as_deref(), Some("/tmp/Config.toml"));
|
||||
assert_eq!(overrides.container_defaults, Some(true));
|
||||
assert_eq!(
|
||||
overrides.values.potatomesh.base_url.as_deref(),
|
||||
Some("https://potato.example/")
|
||||
);
|
||||
assert_eq!(overrides.values.potatomesh.poll_interval_secs, Some(15));
|
||||
assert_eq!(
|
||||
overrides.values.matrix.homeserver.as_deref(),
|
||||
Some("https://matrix.example.org")
|
||||
);
|
||||
assert_eq!(overrides.values.matrix.as_token.as_deref(), Some("token"));
|
||||
assert_eq!(
|
||||
overrides.values.matrix.server_name.as_deref(),
|
||||
Some("example.org")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.values.matrix.room_id.as_deref(),
|
||||
Some("!room:example.org")
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.values.state.state_file.as_deref(),
|
||||
Some("/tmp/state.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_can_disable_container_defaults() {
|
||||
let cli = Cli::parse_from(["bridge", "--no-container-defaults"]);
|
||||
let overrides = cli.into_overrides();
|
||||
assert_eq!(overrides.container_defaults, Some(false));
|
||||
}
|
||||
}
|
||||
+2
-700
@@ -13,40 +13,14 @@
|
||||
// limitations under the License.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "Config.toml";
|
||||
const DEFAULT_CONTAINER_CONFIG_PATH: &str = "/app/Config.toml";
|
||||
const DEFAULT_STATE_FILE: &str = "bridge_state.json";
|
||||
const DEFAULT_CONTAINER_STATE_FILE: &str = "/app/bridge_state.json";
|
||||
const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
|
||||
const DEFAULT_CONTAINER_POLL_INTERVAL_SECS: u64 = 120;
|
||||
const DEFAULT_SECRETS_DIR: &str = "/run/secrets";
|
||||
|
||||
const ENV_CONTAINER: &str = "CONTAINER";
|
||||
const ENV_CONTAINER_DEFAULTS: &str = "POTATOMESH_CONTAINER_DEFAULTS";
|
||||
const ENV_CONFIG_PATH: &str = "POTATOMESH_CONFIG_PATH";
|
||||
const ENV_SECRETS_DIR: &str = "POTATOMESH_SECRETS_DIR";
|
||||
|
||||
const ENV_POTATOMESH_BASE_URL: &str = "POTATOMESH_BASE_URL";
|
||||
const ENV_POTATOMESH_POLL_INTERVAL: &str = "POTATOMESH_POLL_INTERVAL_SECS";
|
||||
const ENV_MATRIX_HOMESERVER: &str = "MATRIX_HOMESERVER";
|
||||
const ENV_MATRIX_AS_TOKEN: &str = "MATRIX_AS_TOKEN";
|
||||
const ENV_MATRIX_SERVER_NAME: &str = "MATRIX_SERVER_NAME";
|
||||
const ENV_MATRIX_ROOM_ID: &str = "MATRIX_ROOM_ID";
|
||||
const ENV_STATE_FILE: &str = "STATE_FILE";
|
||||
|
||||
/// Configuration for the PotatoMesh API access.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct PotatomeshConfig {
|
||||
pub base_url: String,
|
||||
pub poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Configuration for Matrix appservice access.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MatrixConfig {
|
||||
pub homeserver: String,
|
||||
@@ -55,13 +29,11 @@ pub struct MatrixConfig {
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
/// Configuration for persisted bridge state.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct StateConfig {
|
||||
pub state_file: String,
|
||||
}
|
||||
|
||||
/// Complete bridge configuration, merged from file and overrides.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
pub potatomesh: PotatomeshConfig,
|
||||
@@ -69,466 +41,20 @@ pub struct Config {
|
||||
pub state: StateConfig,
|
||||
}
|
||||
|
||||
/// Optional configuration overrides for a single section.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PotatomeshOverrides {
|
||||
pub base_url: Option<String>,
|
||||
pub poll_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Optional Matrix overrides.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MatrixOverrides {
|
||||
pub homeserver: Option<String>,
|
||||
pub as_token: Option<String>,
|
||||
pub server_name: Option<String>,
|
||||
pub room_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Optional state overrides.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StateOverrides {
|
||||
pub state_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Override bundle merged from TOML, CLI, env, and secret files.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConfigOverrides {
|
||||
pub potatomesh: PotatomeshOverrides,
|
||||
pub matrix: MatrixOverrides,
|
||||
pub state: StateOverrides,
|
||||
}
|
||||
|
||||
/// Runtime context discovered while bootstrapping configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeContext {
|
||||
pub in_container: bool,
|
||||
pub container_defaults: bool,
|
||||
pub config_path: String,
|
||||
pub secrets_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Bootstrapped configuration and runtime context.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigBootstrap {
|
||||
pub config: Config,
|
||||
pub context: RuntimeContext,
|
||||
}
|
||||
|
||||
/// CLI-provided override bundle with container defaults toggles.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BootstrapOverrides {
|
||||
pub config_path: Option<String>,
|
||||
pub container_defaults: Option<bool>,
|
||||
pub values: ConfigOverrides,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct PotatomeshFileOverrides {
|
||||
#[serde(default)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
poll_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct MatrixFileOverrides {
|
||||
#[serde(default)]
|
||||
homeserver: Option<String>,
|
||||
#[serde(default)]
|
||||
as_token: Option<String>,
|
||||
#[serde(default)]
|
||||
server_name: Option<String>,
|
||||
#[serde(default)]
|
||||
room_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct StateFileOverrides {
|
||||
#[serde(default)]
|
||||
state_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct ConfigFileOverrides {
|
||||
#[serde(default)]
|
||||
potatomesh: PotatomeshFileOverrides,
|
||||
#[serde(default)]
|
||||
matrix: MatrixFileOverrides,
|
||||
#[serde(default)]
|
||||
state: StateFileOverrides,
|
||||
}
|
||||
|
||||
impl ConfigOverrides {
|
||||
/// Merge another override set, replacing only fields present in `other`.
|
||||
pub fn merge(&mut self, other: ConfigOverrides) {
|
||||
self.potatomesh.merge(other.potatomesh);
|
||||
self.matrix.merge(other.matrix);
|
||||
self.state.merge(other.state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PotatomeshOverrides {
|
||||
/// Merge optional fields, keeping existing values when the override is empty.
|
||||
fn merge(&mut self, other: PotatomeshOverrides) {
|
||||
if other.base_url.is_some() {
|
||||
self.base_url = other.base_url;
|
||||
}
|
||||
if other.poll_interval_secs.is_some() {
|
||||
self.poll_interval_secs = other.poll_interval_secs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MatrixOverrides {
|
||||
/// Merge optional fields, keeping existing values when the override is empty.
|
||||
fn merge(&mut self, other: MatrixOverrides) {
|
||||
if other.homeserver.is_some() {
|
||||
self.homeserver = other.homeserver;
|
||||
}
|
||||
if other.as_token.is_some() {
|
||||
self.as_token = other.as_token;
|
||||
}
|
||||
if other.server_name.is_some() {
|
||||
self.server_name = other.server_name;
|
||||
}
|
||||
if other.room_id.is_some() {
|
||||
self.room_id = other.room_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StateOverrides {
|
||||
/// Merge optional fields, keeping existing values when the override is empty.
|
||||
fn merge(&mut self, other: StateOverrides) {
|
||||
if other.state_file.is_some() {
|
||||
self.state_file = other.state_file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConfigFileOverrides> for ConfigOverrides {
|
||||
fn from(value: ConfigFileOverrides) -> Self {
|
||||
Self {
|
||||
potatomesh: PotatomeshOverrides {
|
||||
base_url: value.potatomesh.base_url,
|
||||
poll_interval_secs: value.potatomesh.poll_interval_secs,
|
||||
},
|
||||
matrix: MatrixOverrides {
|
||||
homeserver: value.matrix.homeserver,
|
||||
as_token: value.matrix.as_token,
|
||||
server_name: value.matrix.server_name,
|
||||
room_id: value.matrix.room_id,
|
||||
},
|
||||
state: StateOverrides {
|
||||
state_file: value.state.state_file,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect container context from env or cgroup hints.
|
||||
fn detect_container() -> bool {
|
||||
let env_value = env::var(ENV_CONTAINER).ok();
|
||||
let cgroup_contents = fs::read_to_string("/proc/1/cgroup").ok();
|
||||
detect_container_from(env_value.as_deref(), cgroup_contents.as_deref())
|
||||
}
|
||||
|
||||
/// Detect container context from provided inputs (used for testing).
|
||||
fn detect_container_from(env_value: Option<&str>, cgroup_contents: Option<&str>) -> bool {
|
||||
if let Some(value) = env_value.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
let normalized = value.to_ascii_lowercase();
|
||||
return normalized != "0" && normalized != "false";
|
||||
}
|
||||
|
||||
if let Some(cgroup) = cgroup_contents {
|
||||
let haystack = cgroup.to_lowercase();
|
||||
return haystack.contains("docker")
|
||||
|| haystack.contains("containerd")
|
||||
|| haystack.contains("kubepods")
|
||||
|| haystack.contains("podman")
|
||||
|| haystack.contains("lxc");
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Read an environment variable, trimming whitespace and ignoring empty values.
|
||||
fn read_env_string(key: &str) -> Option<String> {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Parse a boolean env var, accepting common truthy/falsey values.
|
||||
fn read_env_bool(key: &str) -> anyhow::Result<Option<bool>> {
|
||||
let raw = match read_env_string(key) {
|
||||
Some(value) => value,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let normalized = raw.to_ascii_lowercase();
|
||||
let parsed = match normalized.as_str() {
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid boolean value for {}: {}",
|
||||
key,
|
||||
raw
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
||||
/// Parse a u64 env var with context in error messages.
|
||||
fn read_env_u64(key: &str) -> anyhow::Result<Option<u64>> {
|
||||
let raw = match read_env_string(key) {
|
||||
Some(value) => value,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let parsed = raw
|
||||
.parse::<u64>()
|
||||
.map_err(|err| anyhow::anyhow!("Invalid integer value for {}: {} ({})", key, raw, err))?;
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
||||
/// Load a secret value from a file path and trim trailing whitespace.
|
||||
fn read_secret_file(path: &Path) -> anyhow::Result<String> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("Secret file {} is empty", path.display());
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// Resolve a *_FILE env var or default secrets file.
|
||||
fn read_secret_value(var_name: &str, secrets_dir: Option<&Path>) -> anyhow::Result<Option<String>> {
|
||||
let file_env = format!("{}_FILE", var_name);
|
||||
if let Some(path) = read_env_string(&file_env) {
|
||||
return Ok(Some(read_secret_file(Path::new(&path))?));
|
||||
}
|
||||
|
||||
if let Some(dir) = secrets_dir {
|
||||
let path = dir.join(var_name);
|
||||
if path.exists() {
|
||||
return Ok(Some(read_secret_file(&path)?));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Load a config file if it exists, returning overrides for present fields.
|
||||
fn load_optional_config(path: &str) -> anyhow::Result<Option<ConfigOverrides>> {
|
||||
if !Path::new(path).exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let cfg: ConfigFileOverrides = toml::from_str(&contents)?;
|
||||
Ok(Some(cfg.into()))
|
||||
}
|
||||
|
||||
/// Build overrides from environment variables (non-secret values).
|
||||
fn env_overrides() -> anyhow::Result<ConfigOverrides> {
|
||||
Ok(ConfigOverrides {
|
||||
potatomesh: PotatomeshOverrides {
|
||||
base_url: read_env_string(ENV_POTATOMESH_BASE_URL),
|
||||
poll_interval_secs: read_env_u64(ENV_POTATOMESH_POLL_INTERVAL)?,
|
||||
},
|
||||
matrix: MatrixOverrides {
|
||||
homeserver: read_env_string(ENV_MATRIX_HOMESERVER),
|
||||
as_token: read_env_string(ENV_MATRIX_AS_TOKEN),
|
||||
server_name: read_env_string(ENV_MATRIX_SERVER_NAME),
|
||||
room_id: read_env_string(ENV_MATRIX_ROOM_ID),
|
||||
},
|
||||
state: StateOverrides {
|
||||
state_file: read_env_string(ENV_STATE_FILE),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Build overrides from secret files.
|
||||
fn secret_overrides(secrets_dir: Option<&Path>) -> anyhow::Result<ConfigOverrides> {
|
||||
let poll_interval = match read_secret_value(ENV_POTATOMESH_POLL_INTERVAL, secrets_dir)? {
|
||||
Some(value) => Some(value.parse::<u64>().map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid integer value for {} in secret file: {}",
|
||||
ENV_POTATOMESH_POLL_INTERVAL,
|
||||
err
|
||||
)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(ConfigOverrides {
|
||||
potatomesh: PotatomeshOverrides {
|
||||
base_url: read_secret_value(ENV_POTATOMESH_BASE_URL, secrets_dir)?,
|
||||
poll_interval_secs: poll_interval,
|
||||
},
|
||||
matrix: MatrixOverrides {
|
||||
homeserver: read_secret_value(ENV_MATRIX_HOMESERVER, secrets_dir)?,
|
||||
as_token: read_secret_value(ENV_MATRIX_AS_TOKEN, secrets_dir)?,
|
||||
server_name: read_secret_value(ENV_MATRIX_SERVER_NAME, secrets_dir)?,
|
||||
room_id: read_secret_value(ENV_MATRIX_ROOM_ID, secrets_dir)?,
|
||||
},
|
||||
state: StateOverrides {
|
||||
state_file: read_secret_value(ENV_STATE_FILE, secrets_dir)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the effective secrets directory for default *_FILE lookups.
|
||||
fn resolve_secrets_dir(container_defaults: bool) -> Option<PathBuf> {
|
||||
if let Some(dir) = read_env_string(ENV_SECRETS_DIR) {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
|
||||
if container_defaults {
|
||||
return Some(PathBuf::from(DEFAULT_SECRETS_DIR));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the config path, honoring env and CLI overrides.
|
||||
fn resolve_config_path(container_defaults: bool, overrides: &BootstrapOverrides) -> String {
|
||||
if let Some(path) = read_env_string(ENV_CONFIG_PATH) {
|
||||
return path;
|
||||
}
|
||||
if let Some(path) = &overrides.config_path {
|
||||
return path.clone();
|
||||
}
|
||||
|
||||
if container_defaults {
|
||||
DEFAULT_CONTAINER_CONFIG_PATH.to_string()
|
||||
} else {
|
||||
DEFAULT_CONFIG_PATH.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve whether container defaults should be active.
|
||||
fn resolve_container_defaults(
|
||||
in_container: bool,
|
||||
overrides: &BootstrapOverrides,
|
||||
) -> anyhow::Result<bool> {
|
||||
if let Some(env_value) = read_env_bool(ENV_CONTAINER_DEFAULTS)? {
|
||||
return Ok(env_value);
|
||||
}
|
||||
if let Some(cli_value) = overrides.container_defaults {
|
||||
return Ok(cli_value);
|
||||
}
|
||||
Ok(in_container)
|
||||
}
|
||||
|
||||
/// Apply default values and return a fully populated config.
|
||||
fn finalize_config(overrides: ConfigOverrides, container_defaults: bool) -> anyhow::Result<Config> {
|
||||
let base_url = overrides
|
||||
.potatomesh
|
||||
.base_url
|
||||
.ok_or_else(|| anyhow::anyhow!("potatomesh.base_url is required"))?;
|
||||
let poll_interval_secs = overrides.potatomesh.poll_interval_secs.unwrap_or({
|
||||
if container_defaults {
|
||||
DEFAULT_CONTAINER_POLL_INTERVAL_SECS
|
||||
} else {
|
||||
DEFAULT_POLL_INTERVAL_SECS
|
||||
}
|
||||
});
|
||||
|
||||
let homeserver = overrides
|
||||
.matrix
|
||||
.homeserver
|
||||
.ok_or_else(|| anyhow::anyhow!("matrix.homeserver is required"))?;
|
||||
let as_token = overrides
|
||||
.matrix
|
||||
.as_token
|
||||
.ok_or_else(|| anyhow::anyhow!("matrix.as_token is required"))?;
|
||||
let server_name = overrides
|
||||
.matrix
|
||||
.server_name
|
||||
.ok_or_else(|| anyhow::anyhow!("matrix.server_name is required"))?;
|
||||
let room_id = overrides
|
||||
.matrix
|
||||
.room_id
|
||||
.ok_or_else(|| anyhow::anyhow!("matrix.room_id is required"))?;
|
||||
|
||||
let state_file = overrides.state.state_file.unwrap_or_else(|| {
|
||||
if container_defaults {
|
||||
DEFAULT_CONTAINER_STATE_FILE.to_string()
|
||||
} else {
|
||||
DEFAULT_STATE_FILE.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Config {
|
||||
potatomesh: PotatomeshConfig {
|
||||
base_url,
|
||||
poll_interval_secs,
|
||||
},
|
||||
matrix: MatrixConfig {
|
||||
homeserver,
|
||||
as_token,
|
||||
server_name,
|
||||
room_id,
|
||||
},
|
||||
state: StateConfig { state_file },
|
||||
})
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load config from a specific path.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let cfg = toml::from_str(&contents)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Load config from the default path in the working directory.
|
||||
#[allow(dead_code)]
|
||||
pub fn from_default_path() -> anyhow::Result<Self> {
|
||||
let path = DEFAULT_CONFIG_PATH;
|
||||
let path = "Config.toml";
|
||||
if !Path::new(path).exists() {
|
||||
anyhow::bail!("Config file {path} not found");
|
||||
}
|
||||
Self::load_from_file(path)
|
||||
}
|
||||
|
||||
/// Load configuration by merging TOML, CLI, env, and secret values.
|
||||
pub fn load_with_overrides(overrides: BootstrapOverrides) -> anyhow::Result<ConfigBootstrap> {
|
||||
let in_container = detect_container();
|
||||
let container_defaults = resolve_container_defaults(in_container, &overrides)?;
|
||||
let config_path = resolve_config_path(container_defaults, &overrides);
|
||||
let secrets_dir = resolve_secrets_dir(container_defaults);
|
||||
|
||||
let mut merged = ConfigOverrides::default();
|
||||
if let Some(file_overrides) = load_optional_config(&config_path)? {
|
||||
merged.merge(file_overrides);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Config file {} not found; continuing with overrides",
|
||||
config_path
|
||||
);
|
||||
}
|
||||
|
||||
merged.merge(overrides.values);
|
||||
merged.merge(env_overrides()?);
|
||||
merged.merge(secret_overrides(secrets_dir.as_deref())?);
|
||||
|
||||
let config = finalize_config(merged, container_defaults)?;
|
||||
let context = RuntimeContext {
|
||||
in_container,
|
||||
container_defaults,
|
||||
config_path,
|
||||
secrets_dir,
|
||||
};
|
||||
|
||||
Ok(ConfigBootstrap { config, context })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -536,44 +62,6 @@ mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::io::Write;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
struct EnvGuard {
|
||||
key: String,
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set<K: Into<String>>(key: K, value: &str) -> Self {
|
||||
let key = key.into();
|
||||
let previous = env::var(&key).ok();
|
||||
env::set_var(&key, value);
|
||||
Self {
|
||||
key,
|
||||
value: previous,
|
||||
}
|
||||
}
|
||||
|
||||
fn unset<K: Into<String>>(key: K) -> Self {
|
||||
let key = key.into();
|
||||
let previous = env::var(&key).ok();
|
||||
env::remove_var(&key);
|
||||
Self {
|
||||
key,
|
||||
value: previous,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.value {
|
||||
env::set_var(&self.key, value);
|
||||
} else {
|
||||
env::remove_var(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_config_from_toml_str() {
|
||||
@@ -666,190 +154,4 @@ mod tests {
|
||||
let result = Config::from_default_path();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_container_from_env_values() {
|
||||
assert!(detect_container_from(Some("1"), None));
|
||||
assert!(detect_container_from(Some("true"), None));
|
||||
assert!(!detect_container_from(Some("0"), None));
|
||||
assert!(!detect_container_from(Some("false"), None));
|
||||
assert!(!detect_container_from(Some("FALSE"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_container_from_cgroup_markers() {
|
||||
let cgroup = "12:memory:/docker/abcd\n11:pids:/kubepods.slice";
|
||||
assert!(detect_container_from(None, Some(cgroup)));
|
||||
|
||||
let host_cgroup = "0::/user.slice/user-1000.slice";
|
||||
assert!(!detect_container_from(None, Some(host_cgroup)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn env_overrides_cli_and_toml() {
|
||||
let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
|
||||
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
|
||||
let _guard_poll = EnvGuard::set(ENV_POTATOMESH_POLL_INTERVAL, "25");
|
||||
let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
|
||||
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://toml.example/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
as_token = "toml-token"
|
||||
server_name = "example.org"
|
||||
room_id = "!roomid:example.org"
|
||||
|
||||
[state]
|
||||
state_file = "toml_state.json"
|
||||
"#;
|
||||
let mut file = tempfile::NamedTempFile::new().unwrap();
|
||||
write!(file, "{}", toml_str).unwrap();
|
||||
|
||||
let overrides = BootstrapOverrides {
|
||||
config_path: Some(file.path().to_str().unwrap().to_string()),
|
||||
container_defaults: Some(false),
|
||||
values: ConfigOverrides {
|
||||
potatomesh: PotatomeshOverrides {
|
||||
base_url: Some("https://cli.example/".to_string()),
|
||||
poll_interval_secs: Some(15),
|
||||
},
|
||||
matrix: MatrixOverrides {
|
||||
as_token: Some("cli-token".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
state: StateOverrides {
|
||||
state_file: Some("cli_state.json".to_string()),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = Config::load_with_overrides(overrides).unwrap();
|
||||
assert_eq!(result.config.potatomesh.base_url, "https://env.example/");
|
||||
assert_eq!(result.config.potatomesh.poll_interval_secs, 25);
|
||||
assert_eq!(result.config.matrix.as_token, "env-token");
|
||||
assert_eq!(result.config.state.state_file, "cli_state.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn secret_file_overrides_env_values() {
|
||||
let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
|
||||
let _guard_homeserver = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
|
||||
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
|
||||
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
|
||||
let _guard_env_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
|
||||
let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
|
||||
|
||||
let secret_file = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(secret_file.path(), "secret-token").unwrap();
|
||||
let _guard_secret = EnvGuard::set(
|
||||
format!("{}_FILE", ENV_MATRIX_AS_TOKEN),
|
||||
secret_file.path().to_str().unwrap(),
|
||||
);
|
||||
|
||||
let overrides = BootstrapOverrides::default();
|
||||
let result = Config::load_with_overrides(overrides).unwrap();
|
||||
assert_eq!(result.config.matrix.as_token, "secret-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn container_defaults_change_paths_and_intervals() {
|
||||
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
|
||||
let _guard_defaults = EnvGuard::unset(ENV_CONTAINER_DEFAULTS);
|
||||
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
|
||||
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
|
||||
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
|
||||
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
|
||||
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
|
||||
|
||||
let overrides = BootstrapOverrides::default();
|
||||
let result = Config::load_with_overrides(overrides).unwrap();
|
||||
|
||||
assert!(result.context.in_container);
|
||||
assert!(result.context.container_defaults);
|
||||
assert_eq!(result.context.config_path, DEFAULT_CONTAINER_CONFIG_PATH);
|
||||
assert_eq!(result.config.state.state_file, DEFAULT_CONTAINER_STATE_FILE);
|
||||
assert_eq!(
|
||||
result.config.potatomesh.poll_interval_secs,
|
||||
DEFAULT_CONTAINER_POLL_INTERVAL_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn container_defaults_can_be_disabled() {
|
||||
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
|
||||
let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
|
||||
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
|
||||
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
|
||||
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
|
||||
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
|
||||
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
|
||||
|
||||
let overrides = BootstrapOverrides::default();
|
||||
let result = Config::load_with_overrides(overrides).unwrap();
|
||||
|
||||
assert!(result.context.in_container);
|
||||
assert!(!result.context.container_defaults);
|
||||
assert_eq!(result.context.config_path, DEFAULT_CONFIG_PATH);
|
||||
assert_eq!(result.config.state.state_file, DEFAULT_STATE_FILE);
|
||||
assert_eq!(
|
||||
result.config.potatomesh.poll_interval_secs,
|
||||
DEFAULT_POLL_INTERVAL_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn secrets_dir_defaults_are_used_when_present() {
|
||||
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
|
||||
let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "1");
|
||||
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
|
||||
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
|
||||
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
|
||||
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let secret_path = temp_dir.path().join(ENV_MATRIX_AS_TOKEN);
|
||||
fs::write(&secret_path, "dir-token").unwrap();
|
||||
let _guard_dir = EnvGuard::set(ENV_SECRETS_DIR, temp_dir.path().to_str().unwrap());
|
||||
|
||||
let overrides = BootstrapOverrides::default();
|
||||
let result = Config::load_with_overrides(overrides).unwrap();
|
||||
assert_eq!(result.config.matrix.as_token, "dir-token");
|
||||
assert_eq!(
|
||||
result.context.secrets_dir,
|
||||
Some(PathBuf::from(temp_dir.path()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn read_env_bool_rejects_invalid_values() {
|
||||
let _guard = EnvGuard::set("POTATOMESH_TEST_BOOL", "maybe");
|
||||
let result = read_env_bool("POTATOMESH_TEST_BOOL");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn read_env_u64_rejects_invalid_values() {
|
||||
let _guard = EnvGuard::set("POTATOMESH_TEST_U64", "not-a-number");
|
||||
let result = read_env_u64("POTATOMESH_TEST_U64");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_secret_file_rejects_empty_contents() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(file.path(), " ").unwrap();
|
||||
let result = read_secret_file(file.path());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+118
-346
@@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod matrix;
|
||||
mod potatomesh;
|
||||
@@ -20,34 +19,16 @@ mod potatomesh;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::config::Config;
|
||||
use crate::matrix::MatrixAppserviceClient;
|
||||
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage, PotatoNode};
|
||||
|
||||
fn format_runtime_context(context: &config::RuntimeContext) -> String {
|
||||
format!(
|
||||
"Runtime context: in_container={} container_defaults={} config_path={} secrets_dir={:?}",
|
||||
context.in_container, context.container_defaults, context.config_path, context.secrets_dir
|
||||
)
|
||||
}
|
||||
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct BridgeState {
|
||||
/// Highest message id processed by the bridge.
|
||||
last_message_id: Option<u64>,
|
||||
/// Highest rx_time observed; used to build incremental fetch queries.
|
||||
#[serde(default)]
|
||||
last_rx_time: Option<u64>,
|
||||
/// Message ids seen at the current last_rx_time for de-duplication.
|
||||
#[serde(default)]
|
||||
last_rx_time_ids: Vec<u64>,
|
||||
/// Legacy checkpoint timestamp used before last_rx_time was added.
|
||||
#[serde(default, skip_serializing)]
|
||||
last_checked_at: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -57,15 +38,7 @@ impl BridgeState {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let data = fs::read_to_string(path)?;
|
||||
// Treat empty/whitespace-only files as a fresh state.
|
||||
if data.trim().is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let mut s: Self = serde_json::from_str(&data)?;
|
||||
if s.last_rx_time.is_none() {
|
||||
s.last_rx_time = s.last_checked_at;
|
||||
}
|
||||
s.last_checked_at = None;
|
||||
let s: Self = serde_json::from_str(&data)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -76,32 +49,17 @@ impl BridgeState {
|
||||
}
|
||||
|
||||
fn should_forward(&self, msg: &PotatoMessage) -> bool {
|
||||
match self.last_rx_time {
|
||||
None => match self.last_message_id {
|
||||
None => true,
|
||||
Some(last_id) => msg.id > last_id,
|
||||
},
|
||||
Some(last_ts) => {
|
||||
if msg.rx_time > last_ts {
|
||||
true
|
||||
} else if msg.rx_time < last_ts {
|
||||
false
|
||||
} else {
|
||||
!self.last_rx_time_ids.contains(&msg.id)
|
||||
}
|
||||
}
|
||||
match self.last_message_id {
|
||||
None => true,
|
||||
Some(last) => msg.id > last,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_with(&mut self, msg: &PotatoMessage) {
|
||||
self.last_message_id = Some(msg.id);
|
||||
if self.last_rx_time.is_none() || Some(msg.rx_time) > self.last_rx_time {
|
||||
self.last_rx_time = Some(msg.rx_time);
|
||||
self.last_rx_time_ids = vec![msg.id];
|
||||
} else if Some(msg.rx_time) == self.last_rx_time && !self.last_rx_time_ids.contains(&msg.id)
|
||||
{
|
||||
self.last_rx_time_ids.push(msg.id);
|
||||
}
|
||||
self.last_message_id = Some(match self.last_message_id {
|
||||
None => msg.id,
|
||||
Some(last) => last.max(msg.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +69,7 @@ fn build_fetch_params(state: &BridgeState) -> FetchParams {
|
||||
limit: None,
|
||||
since: None,
|
||||
}
|
||||
} else if let Some(ts) = state.last_rx_time {
|
||||
} else if let Some(ts) = state.last_checked_at {
|
||||
FetchParams {
|
||||
limit: None,
|
||||
since: Some(ts),
|
||||
@@ -124,18 +82,34 @@ fn build_fetch_params(state: &BridgeState) -> FetchParams {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_checkpoint(state: &mut BridgeState, delivered_all: bool, now_secs: u64) -> bool {
|
||||
if !delivered_all {
|
||||
return false;
|
||||
}
|
||||
|
||||
if state.last_message_id.is_some() {
|
||||
state.last_checked_at = Some(now_secs);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_once(
|
||||
potato: &PotatoClient,
|
||||
matrix: &MatrixAppserviceClient,
|
||||
state: &mut BridgeState,
|
||||
state_path: &str,
|
||||
now_secs: u64,
|
||||
) {
|
||||
let params = build_fetch_params(state);
|
||||
|
||||
match potato.fetch_messages(params).await {
|
||||
Ok(mut msgs) => {
|
||||
// sort by rx_time so we process by actual receipt time
|
||||
msgs.sort_by_key(|m| m.rx_time);
|
||||
// sort by id ascending so we process in order
|
||||
msgs.sort_by_key(|m| m.id);
|
||||
|
||||
let mut delivered_all = true;
|
||||
|
||||
for msg in &msgs {
|
||||
if !state.should_forward(msg) {
|
||||
@@ -146,24 +120,28 @@ async fn poll_once(
|
||||
if let Some(port) = &msg.portnum {
|
||||
if port != "TEXT_MESSAGE_APP" {
|
||||
state.update_with(msg);
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = handle_message(potato, matrix, state, msg).await {
|
||||
error!("Error handling message {}: {:?}", msg.id, e);
|
||||
delivered_all = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
state.update_with(msg);
|
||||
// persist after each processed message
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Only advance checkpoint after successful delivery and a known last_message_id.
|
||||
if update_checkpoint(state, delivered_all, now_secs) {
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error fetching PotatoMesh messages: {:?}", e);
|
||||
@@ -182,12 +160,8 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let bootstrap = Config::load_with_overrides(cli.into_overrides())?;
|
||||
info!("Loaded config: {:?}", bootstrap.config);
|
||||
info!("{}", format_runtime_context(&bootstrap.context));
|
||||
|
||||
let cfg = bootstrap.config;
|
||||
let cfg = Config::from_default_path()?;
|
||||
info!("Loaded config: {:?}", cfg);
|
||||
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
|
||||
@@ -202,7 +176,12 @@ async fn main() -> Result<()> {
|
||||
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
|
||||
|
||||
loop {
|
||||
poll_once(&potato, &matrix, &mut state, state_path).await;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
poll_once(&potato, &matrix, &mut state, state_path, now_secs).await;
|
||||
|
||||
sleep(poll_interval).await;
|
||||
}
|
||||
@@ -220,77 +199,38 @@ async fn handle_message(
|
||||
|
||||
// Ensure puppet exists & has display name
|
||||
matrix.ensure_user_registered(&localpart).await?;
|
||||
matrix.ensure_user_joined_room(&user_id).await?;
|
||||
let display_name = display_name_for_node(&node);
|
||||
matrix.set_display_name(&user_id, &display_name).await?;
|
||||
matrix.set_display_name(&user_id, &node.long_name).await?;
|
||||
|
||||
// Format the bridged message
|
||||
let preset_short = modem_preset_short(&msg.modem_preset);
|
||||
let prefix = format!(
|
||||
"[{freq}][{preset_short}][{channel}]",
|
||||
freq = msg.lora_freq,
|
||||
preset_short = preset_short,
|
||||
channel = msg.channel_name,
|
||||
);
|
||||
let (body, formatted_body) = format_message_bodies(&prefix, &msg.text);
|
||||
let short = node
|
||||
.short_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| node.long_name.clone());
|
||||
|
||||
matrix
|
||||
.send_formatted_message_as(&user_id, &body, &formatted_body)
|
||||
.await?;
|
||||
let body = format!(
|
||||
"[{short}] {text}\n({from_id} → {to_id}, {rssi}, {snr}, {chan}/{preset})",
|
||||
short = short,
|
||||
text = msg.text,
|
||||
from_id = msg.from_id,
|
||||
to_id = msg.to_id,
|
||||
rssi = msg
|
||||
.rssi
|
||||
.map(|v| format!("RSSI {v} dB"))
|
||||
.unwrap_or_else(|| "RSSI n/a".to_string()),
|
||||
snr = msg
|
||||
.snr
|
||||
.map(|v| format!("SNR {v} dB"))
|
||||
.unwrap_or_else(|| "SNR n/a".to_string()),
|
||||
chan = msg.channel_name,
|
||||
preset = msg.modem_preset,
|
||||
);
|
||||
|
||||
matrix.send_text_message_as(&user_id, &body).await?;
|
||||
|
||||
state.update_with(msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a compact modem preset label like "LF" for "LongFast".
|
||||
fn modem_preset_short(preset: &str) -> String {
|
||||
let letters: String = preset
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_uppercase())
|
||||
.collect();
|
||||
if letters.is_empty() {
|
||||
preset.chars().take(2).collect()
|
||||
} else {
|
||||
letters
|
||||
}
|
||||
}
|
||||
|
||||
/// Build plain text + HTML message bodies with inline-code metadata.
|
||||
fn format_message_bodies(prefix: &str, text: &str) -> (String, String) {
|
||||
let body = format!("`{}` {}", prefix, text);
|
||||
let formatted_body = format!("<code>{}</code> {}", escape_html(prefix), escape_html(text));
|
||||
(body, formatted_body)
|
||||
}
|
||||
|
||||
/// Build the Matrix display name from a node's long/short names.
|
||||
fn display_name_for_node(node: &PotatoNode) -> String {
|
||||
match node
|
||||
.short_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
Some(short) if short != node.long_name => format!("{} ({})", node.long_name, short),
|
||||
_ => node.long_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal HTML escaping for Matrix formatted_body payloads.
|
||||
fn escape_html(input: &str) -> String {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -319,54 +259,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_node(short_name: Option<&str>, long_name: &str) -> PotatoNode {
|
||||
PotatoNode {
|
||||
node_id: "!abcd1234".to_string(),
|
||||
short_name: short_name.map(str::to_string),
|
||||
long_name: long_name.to_string(),
|
||||
role: None,
|
||||
hw_model: None,
|
||||
last_heard: None,
|
||||
first_heard: None,
|
||||
latitude: None,
|
||||
longitude: None,
|
||||
altitude: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modem_preset_short_handles_camelcase() {
|
||||
assert_eq!(modem_preset_short("LongFast"), "LF");
|
||||
assert_eq!(modem_preset_short("MediumFast"), "MF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_message_bodies_escape_html() {
|
||||
let (body, formatted) = format_message_bodies("[868][LF]", "Hello <&>");
|
||||
assert_eq!(body, "`[868][LF]` Hello <&>");
|
||||
assert_eq!(formatted, "<code>[868][LF]</code> Hello <&>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_html_escapes_quotes() {
|
||||
assert_eq!(escape_html("a\"b'c"), "a"b'c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_for_node_includes_short_when_present() {
|
||||
let node = sample_node(Some("TN"), "Test Node");
|
||||
assert_eq!(display_name_for_node(&node), "Test Node (TN)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_for_node_ignores_empty_or_duplicate_short() {
|
||||
let empty_short = sample_node(Some(""), "Test Node");
|
||||
assert_eq!(display_name_for_node(&empty_short), "Test Node");
|
||||
|
||||
let duplicate_short = sample_node(Some("Test Node"), "Test Node");
|
||||
assert_eq!(display_name_for_node(&duplicate_short), "Test Node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_initially_forwards_all() {
|
||||
let state = BridgeState::default();
|
||||
@@ -376,72 +268,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_tracks_latest_rx_time_and_skips_older() {
|
||||
fn bridge_state_tracks_highest_id_and_skips_older() {
|
||||
let mut state = BridgeState::default();
|
||||
let m1 = sample_msg(10);
|
||||
let m2 = sample_msg(20);
|
||||
let m3 = sample_msg(15);
|
||||
let m1 = PotatoMessage { rx_time: 10, ..m1 };
|
||||
let m2 = PotatoMessage { rx_time: 20, ..m2 };
|
||||
let m3 = PotatoMessage { rx_time: 15, ..m3 };
|
||||
|
||||
// First message, should forward
|
||||
assert!(state.should_forward(&m1));
|
||||
state.update_with(&m1);
|
||||
assert_eq!(state.last_message_id, Some(10));
|
||||
assert_eq!(state.last_rx_time, Some(10));
|
||||
|
||||
// Second message, higher id, should forward
|
||||
assert!(state.should_forward(&m2));
|
||||
state.update_with(&m2);
|
||||
assert_eq!(state.last_message_id, Some(20));
|
||||
assert_eq!(state.last_rx_time, Some(20));
|
||||
|
||||
// Third message, lower than last, should NOT forward
|
||||
assert!(!state.should_forward(&m3));
|
||||
// state remains unchanged
|
||||
assert_eq!(state.last_message_id, Some(20));
|
||||
assert_eq!(state.last_rx_time, Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_uses_legacy_id_filter_when_rx_time_missing() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(10),
|
||||
last_rx_time: None,
|
||||
last_rx_time_ids: vec![],
|
||||
fn bridge_state_update_is_monotonic() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(50),
|
||||
last_checked_at: None,
|
||||
};
|
||||
let older = sample_msg(9);
|
||||
let newer = sample_msg(11);
|
||||
let m = sample_msg(40);
|
||||
|
||||
assert!(!state.should_forward(&older));
|
||||
assert!(state.should_forward(&newer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_dedupes_same_timestamp() {
|
||||
let mut state = BridgeState::default();
|
||||
let m1 = PotatoMessage {
|
||||
rx_time: 100,
|
||||
..sample_msg(10)
|
||||
};
|
||||
let m2 = PotatoMessage {
|
||||
rx_time: 100,
|
||||
..sample_msg(9)
|
||||
};
|
||||
let dup = PotatoMessage {
|
||||
rx_time: 100,
|
||||
..sample_msg(10)
|
||||
};
|
||||
|
||||
assert!(state.should_forward(&m1));
|
||||
state.update_with(&m1);
|
||||
assert!(state.should_forward(&m2));
|
||||
state.update_with(&m2);
|
||||
assert!(!state.should_forward(&dup));
|
||||
assert_eq!(state.last_rx_time, Some(100));
|
||||
assert_eq!(state.last_rx_time_ids, vec![10, 9]);
|
||||
state.update_with(&m); // id is lower than current
|
||||
// last_message_id must stay at 50
|
||||
assert_eq!(state.last_message_id, Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -452,17 +311,13 @@ mod tests {
|
||||
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(12345),
|
||||
last_rx_time: Some(99),
|
||||
last_rx_time_ids: vec![123],
|
||||
last_checked_at: Some(77),
|
||||
last_checked_at: Some(99),
|
||||
};
|
||||
state.save(path_str).unwrap();
|
||||
|
||||
let loaded_state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(loaded_state.last_message_id, Some(12345));
|
||||
assert_eq!(loaded_state.last_rx_time, Some(99));
|
||||
assert_eq!(loaded_state.last_rx_time_ids, vec![123]);
|
||||
assert_eq!(loaded_state.last_checked_at, None);
|
||||
assert_eq!(loaded_state.last_checked_at, Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -473,50 +328,50 @@ mod tests {
|
||||
|
||||
let state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(state.last_message_id, None);
|
||||
assert_eq!(state.last_rx_time, None);
|
||||
assert!(state.last_rx_time_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_load_empty_file() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = tmp_dir.path().join("empty.json");
|
||||
let path_str = file_path.to_str().unwrap();
|
||||
|
||||
fs::write(path_str, "").unwrap();
|
||||
|
||||
let state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(state.last_message_id, None);
|
||||
assert_eq!(state.last_rx_time, None);
|
||||
assert!(state.last_rx_time_ids.is_empty());
|
||||
assert_eq!(state.last_checked_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_migrates_legacy_checkpoint() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = tmp_dir.path().join("legacy_state.json");
|
||||
let path_str = file_path.to_str().unwrap();
|
||||
fn update_checkpoint_requires_last_message_id() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: None,
|
||||
last_checked_at: Some(10),
|
||||
};
|
||||
|
||||
fs::write(
|
||||
path_str,
|
||||
r#"{"last_message_id":42,"last_checked_at":1710000000}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let saved = update_checkpoint(&mut state, true, 123);
|
||||
assert!(!saved);
|
||||
assert_eq!(state.last_checked_at, Some(10));
|
||||
}
|
||||
|
||||
let state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(state.last_message_id, Some(42));
|
||||
assert_eq!(state.last_rx_time, Some(1_710_000_000));
|
||||
assert!(state.last_rx_time_ids.is_empty());
|
||||
#[test]
|
||||
fn update_checkpoint_skips_when_not_delivered() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(5),
|
||||
last_checked_at: Some(10),
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, false, 123);
|
||||
assert!(!saved);
|
||||
assert_eq!(state.last_checked_at, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_sets_when_safe() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(5),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, true, 123);
|
||||
assert!(saved);
|
||||
assert_eq!(state.last_checked_at, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_respects_missing_last_message_id() {
|
||||
let state = BridgeState {
|
||||
last_message_id: None,
|
||||
last_rx_time: Some(123),
|
||||
last_rx_time_ids: vec![],
|
||||
last_checked_at: None,
|
||||
last_checked_at: Some(123),
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
@@ -528,9 +383,7 @@ mod tests {
|
||||
fn fetch_params_uses_since_when_safe() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_rx_time: Some(123),
|
||||
last_rx_time_ids: vec![],
|
||||
last_checked_at: None,
|
||||
last_checked_at: Some(123),
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
@@ -542,8 +395,6 @@ mod tests {
|
||||
fn fetch_params_defaults_to_small_window() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_rx_time: None,
|
||||
last_rx_time_ids: vec![],
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
@@ -553,7 +404,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_leaves_state_unchanged_without_messages() {
|
||||
async fn poll_once_persists_checkpoint_without_messages() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let state_path = tmp_dir.path().join("state.json");
|
||||
let state_str = state_path.to_str().unwrap();
|
||||
@@ -584,62 +435,18 @@ mod tests {
|
||||
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_rx_time: Some(100),
|
||||
last_rx_time_ids: vec![1],
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
poll_once(&potato, &matrix, &mut state, state_str).await;
|
||||
poll_once(&potato, &matrix, &mut state, state_str, 123).await;
|
||||
|
||||
mock_msgs.assert();
|
||||
|
||||
// No new data means state remains unchanged and is not persisted.
|
||||
assert_eq!(state.last_rx_time, Some(100));
|
||||
assert_eq!(state.last_rx_time_ids, vec![1]);
|
||||
assert!(!state_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_persists_state_for_non_text_messages() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let state_path = tmp_dir.path().join("state.json");
|
||||
let state_str = state_path.to_str().unwrap();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock_msgs = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"id":1,"rx_time":100,"rx_iso":"2025-11-27T00:00:00Z","from_id":"!abcd1234","to_id":"^all","channel":1,"portnum":"POSITION_APP","text":"","rssi":-100,"hop_limit":1,"lora_freq":868,"modem_preset":"MediumFast","channel_name":"TEST","snr":0.0,"node_id":"!abcd1234"}]"#,
|
||||
)
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let potatomesh_cfg = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 1,
|
||||
};
|
||||
let matrix_cfg = MatrixConfig {
|
||||
homeserver: server.url(),
|
||||
as_token: "AS_TOKEN".to_string(),
|
||||
server_name: "example.org".to_string(),
|
||||
room_id: "!roomid:example.org".to_string(),
|
||||
};
|
||||
|
||||
let potato = PotatoClient::new(http_client.clone(), potatomesh_cfg);
|
||||
let matrix = MatrixAppserviceClient::new(http_client, matrix_cfg);
|
||||
let mut state = BridgeState::default();
|
||||
|
||||
poll_once(&potato, &matrix, &mut state, state_str).await;
|
||||
|
||||
mock_msgs.assert();
|
||||
assert!(state_path.exists());
|
||||
// Should have advanced checkpoint and saved it.
|
||||
assert_eq!(state.last_checked_at, Some(123));
|
||||
let loaded = BridgeState::load(state_str).unwrap();
|
||||
assert_eq!(loaded.last_checked_at, Some(123));
|
||||
assert_eq!(loaded.last_message_id, Some(1));
|
||||
assert_eq!(loaded.last_rx_time, Some(100));
|
||||
assert_eq!(loaded.last_rx_time_ids, vec![1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -660,8 +467,6 @@ mod tests {
|
||||
let node_id = "abcd1234";
|
||||
let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name);
|
||||
let encoded_user = urlencoding::encode(&user_id);
|
||||
let room_id = matrix_cfg.room_id.clone();
|
||||
let encoded_room = urlencoding::encode(&room_id);
|
||||
|
||||
let mock_get_node = server
|
||||
.mock("GET", "/api/nodes/abcd1234")
|
||||
@@ -676,29 +481,19 @@ mod tests {
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let mock_join = server
|
||||
.mock(
|
||||
"POST",
|
||||
format!("/_matrix/client/v3/rooms/{}/join", encoded_room).as_str(),
|
||||
)
|
||||
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let mock_display_name = server
|
||||
.mock(
|
||||
"PUT",
|
||||
format!("/_matrix/client/v3/profile/{}/displayname", encoded_user).as_str(),
|
||||
)
|
||||
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
|
||||
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
|
||||
"displayname": "Test Node (TN)"
|
||||
})))
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let matrix_client = MatrixAppserviceClient::new(http_client.clone(), matrix_cfg);
|
||||
let room_id = &matrix_client.cfg.room_id;
|
||||
let encoded_room = urlencoding::encode(room_id);
|
||||
let txn_id = matrix_client
|
||||
.txn_counter
|
||||
.load(std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -713,12 +508,6 @@ mod tests {
|
||||
.as_str(),
|
||||
)
|
||||
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
|
||||
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "`[868][MF][TEST]` Ping",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": "<code>[868][MF][TEST]</code> Ping",
|
||||
})))
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
@@ -731,26 +520,9 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
mock_get_node.assert();
|
||||
mock_register.assert();
|
||||
mock_join.assert();
|
||||
mock_display_name.assert();
|
||||
mock_send.assert();
|
||||
|
||||
assert_eq!(state.last_message_id, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_runtime_context_includes_flags() {
|
||||
let context = config::RuntimeContext {
|
||||
in_container: true,
|
||||
container_defaults: false,
|
||||
config_path: "/app/Config.toml".to_string(),
|
||||
secrets_dir: Some(std::path::PathBuf::from("/run/secrets")),
|
||||
};
|
||||
|
||||
let rendered = format_runtime_context(&context);
|
||||
assert!(rendered.contains("in_container=true"));
|
||||
assert!(rendered.contains("container_defaults=false"));
|
||||
assert!(rendered.contains("/app/Config.toml"));
|
||||
assert!(rendered.contains("/run/secrets"));
|
||||
}
|
||||
}
|
||||
|
||||
+41
-105
@@ -134,50 +134,12 @@ impl MatrixAppserviceClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the puppet user is joined to the configured room.
|
||||
pub async fn ensure_user_joined_room(&self, user_id: &str) -> anyhow::Result<()> {
|
||||
#[derive(Serialize)]
|
||||
struct JoinReq {}
|
||||
|
||||
let encoded_room = urlencoding::encode(&self.cfg.room_id);
|
||||
let encoded_user = urlencoding::encode(user_id);
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/join?user_id={}&{}",
|
||||
self.cfg.homeserver,
|
||||
encoded_room,
|
||||
encoded_user,
|
||||
self.auth_query()
|
||||
);
|
||||
|
||||
let resp = self.http.post(&url).json(&JoinReq {}).send().await?;
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let body_snip = resp.text().await.unwrap_or_default();
|
||||
Err(anyhow::anyhow!(
|
||||
"Matrix join failed for {} in {} with status {} ({})",
|
||||
user_id,
|
||||
self.cfg.room_id,
|
||||
status,
|
||||
body_snip
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a text message with HTML formatting into the configured room as puppet user_id.
|
||||
pub async fn send_formatted_message_as(
|
||||
&self,
|
||||
user_id: &str,
|
||||
body_text: &str,
|
||||
formatted_body: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
/// Send a plain text message into the configured room as puppet user_id.
|
||||
pub async fn send_text_message_as(&self, user_id: &str, body_text: &str) -> anyhow::Result<()> {
|
||||
#[derive(Serialize)]
|
||||
struct MsgContent<'a> {
|
||||
msgtype: &'a str,
|
||||
body: &'a str,
|
||||
format: &'a str,
|
||||
formatted_body: &'a str,
|
||||
}
|
||||
|
||||
let txn_id = self.txn_counter.fetch_add(1, Ordering::SeqCst);
|
||||
@@ -196,23 +158,24 @@ impl MatrixAppserviceClient {
|
||||
let content = MsgContent {
|
||||
msgtype: "m.text",
|
||||
body: body_text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body,
|
||||
};
|
||||
|
||||
let resp = self.http.put(&url).json(&content).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// optional: pull a short body snippet for debugging
|
||||
let body_snip = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Log for observability
|
||||
tracing::warn!(
|
||||
"Failed to send formatted message as {}: status {}, body: {}",
|
||||
"Failed to send message as {}: status {}, body: {}",
|
||||
user_id,
|
||||
status,
|
||||
body_snip
|
||||
);
|
||||
|
||||
// Propagate an error so callers know this message was NOT delivered
|
||||
return Err(anyhow::anyhow!(
|
||||
"Matrix send failed for {} with status {}",
|
||||
user_id,
|
||||
@@ -395,59 +358,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_user_joined_room_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let user_id = "@test:example.org";
|
||||
let room_id = "!roomid:example.org";
|
||||
let encoded_user = urlencoding::encode(user_id);
|
||||
let encoded_room = urlencoding::encode(room_id);
|
||||
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
|
||||
let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room);
|
||||
|
||||
let mock = server
|
||||
.mock("POST", path.as_str())
|
||||
.match_query(query.as_str())
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
cfg.room_id = room_id.to_string();
|
||||
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
|
||||
let result = client.ensure_user_joined_room(user_id).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_user_joined_room_fail() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let user_id = "@test:example.org";
|
||||
let room_id = "!roomid:example.org";
|
||||
let encoded_user = urlencoding::encode(user_id);
|
||||
let encoded_room = urlencoding::encode(room_id);
|
||||
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
|
||||
let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room);
|
||||
|
||||
let mock = server
|
||||
.mock("POST", path.as_str())
|
||||
.match_query(query.as_str())
|
||||
.with_status(403)
|
||||
.create();
|
||||
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
cfg.room_id = room_id.to_string();
|
||||
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
|
||||
let result = client.ensure_user_joined_room(user_id).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_formatted_message_as_success() {
|
||||
async fn test_send_text_message_as_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let user_id = "@test:example.org";
|
||||
let room_id = "!roomid:example.org";
|
||||
@@ -470,20 +381,45 @@ mod tests {
|
||||
let mock = server
|
||||
.mock("PUT", path.as_str())
|
||||
.match_query(query.as_str())
|
||||
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
|
||||
"msgtype": "m.text",
|
||||
"body": "`[meta]` hello",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": "<code>[meta]</code> hello",
|
||||
})))
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let result = client
|
||||
.send_formatted_message_as(user_id, "`[meta]` hello", "<code>[meta]</code> hello")
|
||||
.await;
|
||||
let result = client.send_text_message_as(user_id, "hello").await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_text_message_as_fail() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let user_id = "@test:example.org";
|
||||
let room_id = "!roomid:example.org";
|
||||
let encoded_user = urlencoding::encode(user_id);
|
||||
let encoded_room = urlencoding::encode(room_id);
|
||||
|
||||
let client = {
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
cfg.room_id = room_id.to_string();
|
||||
MatrixAppserviceClient::new(reqwest::Client::new(), cfg)
|
||||
};
|
||||
let txn_id = client.txn_counter.load(Ordering::SeqCst);
|
||||
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
|
||||
let path = format!(
|
||||
"/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
||||
encoded_room, txn_id
|
||||
);
|
||||
|
||||
let mock = server
|
||||
.mock("PUT", path.as_str())
|
||||
.match_query(query.as_str())
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let result = client.send_text_message_as(user_id, "hello").await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,40 +285,6 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
|
||||
mesh_module.INSTANCE = mesh_module.config.INSTANCE
|
||||
|
||||
|
||||
def test_parse_channel_names_applies_allowlist(mesh_module):
|
||||
"""Ensure allowlists reuse the shared channel parser."""
|
||||
|
||||
mesh = mesh_module
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
|
||||
try:
|
||||
parsed = mesh.config._parse_channel_names(" Primary ,Chat ,primary , Ops ")
|
||||
mesh.ALLOWED_CHANNELS = parsed
|
||||
|
||||
assert parsed == ("Primary", "Chat", "Ops")
|
||||
assert mesh.channels.allowed_channel_names() == ("Primary", "Chat", "Ops")
|
||||
assert mesh.channels.is_allowed_channel("chat")
|
||||
assert mesh.channels.is_allowed_channel(" ops ")
|
||||
assert not mesh.channels.is_allowed_channel("unknown")
|
||||
assert not mesh.channels.is_allowed_channel(None)
|
||||
assert mesh.config._parse_channel_names("") == ()
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
|
||||
|
||||
def test_allowed_channel_defaults_allow_all(mesh_module):
|
||||
"""Ensure unset allowlists do not block any channels."""
|
||||
|
||||
mesh = mesh_module
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
|
||||
try:
|
||||
mesh.ALLOWED_CHANNELS = ()
|
||||
assert mesh.channels.is_allowed_channel("Any")
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
|
||||
|
||||
def test_parse_hidden_channels_deduplicates_names(mesh_module):
|
||||
"""Ensure hidden channel parsing strips blanks and deduplicates."""
|
||||
|
||||
@@ -1929,110 +1895,6 @@ def test_store_packet_dict_allows_primary_channel_broadcast(mesh_module, monkeyp
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_accepts_routing_app_messages(mesh_module, monkeypatch):
|
||||
"""Ensure routing app payloads are treated as message posts."""
|
||||
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 333,
|
||||
"rxTime": 999,
|
||||
"fromId": "!node",
|
||||
"toId": "^all",
|
||||
"channel": 0,
|
||||
"decoded": {"payload": "GAA=", "portnum": "ROUTING_APP"},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
path, payload, priority = captured[0]
|
||||
assert path == "/api/messages"
|
||||
assert payload["portnum"] == "ROUTING_APP"
|
||||
assert payload["text"] == "GAA="
|
||||
assert payload["channel"] == 0
|
||||
assert payload["encrypted"] is None
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_serializes_routing_payloads(mesh_module, monkeypatch):
|
||||
"""Ensure routing payloads are serialized when text is absent."""
|
||||
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 334,
|
||||
"rxTime": 1000,
|
||||
"fromId": "!node",
|
||||
"toId": "^all",
|
||||
"channel": 0,
|
||||
"decoded": {
|
||||
"payload": b"\x01\x02",
|
||||
"portnum": "ROUTING_APP",
|
||||
},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == "AQI="
|
||||
|
||||
captured.clear()
|
||||
|
||||
packet["decoded"]["payload"] = {"kind": "ack"}
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == '{"kind": "ack"}'
|
||||
|
||||
captured.clear()
|
||||
|
||||
packet["decoded"]["portnum"] = 7
|
||||
packet["decoded"]["payload"] = b"\x00"
|
||||
packet["decoded"]["routing"] = {"errorReason": "NONE"}
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected numeric routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == "AA=="
|
||||
|
||||
|
||||
def test_portnum_candidates_reads_enum_values(mesh_module, monkeypatch):
|
||||
"""Ensure portnum candidates include enum and constants when available."""
|
||||
|
||||
mesh = mesh_module
|
||||
module_name = "meshtastic.portnums_pb2"
|
||||
|
||||
class DummyPortNum:
|
||||
@staticmethod
|
||||
def Value(name):
|
||||
if name == "ROUTING_APP":
|
||||
return 7
|
||||
raise KeyError(name)
|
||||
|
||||
dummy_module = types.SimpleNamespace(PortNum=DummyPortNum, ROUTING_APP=8)
|
||||
monkeypatch.setitem(sys.modules, module_name, dummy_module)
|
||||
|
||||
candidates = mesh.handlers._portnum_candidates("ROUTING_APP")
|
||||
|
||||
assert 7 in candidates
|
||||
assert 8 in candidates
|
||||
|
||||
|
||||
def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
mesh.channels._reset_channel_cache()
|
||||
@@ -2135,10 +1997,8 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys
|
||||
|
||||
previous_debug = mesh.config.DEBUG
|
||||
previous_hidden = mesh.HIDDEN_CHANNELS
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
mesh.config.DEBUG = True
|
||||
mesh.DEBUG = True
|
||||
mesh.ALLOWED_CHANNELS = ("Chat",)
|
||||
mesh.HIDDEN_CHANNELS = ("Chat",)
|
||||
|
||||
try:
|
||||
@@ -2157,77 +2017,6 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys
|
||||
assert ignored == ["hidden-channel"]
|
||||
assert "Ignored packet on hidden channel" in capsys.readouterr().out
|
||||
finally:
|
||||
mesh.HIDDEN_CHANNELS = previous_hidden
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
mesh.config.DEBUG = previous_debug
|
||||
mesh.DEBUG = previous_debug
|
||||
|
||||
|
||||
def test_store_packet_dict_skips_disallowed_channel(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
mesh.channels._reset_channel_cache()
|
||||
mesh.config.MODEM_PRESET = None
|
||||
|
||||
class DummyInterface:
|
||||
def __init__(self) -> None:
|
||||
self.localNode = SimpleNamespace(
|
||||
channels=[
|
||||
SimpleNamespace(
|
||||
role=1,
|
||||
settings=SimpleNamespace(name="Primary"),
|
||||
),
|
||||
SimpleNamespace(
|
||||
role=2,
|
||||
index=5,
|
||||
settings=SimpleNamespace(name="Chat"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def waitForConfig(self):
|
||||
return None
|
||||
|
||||
mesh.channels.capture_from_interface(DummyInterface())
|
||||
capsys.readouterr()
|
||||
|
||||
captured: list[tuple[str, dict, int]] = []
|
||||
ignored: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh.handlers,
|
||||
"_record_ignored_packet",
|
||||
lambda packet, *, reason: ignored.append(reason),
|
||||
)
|
||||
|
||||
previous_debug = mesh.config.DEBUG
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
previous_hidden = mesh.HIDDEN_CHANNELS
|
||||
mesh.config.DEBUG = True
|
||||
mesh.DEBUG = True
|
||||
mesh.ALLOWED_CHANNELS = ("Primary",)
|
||||
mesh.HIDDEN_CHANNELS = ()
|
||||
|
||||
try:
|
||||
packet = {
|
||||
"id": "1001",
|
||||
"rxTime": 25_680,
|
||||
"from": "!sender",
|
||||
"to": "^all",
|
||||
"channel": 5,
|
||||
"decoded": {"text": "disallowed msg", "portnum": 1},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured == []
|
||||
assert ignored == ["disallowed-channel"]
|
||||
assert "Ignored packet on disallowed channel" in capsys.readouterr().out
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
mesh.HIDDEN_CHANNELS = previous_hidden
|
||||
mesh.config.DEBUG = previous_debug
|
||||
mesh.DEBUG = previous_debug
|
||||
@@ -2744,62 +2533,6 @@ def test_parse_ble_target_rejects_invalid_values(mesh_module):
|
||||
assert mesh._parse_ble_target("zz:zz:zz:zz:zz:zz") is None
|
||||
|
||||
|
||||
def test_parse_ble_target_accepts_mac_addresses(mesh_module):
|
||||
"""Test that _parse_ble_target accepts valid MAC address format (Linux/Windows)."""
|
||||
mesh = mesh_module
|
||||
|
||||
# Valid MAC addresses should be accepted and normalized to uppercase
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF:60") == "ED:4D:9E:95:CF:60"
|
||||
assert mesh._parse_ble_target("ed:4d:9e:95:cf:60") == "ED:4D:9E:95:CF:60"
|
||||
assert mesh._parse_ble_target("AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF"
|
||||
assert mesh._parse_ble_target("00:11:22:33:44:55") == "00:11:22:33:44:55"
|
||||
|
||||
# With whitespace
|
||||
assert mesh._parse_ble_target(" ED:4D:9E:95:CF:60 ") == "ED:4D:9E:95:CF:60"
|
||||
|
||||
# Invalid MAC addresses should be rejected
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF") is None # Too short
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF:60:AB") is None # Too long
|
||||
assert mesh._parse_ble_target("GG:HH:II:JJ:KK:LL") is None # Invalid hex
|
||||
|
||||
|
||||
def test_parse_ble_target_accepts_uuids(mesh_module):
|
||||
"""Test that _parse_ble_target accepts valid UUID format (macOS)."""
|
||||
mesh = mesh_module
|
||||
|
||||
# Valid UUIDs should be accepted and normalized to uppercase
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
assert (
|
||||
mesh._parse_ble_target("c0aea92f-045e-9b82-c9a6-a1fd822b3a9e")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
assert (
|
||||
mesh._parse_ble_target("12345678-1234-5678-9ABC-DEF012345678")
|
||||
== "12345678-1234-5678-9ABC-DEF012345678"
|
||||
)
|
||||
|
||||
# With whitespace
|
||||
assert (
|
||||
mesh._parse_ble_target(" C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E ")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
|
||||
# Invalid UUIDs should be rejected
|
||||
assert mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6") is None # Too short
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E-EXTRA") is None
|
||||
) # Too long
|
||||
assert (
|
||||
mesh._parse_ble_target("GGGGGGGG-GGGG-GGGG-GGGG-GGGGGGGGGGGG") is None
|
||||
) # Invalid hex
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F:045E:9B82:C9A6:A1FD822B3A9E") is None
|
||||
) # Wrong separator
|
||||
|
||||
|
||||
def test_parse_network_target_additional_cases(mesh_module):
|
||||
mesh = mesh_module
|
||||
|
||||
|
||||
@@ -110,20 +110,11 @@ module PotatoMesh
|
||||
["!#{canonical_hex}", parsed, short_id]
|
||||
end
|
||||
|
||||
def broadcast_node_ref?(node_ref, fallback_num = nil)
|
||||
return true if fallback_num == 0xFFFFFFFF
|
||||
trimmed = string_or_nil(node_ref)
|
||||
return false unless trimmed
|
||||
normalized = trimmed.delete_prefix("!").strip.downcase
|
||||
normalized == "ffffffff"
|
||||
end
|
||||
|
||||
def ensure_unknown_node(db, node_ref, fallback_num = nil, heard_time: nil)
|
||||
parts = canonical_node_parts(node_ref, fallback_num)
|
||||
return unless parts
|
||||
|
||||
node_id, node_num, short_id = parts
|
||||
return if broadcast_node_ref?(node_id, node_num)
|
||||
|
||||
existing = db.get_first_value(
|
||||
"SELECT 1 FROM nodes WHERE node_id = ? LIMIT 1",
|
||||
@@ -167,10 +158,7 @@ module PotatoMesh
|
||||
node_id = nil
|
||||
|
||||
parts = canonical_node_parts(node_ref, fallback_num)
|
||||
if parts
|
||||
node_id, node_num = parts
|
||||
return if broadcast_node_ref?(node_id, node_num)
|
||||
end
|
||||
node_id, = parts if parts
|
||||
|
||||
unless node_id
|
||||
trimmed = string_or_nil(node_ref)
|
||||
@@ -182,7 +170,6 @@ module PotatoMesh
|
||||
end
|
||||
end
|
||||
|
||||
return if broadcast_node_ref?(node_id, fallback_num)
|
||||
return unless node_id
|
||||
|
||||
updated = false
|
||||
@@ -1440,17 +1427,6 @@ module PotatoMesh
|
||||
source: :message,
|
||||
)
|
||||
|
||||
ensure_unknown_node(db, to_id || raw_to_id, message["to_num"], heard_time: rx_time) if to_id || raw_to_id
|
||||
if to_id || raw_to_id || message.key?("to_num")
|
||||
touch_node_last_seen(
|
||||
db,
|
||||
to_id || raw_to_id || message["to_num"],
|
||||
message["to_num"],
|
||||
rx_time: rx_time,
|
||||
source: :message,
|
||||
)
|
||||
end
|
||||
|
||||
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"])
|
||||
|
||||
@@ -116,17 +116,6 @@ module PotatoMesh
|
||||
coerced
|
||||
end
|
||||
|
||||
# Normalise a caller-supplied timestamp for API pagination windows.
|
||||
#
|
||||
# @param since [Object] requested lower bound expressed as seconds since the epoch.
|
||||
# @param floor [Integer] minimum allowable timestamp used to clamp the value.
|
||||
# @return [Integer] non-negative timestamp greater than or equal to +floor+.
|
||||
def normalize_since_threshold(since, floor: 0)
|
||||
threshold = coerce_integer(since)
|
||||
threshold = 0 if threshold.nil? || threshold.negative?
|
||||
[threshold, floor].max
|
||||
end
|
||||
|
||||
def node_reference_tokens(node_ref)
|
||||
parts = canonical_node_parts(node_ref)
|
||||
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
|
||||
@@ -209,20 +198,12 @@ module PotatoMesh
|
||||
["(#{clauses.join(" OR ")})", params]
|
||||
end
|
||||
|
||||
# Fetch node state optionally scoped by identifier and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to narrow results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
|
||||
# @return [Array<Hash>] compacted node rows suitable for API responses.
|
||||
def query_nodes(limit, node_ref: nil, since: 0)
|
||||
def query_nodes(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_last_heard = now - PotatoMesh::Config.week_seconds
|
||||
since_floor = node_ref ? 0 : min_last_heard
|
||||
since_threshold = normalize_since_threshold(since, floor: since_floor)
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
@@ -233,7 +214,7 @@ module PotatoMesh
|
||||
params.concat(clause.last)
|
||||
else
|
||||
where_clauses << "last_heard >= ?"
|
||||
params << since_threshold
|
||||
params << min_last_heard
|
||||
end
|
||||
|
||||
if private_mode?
|
||||
@@ -261,7 +242,7 @@ module PotatoMesh
|
||||
.map { |value| coerce_integer(value) }
|
||||
.compact
|
||||
.max
|
||||
last_candidate && last_candidate >= since_threshold
|
||||
last_candidate && last_candidate >= min_last_heard
|
||||
end
|
||||
rows.each do |r|
|
||||
r["role"] ||= "CLIENT"
|
||||
@@ -281,18 +262,12 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
# Fetch ingestor heartbeats with optional freshness filtering.
|
||||
#
|
||||
# @param limit [Integer] maximum number of ingestors to return.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
|
||||
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
|
||||
def query_ingestors(limit, since: 0)
|
||||
def query_ingestors(limit)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
cutoff = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: cutoff)
|
||||
sql = <<~SQL
|
||||
SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset
|
||||
FROM ingestors
|
||||
@@ -301,7 +276,7 @@ module PotatoMesh
|
||||
LIMIT ?
|
||||
SQL
|
||||
|
||||
rows = db.execute(sql, [since_threshold, limit])
|
||||
rows = db.execute(sql, [cutoff, limit])
|
||||
rows.each do |row|
|
||||
row.delete_if { |key, _| key.is_a?(Integer) }
|
||||
start_time = coerce_integer(row["start_time"])
|
||||
@@ -331,7 +306,8 @@ module PotatoMesh
|
||||
# @return [Array<Hash>] compacted message rows safe for API responses.
|
||||
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
since_threshold = normalize_since_threshold(since, floor: 0)
|
||||
since_threshold = coerce_integer(since)
|
||||
since_threshold = 0 if since_threshold.nil? || since_threshold.negative?
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
@@ -409,13 +385,7 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
# Fetch positions optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted position rows suitable for API responses.
|
||||
def query_positions(limit, node_ref: nil, since: 0)
|
||||
def query_positions(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -423,10 +393,8 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_floor = node_ref ? 0 : min_rx_time
|
||||
since_threshold = normalize_since_threshold(since, floor: since_floor)
|
||||
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
|
||||
params << since_threshold
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -468,13 +436,7 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
# Fetch neighbor relationships optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
|
||||
# @return [Array<Hash>] compacted neighbor rows suitable for API responses.
|
||||
def query_neighbors(limit, node_ref: nil, since: 0)
|
||||
def query_neighbors(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -482,10 +444,8 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_floor = node_ref ? 0 : min_rx_time
|
||||
since_threshold = normalize_since_threshold(since, floor: since_floor)
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << since_threshold
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
|
||||
@@ -516,13 +476,7 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
# Fetch telemetry packets optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
|
||||
# @return [Array<Hash>] compacted telemetry rows suitable for API responses.
|
||||
def query_telemetry(limit, node_ref: nil, since: 0)
|
||||
def query_telemetry(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -530,10 +484,8 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_floor = node_ref ? 0 : min_rx_time
|
||||
since_threshold = normalize_since_threshold(since, floor: since_floor)
|
||||
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
|
||||
params << since_threshold
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -603,13 +555,7 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
# Aggregate telemetry metrics into time buckets.
|
||||
#
|
||||
# @param window_seconds [Integer] duration expressed in seconds to include in the query.
|
||||
# @param bucket_seconds [Integer] size of each aggregation bucket in seconds.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the requested window.
|
||||
# @return [Array<Hash>] aggregated telemetry metrics grouped by bucket start time.
|
||||
def query_telemetry_buckets(window_seconds:, bucket_seconds:, since: 0)
|
||||
def query_telemetry_buckets(window_seconds:, bucket_seconds:)
|
||||
window = coerce_integer(window_seconds) || DEFAULT_TELEMETRY_WINDOW_SECONDS
|
||||
window = DEFAULT_TELEMETRY_WINDOW_SECONDS if window <= 0
|
||||
bucket = coerce_integer(bucket_seconds) || DEFAULT_TELEMETRY_BUCKET_SECONDS
|
||||
@@ -619,7 +565,6 @@ module PotatoMesh
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_timestamp = now - window
|
||||
since_threshold = normalize_since_threshold(since, floor: min_timestamp)
|
||||
bucket_expression = "((COALESCE(rx_time, telemetry_time) / ?) * ?)"
|
||||
select_clauses = [
|
||||
"#{bucket_expression} AS bucket_start",
|
||||
@@ -645,7 +590,7 @@ module PotatoMesh
|
||||
ORDER BY bucket_start ASC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params = [bucket, bucket, since_threshold, MAX_QUERY_LIMIT]
|
||||
params = [bucket, bucket, min_timestamp, MAX_QUERY_LIMIT]
|
||||
rows = db.execute(sql, params)
|
||||
rows.map do |row|
|
||||
bucket_start = coerce_integer(row["bucket_start"])
|
||||
@@ -725,23 +670,16 @@ module PotatoMesh
|
||||
column
|
||||
end
|
||||
|
||||
# Fetch trace records optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted trace rows suitable for API responses.
|
||||
def query_traces(limit, node_ref: nil, since: 0)
|
||||
def query_traces(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.trace_neighbor_window_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << since_threshold
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
tokens = node_reference_tokens(node_ref)
|
||||
|
||||
@@ -64,7 +64,7 @@ module PotatoMesh
|
||||
app.get "/api/nodes" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_nodes(limit, since: params["since"]).to_json
|
||||
query_nodes(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/nodes/:id" do
|
||||
@@ -72,7 +72,7 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
rows = query_nodes(limit, node_ref: node_ref, since: params["since"])
|
||||
rows = query_nodes(limit, node_ref: node_ref)
|
||||
halt 404, { error: "not found" }.to_json if rows.empty?
|
||||
rows.first.to_json
|
||||
end
|
||||
@@ -80,7 +80,7 @@ module PotatoMesh
|
||||
app.get "/api/ingestors" do
|
||||
content_type :json
|
||||
limit = coerce_query_limit(params["limit"])
|
||||
query_ingestors(limit, since: params["since"]).to_json
|
||||
query_ingestors(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/messages" do
|
||||
@@ -111,7 +111,7 @@ module PotatoMesh
|
||||
app.get "/api/positions" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit, since: params["since"]).to_json
|
||||
query_positions(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/positions/:id" do
|
||||
@@ -119,13 +119,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
query_positions(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit, since: params["since"]).to_json
|
||||
query_neighbors(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors/:id" do
|
||||
@@ -133,13 +133,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
query_neighbors(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit, since: params["since"]).to_json
|
||||
query_telemetry(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/aggregated" do
|
||||
@@ -170,11 +170,7 @@ module PotatoMesh
|
||||
halt 400, { error: "bucketSeconds too small for requested window" }.to_json
|
||||
end
|
||||
|
||||
query_telemetry_buckets(
|
||||
window_seconds: window_seconds,
|
||||
bucket_seconds: bucket_seconds,
|
||||
since: params["since"],
|
||||
).to_json
|
||||
query_telemetry_buckets(window_seconds: window_seconds, bucket_seconds: bucket_seconds).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/:id" do
|
||||
@@ -182,13 +178,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
query_telemetry(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/traces" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_traces(limit, since: params["since"]).to_json
|
||||
query_traces(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/traces/:id" do
|
||||
@@ -196,7 +192,7 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_traces(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
query_traces(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/instances" do
|
||||
|
||||
@@ -42,7 +42,6 @@ module PotatoMesh
|
||||
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY = 128
|
||||
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS = 120
|
||||
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
|
||||
DEFAULT_FEDERATION_SEED_DOMAINS = %w[potatomesh.net potatomesh.jmrp.io mesh.qrp.ro].freeze
|
||||
|
||||
# Retrieve the configured API token used for authenticated requests.
|
||||
#
|
||||
@@ -158,13 +157,6 @@ module PotatoMesh
|
||||
7 * 24 * 60 * 60
|
||||
end
|
||||
|
||||
# Rolling retention window in seconds for trace and neighbor API queries.
|
||||
#
|
||||
# @return [Integer] seconds in twenty-eight days.
|
||||
def trace_neighbor_window_seconds
|
||||
28 * 24 * 60 * 60
|
||||
end
|
||||
|
||||
# Default upper bound for accepted JSON payload sizes.
|
||||
#
|
||||
# @return [Integer] byte ceiling for HTTP request bodies.
|
||||
@@ -183,7 +175,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] semantic version identifier.
|
||||
def version_fallback
|
||||
"0.5.9"
|
||||
"0.5.8"
|
||||
end
|
||||
|
||||
# Default refresh interval for frontend polling routines.
|
||||
@@ -417,7 +409,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [Array<String>] list of default seed domains.
|
||||
def federation_seed_domains
|
||||
DEFAULT_FEDERATION_SEED_DOMAINS
|
||||
["potatomesh.net"].freeze
|
||||
end
|
||||
|
||||
# Determine how often we broadcast federation announcements.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.9",
|
||||
"version": "0.5.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.9",
|
||||
"version": "0.5.8",
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.9",
|
||||
"version": "0.5.8",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -113,9 +113,11 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
|
||||
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
|
||||
});
|
||||
|
||||
test('buildChatTabModel skips channel buckets when there are no messages', () => {
|
||||
test('buildChatTabModel always includes channel zero bucket', () => {
|
||||
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
|
||||
assert.equal(model.channels.length, 0);
|
||||
assert.equal(model.channels.length, 1);
|
||||
assert.equal(model.channels[0].index, 0);
|
||||
assert.equal(model.channels[0].entries.length, 0);
|
||||
});
|
||||
|
||||
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveLegendVisibility } from '../map-legend-visibility.js';
|
||||
|
||||
test('resolveLegendVisibility hides when a default collapse is requested', () => {
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: true, mediaQueryMatches: false }), false);
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: true, mediaQueryMatches: true }), false);
|
||||
});
|
||||
|
||||
test('resolveLegendVisibility hides for dashboard and map views', () => {
|
||||
assert.equal(
|
||||
resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false, viewMode: 'dashboard' }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false, viewMode: 'map' }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLegendVisibility follows the media query when not forced', () => {
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false }), true);
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: true }), false);
|
||||
});
|
||||
@@ -1,455 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { __test__, initializeMobileMenu } from '../mobile-menu.js';
|
||||
|
||||
const { createMobileMenuController, resolveFocusableElements } = __test__;
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(...names) {
|
||||
names.forEach(name => values.add(name));
|
||||
},
|
||||
remove(...names) {
|
||||
names.forEach(name => values.delete(name));
|
||||
},
|
||||
contains(name) {
|
||||
return values.has(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createElement(tagName = 'div', initialId = '') {
|
||||
const listeners = new Map();
|
||||
const attributes = new Map();
|
||||
if (initialId) {
|
||||
attributes.set('id', String(initialId));
|
||||
}
|
||||
return {
|
||||
tagName: tagName.toUpperCase(),
|
||||
attributes,
|
||||
classList: createClassList(),
|
||||
dataset: {},
|
||||
hidden: false,
|
||||
parentNode: null,
|
||||
nextSibling: null,
|
||||
setAttribute(name, value) {
|
||||
attributes.set(name, String(value));
|
||||
},
|
||||
getAttribute(name) {
|
||||
return attributes.has(name) ? attributes.get(name) : null;
|
||||
},
|
||||
addEventListener(event, handler) {
|
||||
listeners.set(event, handler);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
const key = typeof event === 'string' ? event : event?.type;
|
||||
const handler = listeners.get(key);
|
||||
if (handler) {
|
||||
handler(event);
|
||||
}
|
||||
},
|
||||
appendChild(node) {
|
||||
this.lastAppended = node;
|
||||
return node;
|
||||
},
|
||||
insertBefore(node, nextSibling) {
|
||||
this.lastInserted = { node, nextSibling };
|
||||
return node;
|
||||
},
|
||||
focus() {
|
||||
globalThis.document.activeElement = this;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDomStub() {
|
||||
const originalDocument = globalThis.document;
|
||||
const registry = new Map();
|
||||
const documentStub = {
|
||||
body: createElement('body'),
|
||||
activeElement: null,
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
getElementById(id) {
|
||||
return registry.get(id) || null;
|
||||
}
|
||||
};
|
||||
globalThis.document = documentStub;
|
||||
return {
|
||||
documentStub,
|
||||
registry,
|
||||
cleanup() {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createWindowStub(matches = true) {
|
||||
const listeners = new Map();
|
||||
const mediaListeners = new Map();
|
||||
return {
|
||||
matchMedia() {
|
||||
return {
|
||||
matches,
|
||||
addEventListener(event, handler) {
|
||||
mediaListeners.set(event, handler);
|
||||
}
|
||||
};
|
||||
},
|
||||
addEventListener(event, handler) {
|
||||
listeners.set(event, handler);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
const key = typeof event === 'string' ? event : event?.type;
|
||||
const handler = listeners.get(key);
|
||||
if (handler) {
|
||||
handler(event);
|
||||
}
|
||||
},
|
||||
dispatchMediaChange() {
|
||||
const handler = mediaListeners.get('change');
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createWindowStubWithListener(matches = true) {
|
||||
const listeners = new Map();
|
||||
let mediaHandler = null;
|
||||
return {
|
||||
matchMedia() {
|
||||
return {
|
||||
matches,
|
||||
addListener(handler) {
|
||||
mediaHandler = handler;
|
||||
}
|
||||
};
|
||||
},
|
||||
addEventListener(event, handler) {
|
||||
listeners.set(event, handler);
|
||||
},
|
||||
dispatchMediaChange() {
|
||||
if (mediaHandler) {
|
||||
mediaHandler();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('mobile menu toggles open state and aria-expanded', () => {
|
||||
const { documentStub, registry, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStub(true);
|
||||
|
||||
const menuToggle = createElement('button');
|
||||
const menu = createElement('div');
|
||||
const menuPanel = createElement('div');
|
||||
const closeButton = createElement('button');
|
||||
const navLink = createElement('a');
|
||||
|
||||
menu.hidden = true;
|
||||
menuPanel.classList.add('mobile-menu__panel');
|
||||
|
||||
menu.querySelector = selector => {
|
||||
if (selector === '.mobile-menu__panel') return menuPanel;
|
||||
return null;
|
||||
};
|
||||
menu.querySelectorAll = selector => {
|
||||
if (selector === '[data-mobile-menu-close]') return [closeButton];
|
||||
if (selector === 'a') return [navLink];
|
||||
return [];
|
||||
};
|
||||
menuPanel.querySelectorAll = () => [closeButton, navLink];
|
||||
|
||||
registry.set('mobileMenuToggle', menuToggle);
|
||||
registry.set('mobileMenu', menu);
|
||||
|
||||
try {
|
||||
const controller = createMobileMenuController({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
|
||||
controller.initialize();
|
||||
windowStub.dispatchMediaChange();
|
||||
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
assert.equal(menu.hidden, false);
|
||||
assert.equal(menuToggle.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(documentStub.body.classList.contains('menu-open'), true);
|
||||
|
||||
navLink.dispatchEvent({ type: 'click' });
|
||||
assert.equal(menu.hidden, true);
|
||||
|
||||
closeButton.dispatchEvent({ type: 'click' });
|
||||
assert.equal(menu.hidden, true);
|
||||
assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile menu closes on escape and route changes', () => {
|
||||
const { documentStub, registry, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStub(true);
|
||||
|
||||
const menuToggle = createElement('button');
|
||||
const menu = createElement('div');
|
||||
const menuPanel = createElement('div');
|
||||
const closeButton = createElement('button');
|
||||
|
||||
menu.hidden = true;
|
||||
menuPanel.classList.add('mobile-menu__panel');
|
||||
|
||||
menu.querySelector = selector => {
|
||||
if (selector === '.mobile-menu__panel') return menuPanel;
|
||||
return null;
|
||||
};
|
||||
menu.querySelectorAll = selector => {
|
||||
if (selector === '[data-mobile-menu-close]') return [closeButton];
|
||||
return [];
|
||||
};
|
||||
menuPanel.querySelectorAll = () => [closeButton];
|
||||
|
||||
registry.set('mobileMenuToggle', menuToggle);
|
||||
registry.set('mobileMenu', menu);
|
||||
|
||||
try {
|
||||
const controller = createMobileMenuController({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
|
||||
controller.initialize();
|
||||
|
||||
menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
|
||||
assert.equal(menu.hidden, true);
|
||||
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
assert.equal(menu.hidden, false);
|
||||
|
||||
menuPanel.dispatchEvent({ type: 'keydown', key: 'ArrowDown' });
|
||||
assert.equal(menu.hidden, false);
|
||||
|
||||
menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
|
||||
assert.equal(menu.hidden, true);
|
||||
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
windowStub.dispatchEvent({ type: 'hashchange' });
|
||||
assert.equal(menu.hidden, true);
|
||||
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
windowStub.dispatchEvent({ type: 'popstate' });
|
||||
assert.equal(menu.hidden, true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile menu traps focus within the panel', () => {
|
||||
const { documentStub, registry, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStub(true);
|
||||
|
||||
const menuToggle = createElement('button');
|
||||
const menu = createElement('div');
|
||||
const menuPanel = createElement('div');
|
||||
const firstLink = createElement('a');
|
||||
const lastButton = createElement('button');
|
||||
|
||||
menuPanel.classList.add('mobile-menu__panel');
|
||||
menuPanel.querySelectorAll = () => [firstLink, lastButton];
|
||||
menu.querySelector = selector => {
|
||||
if (selector === '.mobile-menu__panel') return menuPanel;
|
||||
return null;
|
||||
};
|
||||
menu.querySelectorAll = () => [];
|
||||
|
||||
registry.set('mobileMenuToggle', menuToggle);
|
||||
registry.set('mobileMenu', menu);
|
||||
|
||||
try {
|
||||
const controller = createMobileMenuController({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
|
||||
controller.initialize();
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
|
||||
documentStub.activeElement = lastButton;
|
||||
menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: false });
|
||||
assert.equal(documentStub.activeElement, firstLink);
|
||||
|
||||
documentStub.activeElement = firstLink;
|
||||
menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: true });
|
||||
assert.equal(documentStub.activeElement, lastButton);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveFocusableElements filters out aria-hidden nodes', () => {
|
||||
const hiddenButton = createElement('button');
|
||||
hiddenButton.getAttribute = name => (name === 'aria-hidden' ? 'true' : null);
|
||||
const openLink = createElement('a');
|
||||
const bareNode = { tagName: 'DIV' };
|
||||
const container = {
|
||||
querySelectorAll() {
|
||||
return [hiddenButton, bareNode, openLink];
|
||||
}
|
||||
};
|
||||
|
||||
const focusables = resolveFocusableElements(container);
|
||||
assert.equal(focusables.length, 1);
|
||||
assert.equal(focusables[0], openLink);
|
||||
});
|
||||
|
||||
test('resolveFocusableElements handles empty containers', () => {
|
||||
assert.deepEqual(resolveFocusableElements(null), []);
|
||||
assert.deepEqual(resolveFocusableElements({}), []);
|
||||
});
|
||||
|
||||
test('mobile menu focuses the panel when no focusables exist', () => {
|
||||
const { documentStub, registry, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStub(true);
|
||||
|
||||
const menuToggle = createElement('button');
|
||||
const menu = createElement('div');
|
||||
const menuPanel = createElement('div');
|
||||
const lastActive = createElement('button');
|
||||
|
||||
menuPanel.classList.add('mobile-menu__panel');
|
||||
menuPanel.querySelectorAll = () => [];
|
||||
menu.querySelector = selector => {
|
||||
if (selector === '.mobile-menu__panel') return menuPanel;
|
||||
return null;
|
||||
};
|
||||
menu.querySelectorAll = () => [];
|
||||
|
||||
registry.set('mobileMenuToggle', menuToggle);
|
||||
registry.set('mobileMenu', menu);
|
||||
documentStub.activeElement = lastActive;
|
||||
|
||||
try {
|
||||
const controller = createMobileMenuController({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
|
||||
controller.initialize();
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
assert.equal(documentStub.activeElement, menuPanel);
|
||||
|
||||
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
|
||||
assert.equal(documentStub.activeElement, lastActive);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile menu registers legacy media query listeners', () => {
|
||||
const { documentStub, registry, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStubWithListener(true);
|
||||
|
||||
const menuToggle = createElement('button');
|
||||
const menu = createElement('div');
|
||||
const menuPanel = createElement('div');
|
||||
|
||||
menuPanel.classList.add('mobile-menu__panel');
|
||||
menu.querySelector = selector => {
|
||||
if (selector === '.mobile-menu__panel') return menuPanel;
|
||||
return null;
|
||||
};
|
||||
menu.querySelectorAll = () => [];
|
||||
|
||||
registry.set('mobileMenuToggle', menuToggle);
|
||||
registry.set('mobileMenu', menu);
|
||||
|
||||
try {
|
||||
const controller = createMobileMenuController({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
|
||||
controller.initialize();
|
||||
windowStub.dispatchMediaChange();
|
||||
assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile menu safely no-ops without required nodes', () => {
|
||||
const { documentStub, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStub(true);
|
||||
|
||||
try {
|
||||
const controller = createMobileMenuController({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
|
||||
controller.initialize();
|
||||
controller.openMenu();
|
||||
controller.closeMenu();
|
||||
controller.syncLayout();
|
||||
assert.equal(documentStub.body.classList.contains('menu-open'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('initializeMobileMenu returns a controller', () => {
|
||||
const { documentStub, registry, cleanup } = createDomStub();
|
||||
const windowStub = createWindowStub(true);
|
||||
|
||||
const menuToggle = createElement('button');
|
||||
const menu = createElement('div');
|
||||
const menuPanel = createElement('div');
|
||||
|
||||
menuPanel.classList.add('mobile-menu__panel');
|
||||
menu.querySelector = selector => {
|
||||
if (selector === '.mobile-menu__panel') return menuPanel;
|
||||
return null;
|
||||
};
|
||||
menu.querySelectorAll = () => [];
|
||||
|
||||
registry.set('mobileMenuToggle', menuToggle);
|
||||
registry.set('mobileMenu', menu);
|
||||
|
||||
try {
|
||||
const controller = initializeMobileMenu({
|
||||
documentObject: documentStub,
|
||||
windowObject: windowStub
|
||||
});
|
||||
assert.equal(typeof controller.openMenu, 'function');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
@@ -405,77 +405,6 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
assert.equal(html.includes('node-detail__chart-point'), true);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 120,
|
||||
device_metrics: {
|
||||
battery_level: 90,
|
||||
voltage: 7.2,
|
||||
current: 3.6,
|
||||
channel_utilization: 45,
|
||||
air_util_tx: 18,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 45,
|
||||
relative_humidity: 48,
|
||||
barometric_pressure: 1250,
|
||||
gas_resistance: 1200,
|
||||
iaq: 650,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderTelemetryCharts(node, { nowMs });
|
||||
assert.match(html, />7\.2<\/text>/);
|
||||
assert.match(html, />3\.6<\/text>/);
|
||||
assert.match(html, />45<\/text>/);
|
||||
assert.match(html, />650<\/text>/);
|
||||
assert.match(html, />1100<\/text>/);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 180,
|
||||
device_metrics: {
|
||||
battery_level: 70,
|
||||
voltage: 4.5,
|
||||
current: 1.5,
|
||||
channel_utilization: 35,
|
||||
air_util_tx: 15,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 25,
|
||||
relative_humidity: 50,
|
||||
barometric_pressure: 1015,
|
||||
gas_resistance: 1500,
|
||||
iaq: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderTelemetryCharts(node, { nowMs });
|
||||
assert.match(html, />6\.0<\/text>/);
|
||||
assert.match(html, />3\.0<\/text>/);
|
||||
assert.match(html, />40<\/text>/);
|
||||
assert.match(html, />500<\/text>/);
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
|
||||
const html = renderNodeDetailHtml(
|
||||
{
|
||||
@@ -946,19 +875,13 @@ test('initializeNodeDetailPage reports an error when refresh fails', async () =>
|
||||
throw new Error('boom');
|
||||
};
|
||||
const renderShortHtml = short => `<span>${short}</span>`;
|
||||
const originalError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const result = await initializeNodeDetailPage({
|
||||
document: documentStub,
|
||||
refreshImpl,
|
||||
renderShortHtml,
|
||||
});
|
||||
assert.equal(result, false);
|
||||
assert.equal(element.innerHTML.includes('Failed to load'), true);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
const result = await initializeNodeDetailPage({
|
||||
document: documentStub,
|
||||
refreshImpl,
|
||||
renderShortHtml,
|
||||
});
|
||||
assert.equal(result, false);
|
||||
assert.equal(element.innerHTML.includes('Failed to load'), true);
|
||||
});
|
||||
|
||||
test('initializeNodeDetailPage handles missing reference payloads', async () => {
|
||||
|
||||
@@ -65,8 +65,7 @@ function resolveSnapshotList(entry) {
|
||||
* Build a data model describing the content for chat tabs.
|
||||
*
|
||||
* Entries outside the recent activity window, encrypted messages, and
|
||||
* channels above {@link MAX_CHANNEL_INDEX} are filtered out. Channel
|
||||
* buckets are only created when messages are present for that channel.
|
||||
* channels above {@link MAX_CHANNEL_INDEX} are filtered out.
|
||||
*
|
||||
* @param {{
|
||||
* nodes?: Array<Object>,
|
||||
@@ -288,6 +287,26 @@ export function buildChatTabModel({
|
||||
|
||||
logEntries.sort((a, b) => a.ts - b.ts);
|
||||
|
||||
let hasPrimaryBucket = false;
|
||||
for (const bucket of channelBuckets.values()) {
|
||||
if (bucket.index === 0) {
|
||||
hasPrimaryBucket = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasPrimaryBucket) {
|
||||
const bucketKey = '0';
|
||||
channelBuckets.set(bucketKey, {
|
||||
key: bucketKey,
|
||||
id: buildChannelTabId(bucketKey),
|
||||
index: 0,
|
||||
label: '0',
|
||||
entries: [],
|
||||
labelPriority: CHANNEL_LABEL_PRIORITY.INDEX,
|
||||
isPrimaryFallback: true
|
||||
});
|
||||
}
|
||||
|
||||
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
|
||||
if (a.index !== b.index) {
|
||||
return a.index - b.index;
|
||||
|
||||
@@ -18,7 +18,6 @@ import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from
|
||||
import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
||||
import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
||||
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
|
||||
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
|
||||
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
|
||||
@@ -44,7 +43,6 @@ import {
|
||||
formatChatPresetTag
|
||||
} from './chat-format.js';
|
||||
import { initializeInstanceSelector } from './instance-selector.js';
|
||||
import { initializeMobileMenu } from './mobile-menu.js';
|
||||
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
|
||||
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
|
||||
import { renderChatTabs } from './chat-tabs.js';
|
||||
@@ -118,10 +116,7 @@ export function initializeApp(config) {
|
||||
: false;
|
||||
const isDashboardView = bodyClassList ? bodyClassList.contains('view-dashboard') : false;
|
||||
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
|
||||
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
|
||||
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
|
||||
|
||||
initializeMobileMenu({ documentObject: document, windowObject: window });
|
||||
/**
|
||||
* Column sorter configuration for the node table.
|
||||
*
|
||||
@@ -195,7 +190,7 @@ export function initializeApp(config) {
|
||||
});
|
||||
const NODE_LIMIT = 1000;
|
||||
const TRACE_LIMIT = 200;
|
||||
const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60;
|
||||
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
|
||||
const CHAT_LIMIT = MESSAGE_LIMIT;
|
||||
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
||||
@@ -440,7 +435,6 @@ export function initializeApp(config) {
|
||||
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
|
||||
const fullscreenContainer = mapPanel || mapContainer;
|
||||
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
|
||||
const legendDefaultCollapsed = mapPanel ? mapPanel.dataset.legendCollapsed === 'true' : false;
|
||||
let mapStatusEl = null;
|
||||
let map = null;
|
||||
let mapCenterLatLng = null;
|
||||
@@ -1532,14 +1526,8 @@ export function initializeApp(config) {
|
||||
legendToggleControl.addTo(map);
|
||||
|
||||
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
|
||||
const initialLegendVisible = resolveLegendVisibility({
|
||||
defaultCollapsed: legendDefaultCollapsed,
|
||||
mediaQueryMatches: legendMediaQuery.matches,
|
||||
viewMode: isDashboardView ? 'dashboard' : (isMapView ? 'map' : undefined)
|
||||
});
|
||||
setLegendVisibility(initialLegendVisible);
|
||||
setLegendVisibility(!legendMediaQuery.matches);
|
||||
legendMediaQuery.addEventListener('change', event => {
|
||||
if (legendDefaultCollapsed || isDashboardView || isMapView) return;
|
||||
setLegendVisibility(!event.matches);
|
||||
});
|
||||
} else if (mapContainer && !hasLeaflet) {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the initial visibility of the map legend.
|
||||
*
|
||||
* @param {{ defaultCollapsed: boolean, mediaQueryMatches: boolean, viewMode?: string }} options
|
||||
* @returns {boolean} True when the legend should be visible.
|
||||
*/
|
||||
export function resolveLegendVisibility({ defaultCollapsed, mediaQueryMatches, viewMode }) {
|
||||
if (defaultCollapsed || viewMode === 'dashboard' || viewMode === 'map') return false;
|
||||
return !mediaQueryMatches;
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const MOBILE_MENU_MEDIA_QUERY = '(max-width: 900px)';
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(', ');
|
||||
|
||||
/**
|
||||
* Collect the elements that can receive focus within a container.
|
||||
*
|
||||
* @param {?Element} container DOM node hosting focusable descendants.
|
||||
* @returns {Array<Element>} Ordered list of focusable elements.
|
||||
*/
|
||||
function resolveFocusableElements(container) {
|
||||
if (!container || typeof container.querySelectorAll !== 'function') {
|
||||
return [];
|
||||
}
|
||||
const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
|
||||
return candidates.filter(candidate => {
|
||||
if (!candidate || typeof candidate.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return candidate.getAttribute('aria-hidden') !== 'true';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a menu controller for handling toggle state, focus trapping, and
|
||||
* responsive layout swapping.
|
||||
*
|
||||
* @param {{
|
||||
* documentObject?: Document,
|
||||
* windowObject?: Window
|
||||
* }} [options]
|
||||
* @returns {{
|
||||
* initialize: () => void,
|
||||
* openMenu: () => void,
|
||||
* closeMenu: () => void,
|
||||
* syncLayout: () => void
|
||||
* }}
|
||||
*/
|
||||
function createMobileMenuController(options = {}) {
|
||||
const documentObject = options.documentObject || document;
|
||||
const windowObject = options.windowObject || window;
|
||||
const menuToggle = documentObject.getElementById('mobileMenuToggle');
|
||||
const menu = documentObject.getElementById('mobileMenu');
|
||||
const menuPanel = menu ? menu.querySelector('.mobile-menu__panel') : null;
|
||||
const closeTriggers = menu ? Array.from(menu.querySelectorAll('[data-mobile-menu-close]')) : [];
|
||||
const menuLinks = menu ? Array.from(menu.querySelectorAll('a')) : [];
|
||||
const body = documentObject.body;
|
||||
const mediaQuery = windowObject.matchMedia
|
||||
? windowObject.matchMedia(MOBILE_MENU_MEDIA_QUERY)
|
||||
: null;
|
||||
let isOpen = false;
|
||||
let lastActive = null;
|
||||
|
||||
/**
|
||||
* Toggle the ``aria-expanded`` state on the menu trigger.
|
||||
*
|
||||
* @param {boolean} expanded Whether the menu is open.
|
||||
* @returns {void}
|
||||
*/
|
||||
function setExpandedState(expanded) {
|
||||
if (!menuToggle || typeof menuToggle.setAttribute !== 'function') {
|
||||
return;
|
||||
}
|
||||
menuToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the meta row placement based on the active media query.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the slide-in menu and trap focus within the panel.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function openMenu() {
|
||||
if (!menu || !menuToggle || !menuPanel) {
|
||||
return;
|
||||
}
|
||||
syncLayout();
|
||||
menu.hidden = false;
|
||||
menu.classList.add('is-open');
|
||||
if (body && body.classList) {
|
||||
body.classList.add('menu-open');
|
||||
}
|
||||
setExpandedState(true);
|
||||
isOpen = true;
|
||||
lastActive = documentObject.activeElement || null;
|
||||
const focusables = resolveFocusableElements(menuPanel);
|
||||
const focusTarget = focusables[0] || menuPanel;
|
||||
if (focusTarget && typeof focusTarget.focus === 'function') {
|
||||
focusTarget.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the menu and restore focus to the trigger.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function closeMenu() {
|
||||
if (!menu || !menuToggle) {
|
||||
return;
|
||||
}
|
||||
menu.classList.remove('is-open');
|
||||
menu.hidden = true;
|
||||
if (body && body.classList) {
|
||||
body.classList.remove('menu-open');
|
||||
}
|
||||
setExpandedState(false);
|
||||
isOpen = false;
|
||||
if (lastActive && typeof lastActive.focus === 'function') {
|
||||
lastActive.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle open or closed based on the trigger interaction.
|
||||
*
|
||||
* @param {Event} event Click event originating from the trigger.
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleToggleClick(event) {
|
||||
if (event && typeof event.preventDefault === 'function') {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap tab focus within the menu panel while open.
|
||||
*
|
||||
* @param {KeyboardEvent} event Keydown event from the panel.
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleKeydown(event) {
|
||||
if (!isOpen || !event) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
const focusables = resolveFocusableElements(menuPanel);
|
||||
if (!focusables.length) {
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = documentObject.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the menu when navigation state changes.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleRouteChange() {
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners and sync initial layout.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function initialize() {
|
||||
if (!menuToggle || !menu) {
|
||||
return;
|
||||
}
|
||||
menuToggle.addEventListener('click', handleToggleClick);
|
||||
closeTriggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', closeMenu);
|
||||
});
|
||||
menuLinks.forEach(link => {
|
||||
link.addEventListener('click', closeMenu);
|
||||
});
|
||||
if (menuPanel && typeof menuPanel.addEventListener === 'function') {
|
||||
menuPanel.addEventListener('keydown', handleKeydown);
|
||||
}
|
||||
if (mediaQuery) {
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', syncLayout);
|
||||
} else if (typeof mediaQuery.addListener === 'function') {
|
||||
mediaQuery.addListener(syncLayout);
|
||||
}
|
||||
}
|
||||
if (windowObject && typeof windowObject.addEventListener === 'function') {
|
||||
windowObject.addEventListener('hashchange', handleRouteChange);
|
||||
windowObject.addEventListener('popstate', handleRouteChange);
|
||||
}
|
||||
syncLayout();
|
||||
setExpandedState(false);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
syncLayout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the mobile menu using the live DOM environment.
|
||||
*
|
||||
* @param {{
|
||||
* documentObject?: Document,
|
||||
* windowObject?: Window
|
||||
* }} [options]
|
||||
* @returns {{
|
||||
* initialize: () => void,
|
||||
* openMenu: () => void,
|
||||
* closeMenu: () => void,
|
||||
* syncLayout: () => void
|
||||
* }}
|
||||
*/
|
||||
export function initializeMobileMenu(options = {}) {
|
||||
const controller = createMobileMenuController(options);
|
||||
controller.initialize();
|
||||
return controller;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
createMobileMenuController,
|
||||
resolveFocusableElements,
|
||||
};
|
||||
@@ -68,7 +68,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 6,
|
||||
ticks: 3,
|
||||
color: '#9ebcda',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
@@ -78,7 +77,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 3,
|
||||
ticks: 3,
|
||||
color: '#3182bd',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -158,7 +156,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 40,
|
||||
ticks: 4,
|
||||
color: '#fc8d59',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'humidity',
|
||||
@@ -223,7 +220,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 500,
|
||||
ticks: 5,
|
||||
color: '#636363',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -1008,31 +1004,6 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) {
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective axis maximum when upper overflow is allowed.
|
||||
*
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Array<{axisId: string, points: Array<{timestamp: number, value: number}>}>} seriesEntries Series entries.
|
||||
* @returns {number} Effective axis max.
|
||||
*/
|
||||
function resolveAxisMax(axis, seriesEntries) {
|
||||
if (!axis || axis.allowUpperOverflow !== true) {
|
||||
return axis?.max;
|
||||
}
|
||||
let observedMax = null;
|
||||
for (const entry of seriesEntries) {
|
||||
if (!entry || entry.axisId !== axis.id || !Array.isArray(entry.points)) continue;
|
||||
for (const point of entry.points) {
|
||||
if (!point || !Number.isFinite(point.value)) continue;
|
||||
observedMax = observedMax == null ? point.value : Math.max(observedMax, point.value);
|
||||
}
|
||||
}
|
||||
if (observedMax != null && Number.isFinite(axis.max) && observedMax > axis.max) {
|
||||
return observedMax;
|
||||
}
|
||||
return axis.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a telemetry series as circles plus an optional translucent guide line.
|
||||
*
|
||||
@@ -1162,48 +1133,33 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
const domainEnd = nowMs;
|
||||
const domainStart = nowMs - windowMs;
|
||||
const dims = createChartDimensions(spec);
|
||||
const axisMap = new Map(spec.axes.map(axis => [axis.id, axis]));
|
||||
const seriesEntries = spec.series
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axis);
|
||||
if (!axis) return null;
|
||||
const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd);
|
||||
if (points.length === 0) return null;
|
||||
return { config: series, axisId: series.axis, points };
|
||||
return { config: series, axis, points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (seriesEntries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const adjustedAxes = spec.axes.map(axis => {
|
||||
const resolvedMax = resolveAxisMax(axis, seriesEntries);
|
||||
if (resolvedMax != null && resolvedMax !== axis.max) {
|
||||
return { ...axis, max: resolvedMax };
|
||||
}
|
||||
return axis;
|
||||
});
|
||||
const axisMap = new Map(adjustedAxes.map(axis => [axis.id, axis]));
|
||||
const plottedSeries = seriesEntries
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axisId);
|
||||
if (!axis) return null;
|
||||
return { config: series.config, axis, points: series.points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (plottedSeries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const axesMarkup = adjustedAxes.map(axis => renderYAxis(axis, dims)).join('');
|
||||
const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join('');
|
||||
const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
|
||||
const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
|
||||
const ticks = tickBuilder(nowMs, windowMs);
|
||||
const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
|
||||
|
||||
const seriesMarkup = plottedSeries
|
||||
const seriesMarkup = seriesEntries
|
||||
.map(series =>
|
||||
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
|
||||
lineReducer: chartOptions.lineReducer,
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const legendItems = plottedSeries
|
||||
const legendItems = seriesEntries
|
||||
.map(series => {
|
||||
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
|
||||
return `
|
||||
|
||||
@@ -215,214 +215,25 @@ h1 {
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.site-header__left,
|
||||
.site-header__right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-header__left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-header__right {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-title-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-title img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-nav__link:hover {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.site-nav__link.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-nav__link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu__backdrop {
|
||||
flex: 1 1 auto;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.mobile-menu__panel {
|
||||
width: min(320px, 86vw);
|
||||
background: var(--bg2);
|
||||
color: var(--fg);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 220ms ease;
|
||||
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.mobile-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-menu__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mobile-menu__close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.mobile-nav__link.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-nav__link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mobile-menu.is-open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobile-menu.is-open .mobile-menu__backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-menu.is-open .mobile-menu__panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.menu-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.section-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #555;
|
||||
margin-bottom: 12px;
|
||||
@@ -471,29 +282,11 @@ h1 {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.site-header__left {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.site-header__left--federation {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.instance-selector {
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.instance-selector,
|
||||
.instance-select {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -503,7 +296,6 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
@@ -1902,6 +1694,10 @@ input[type="radio"] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls--full-screen {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.controls .filter-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
+17
-279
@@ -1080,8 +1080,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
seed_domains = PotatoMesh::Config.federation_seed_domains.map(&:downcase)
|
||||
expect(targets.first(seed_domains.length)).to eq(seed_domains)
|
||||
expect(targets.first).to eq("potatomesh.net")
|
||||
expect(targets).to include("remote.mesh")
|
||||
expect(targets).not_to include("self.mesh")
|
||||
end
|
||||
@@ -1091,7 +1090,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(PotatoMesh::Config.federation_seed_domains.map(&:downcase))
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
end
|
||||
|
||||
it "ignores remote instances that have not updated within a week" do
|
||||
@@ -1119,7 +1118,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(PotatoMesh::Config.federation_seed_domains.map(&:downcase))
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3017,69 +3016,6 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores broadcast identifiers when creating placeholders" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
payload = {
|
||||
"id" => 10_002,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!ffffffff",
|
||||
"channel" => 0,
|
||||
"text" => "broadcast",
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = '!ffffffff'")
|
||||
expect(count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it "creates hidden nodes for unseen message participants" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
payload = {
|
||||
"id" => 10_001,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!cafef00d",
|
||||
"to_id" => "!deadbeef",
|
||||
"channel" => 0,
|
||||
"portnum" => "TEXT_MESSAGE_APP",
|
||||
"text" => "Spec participant placeholder",
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
rows = db.execute(
|
||||
<<~SQL,
|
||||
SELECT node_id, num, short_name, long_name, role, last_heard, first_heard
|
||||
FROM nodes
|
||||
WHERE node_id IN ("!cafef00d", "!deadbeef")
|
||||
ORDER BY node_id
|
||||
SQL
|
||||
)
|
||||
|
||||
expect(rows.map { |row| row["node_id"] }).to contain_exactly("!cafef00d", "!deadbeef")
|
||||
rows.each do |row|
|
||||
expect(row["num"]).to be_an(Integer)
|
||||
expect(row["role"]).to eq("CLIENT_HIDDEN")
|
||||
expect(row["short_name"]).to eq(row["node_id"][-4, 4].upcase)
|
||||
expect(row["long_name"]).to eq("Meshtastic #{row["short_name"]}")
|
||||
expect(row["last_heard"]).to eq(reference_time.to_i)
|
||||
expect(row["first_heard"]).to eq(reference_time.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "returns 400 when the payload is not valid JSON" do
|
||||
post "/api/messages", "{", auth_headers
|
||||
|
||||
@@ -4136,7 +4072,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "excludes nodes whose last activity is older than a week from collection queries" do
|
||||
it "excludes nodes whose last activity is older than a week" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
@@ -4162,9 +4098,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(ids).not_to include("!stale-node")
|
||||
|
||||
get "/api/nodes/!stale-node"
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload["node_id"]).to eq("!stale-node")
|
||||
expect(last_response.status).to eq(404)
|
||||
|
||||
get "/api/nodes/!fresh-node"
|
||||
expect(last_response).to be_ok
|
||||
@@ -4172,39 +4106,6 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(payload["node_id"]).to eq("!fresh-node")
|
||||
end
|
||||
|
||||
it "filters node results using the since parameter for collections and single lookups" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_last_heard = now - 120
|
||||
recent_last_heard = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!older-node", "old", "Older", "TBEAM", "CLIENT", 0.0, older_last_heard, older_last_heard],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!recent-node", "new", "Recent", "TBEAM", "CLIENT", 0.0, recent_last_heard, recent_last_heard],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/nodes?since=#{recent_last_heard}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["node_id"] }).to eq(["!recent-node"])
|
||||
|
||||
get "/api/nodes/!older-node?since=#{recent_last_heard}"
|
||||
expect(last_response.status).to eq(404)
|
||||
|
||||
get "/api/nodes/!recent-node?since=#{recent_last_heard}"
|
||||
expect(last_response).to be_ok
|
||||
detail = JSON.parse(last_response.body)
|
||||
expect(detail["node_id"]).to eq("!recent-node")
|
||||
end
|
||||
|
||||
it "omits blank values from node responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4534,7 +4435,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(entry["payload_b64"]).to eq("AQI=")
|
||||
end
|
||||
|
||||
it "excludes position entries older than seven days from collection queries" do
|
||||
it "excludes position entries older than seven days" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
@@ -4563,38 +4464,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
|
||||
end
|
||||
|
||||
it "filters positions using the since parameter for both global and node queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 180
|
||||
recent_rx = now - 15
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[10, "!pos-since", 101, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 52.0, 13.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[11, "!pos-since", 101, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 53.0, 14.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/positions?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([11])
|
||||
|
||||
get "/api/positions/!pos-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([11])
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
it "omits blank values from position responses" do
|
||||
@@ -4648,11 +4518,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
|
||||
describe "GET /api/neighbors" do
|
||||
it "excludes neighbor records older than twenty-eight days from collection queries" do
|
||||
it "excludes neighbor records older than seven days" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
stale_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds + 45)
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 45)
|
||||
fresh_rx = now - 10
|
||||
|
||||
with_db do |db|
|
||||
@@ -4690,51 +4560,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.length).to eq(2)
|
||||
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new", "!neighbor-old"])
|
||||
end
|
||||
|
||||
it "honours the since parameter for neighbor queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!origin-since", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!origin-since", "!neighbor-old", 1.5, older_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!origin-since", "!neighbor-new", 7.5, recent_rx],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/neighbors?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
|
||||
|
||||
get "/api/neighbors/!origin-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
|
||||
expect(filtered.length).to eq(1)
|
||||
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
|
||||
expect(filtered.first["rx_time"]).to eq(fresh_rx)
|
||||
end
|
||||
|
||||
it "omits blank values from neighbor responses" do
|
||||
@@ -4835,7 +4663,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect_same_value(second_entry["soil_temperature"], telemetry_metric(second_latest, "soil_temperature"))
|
||||
end
|
||||
|
||||
it "excludes telemetry entries older than seven days from collection queries" do
|
||||
it "excludes telemetry entries older than seven days" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
@@ -4864,38 +4692,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
|
||||
end
|
||||
|
||||
it "filters telemetry rows using the since parameter for both global and node-scoped queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 60
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[10, "!tele-since", 21, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 20.0, 3.9],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[11, "!tele-since", 21, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 80.0, 4.1],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([11])
|
||||
|
||||
get "/api/telemetry/!tele-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([11])
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
it "omits blank values from telemetry responses" do
|
||||
@@ -5061,34 +4858,6 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(buckets.first["bucket_seconds"]).to eq(PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS)
|
||||
end
|
||||
|
||||
it "filters aggregated telemetry buckets using the since parameter" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 1800
|
||||
recent_rx = now - 120
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
|
||||
[801, "!agg-since", older_rx, Time.at(older_rx).utc.iso8601, older_rx - 30, 30.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
|
||||
[802, "!agg-since", recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 30, 80.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300&since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
buckets = JSON.parse(last_response.body)
|
||||
expect(buckets.length).to eq(1)
|
||||
aggregates = buckets.first.fetch("aggregates")
|
||||
expect(aggregates).to have_key("battery_level")
|
||||
expect_same_value(aggregates.dig("battery_level", "avg"), 80.0)
|
||||
end
|
||||
|
||||
it "omits zero-valued battery and voltage aggregates" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -5237,11 +5006,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(JSON.parse(last_response.body)).to eq([])
|
||||
end
|
||||
|
||||
it "excludes traces older than twenty-eight days" do
|
||||
it "excludes traces older than one week" do
|
||||
clear_database
|
||||
now = Time.now.to_i
|
||||
recent_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds / 2)
|
||||
stale_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds + 60)
|
||||
recent_rx = now - (PotatoMesh::Config.week_seconds / 2)
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 60)
|
||||
payload = [
|
||||
{ "id" => 50_001, "src" => 1, "dest" => 2, "rx_time" => recent_rx, "metrics" => {} },
|
||||
{ "id" => 50_002, "src" => 3, "dest" => 4, "rx_time" => stale_rx, "metrics" => {} },
|
||||
@@ -5256,37 +5025,6 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
ids = JSON.parse(last_response.body).map { |row| row["id"] }
|
||||
expect(ids).to eq([50_001])
|
||||
end
|
||||
|
||||
it "filters traces using the since parameter for collection and scoped requests" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 25
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
|
||||
[60_001, 123, 456, older_rx, Time.at(older_rx).utc.iso8601],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
|
||||
[60_002, 123, 456, recent_rx, Time.at(recent_rx).utc.iso8601],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/traces?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([60_002])
|
||||
|
||||
get "/api/traces/123?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
scoped = JSON.parse(last_response.body)
|
||||
expect(scoped.map { |row| row["id"] }).to eq([60_002])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /nodes/:id" do
|
||||
|
||||
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
node_id: "!abc12345",
|
||||
start_time: now - 120,
|
||||
last_seen_time: now - 60,
|
||||
version: "0.5.9",
|
||||
version: "0.5.8",
|
||||
lora_freq: 915,
|
||||
modem_preset: "LongFast",
|
||||
}.merge(overrides)
|
||||
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!fresh000", now - 100, now - 10, "0.5.9"],
|
||||
["!fresh000", now - 100, now - 10, "0.5.8"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
@@ -141,7 +141,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, lora_freq, modem_preset) VALUES(?,?,?,?,?,?)",
|
||||
["!rich000", now - 200, now - 100, "0.5.9", 915, "MediumFast"],
|
||||
["!rich000", now - 200, now - 100, "0.5.8", 915, "MediumFast"],
|
||||
)
|
||||
end
|
||||
|
||||
@@ -159,30 +159,6 @@ RSpec.describe "Ingestor endpoints" do
|
||||
expect(rich["start_time_iso"]).to be_a(String)
|
||||
expect(rich["last_seen_iso"]).to be_a(String)
|
||||
end
|
||||
|
||||
it "filters ingestors using the since parameter" do
|
||||
frozen_time = Time.at(1_700_000_000)
|
||||
allow(Time).to receive(:now).and_return(frozen_time)
|
||||
now = frozen_time.to_i
|
||||
recent_cutoff = now - 120
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!old-ingestor", now - 600, now - 300, "0.5.5"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!new-ingestor", now - 60, now - 30, "0.5.9"],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/ingestors?since=#{recent_cutoff}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |entry| entry["node_id"] }).to eq(["!new-ingestor"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "schema migrations" do
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<% unless private_mode %>
|
||||
<%= erb :"shared/_chat_panel", locals: { full_screen: false } %>
|
||||
<% end %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false, legend_collapsed: true } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false } %>
|
||||
</div>
|
||||
|
||||
<%= erb :"shared/_nodes_table", locals: { full_screen: false } %>
|
||||
|
||||
+16
-64
@@ -75,19 +75,16 @@
|
||||
main_classes = ["page-main"]
|
||||
main_classes << "page-main--dashboard" if view_mode == :dashboard
|
||||
main_classes << "page-main--full-screen" if full_screen_view
|
||||
show_header = true
|
||||
show_header = !full_screen_view
|
||||
show_meta_info = true
|
||||
show_auto_refresh_controls = view_mode != :federation
|
||||
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
|
||||
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
|
||||
show_info_button = true
|
||||
show_info_button = !full_screen_view
|
||||
show_footer = !full_screen_view
|
||||
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
|
||||
show_auto_refresh_toggle = show_auto_refresh_controls
|
||||
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
|
||||
nodes_nav_href = "/nodes"
|
||||
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
|
||||
federation_nav_enabled = !private_mode && federation_enabled
|
||||
controls_classes = ["controls"]
|
||||
controls_classes << "controls--full-screen" if full_screen_view
|
||||
refresh_row_classes = ["refresh-row"]
|
||||
@@ -104,69 +101,24 @@
|
||||
<div class="<%= shell_classes.join(" ") %>">
|
||||
<% if show_header %>
|
||||
<header class="site-header">
|
||||
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
|
||||
<h1 class="site-title">
|
||||
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
|
||||
<span class="site-title-text"><%= site_name %></span>
|
||||
</h1>
|
||||
<% if federation_nav_enabled %>
|
||||
<div class="header-federation">
|
||||
<div class="instance-selector">
|
||||
<label class="visually-hidden" for="instanceSelect">Select a region</label>
|
||||
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
|
||||
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
|
||||
</select>
|
||||
</div>
|
||||
<h1 class="site-title">
|
||||
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
|
||||
<span class="site-title-text"><%= site_name %></span>
|
||||
</h1>
|
||||
<% if !private_mode && federation_enabled %>
|
||||
<div class="header-federation">
|
||||
<div class="instance-selector">
|
||||
<label class="visually-hidden" for="instanceSelect">Select a region</label>
|
||||
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
|
||||
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
|
||||
</select>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="site-header__right">
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
<a href="/" class="site-nav__link<%= view_mode == :dashboard ? " is-active" : "" %>"<%= view_mode == :dashboard ? ' aria-current="page"' : "" %>>Dashboard</a>
|
||||
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
|
||||
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
|
||||
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
|
||||
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
|
||||
<% if federation_nav_enabled %>
|
||||
<a href="/federation" class="site-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
<button
|
||||
id="mobileMenuToggle"
|
||||
class="icon-button menu-toggle"
|
||||
type="button"
|
||||
aria-label="Open navigation menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobileMenu"
|
||||
>
|
||||
<span aria-hidden="true">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="mobileMenu" class="mobile-menu" hidden>
|
||||
<div class="mobile-menu__backdrop" data-mobile-menu-close></div>
|
||||
<div class="mobile-menu__panel" role="dialog" aria-modal="true" aria-labelledby="mobileMenuTitle" tabindex="-1">
|
||||
<div class="mobile-menu__header">
|
||||
<h2 id="mobileMenuTitle" class="mobile-menu__title">Menu</h2>
|
||||
<button class="icon-button mobile-menu__close" type="button" data-mobile-menu-close aria-label="Close navigation menu">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="mobile-nav" aria-label="Mobile">
|
||||
<a href="/" class="mobile-nav__link<%= view_mode == :dashboard ? " is-active" : "" %>"<%= view_mode == :dashboard ? ' aria-current="page"' : "" %>>Dashboard</a>
|
||||
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
|
||||
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
|
||||
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
|
||||
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
|
||||
<% if federation_nav_enabled %>
|
||||
<a href="/federation" class="mobile-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
<% end %>
|
||||
|
||||
<div id="metaRow" class="row meta">
|
||||
<div class="row meta">
|
||||
<% if show_meta_info %>
|
||||
<div class="meta-info">
|
||||
<div class="<%= refresh_row_classes.join(" ") %>">
|
||||
|
||||
+1
-1
@@ -14,5 +14,5 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
<section class="full-screen-section full-screen-section--map">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
|
||||
</section>
|
||||
|
||||
@@ -14,12 +14,8 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
<% map_classes = ["map-panel"]
|
||||
map_classes << "map-panel--full" if defined?(full_screen) && full_screen
|
||||
data_attrs = []
|
||||
if defined?(legend_collapsed)
|
||||
data_attrs << "data-legend-collapsed=\\" #{legend_collapsed ? 'true' : 'false'}\\""
|
||||
end %>
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel" <%= data_attrs.join(" ") %>>
|
||||
map_classes << "map-panel--full" if defined?(full_screen) && full_screen %>
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel">
|
||||
<div id="map" role="region" aria-label="Nodes map"></div>
|
||||
<div class="map-toolbar" role="group" aria-label="Map view controls">
|
||||
<button id="mapFullscreenToggle" type="button" aria-pressed="false" aria-label="Enter full screen map view">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<div id="nodes-table" class="nodes-table-wrapper">
|
||||
<div class="nodes-table-wrapper">
|
||||
<table id="nodes">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
Reference in New Issue
Block a user