mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
28 Commits
richer-ins
...
3.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac6a5774af | ||
|
|
b12e612596 | ||
|
|
d1499ad75f | ||
|
|
79d5e69ee0 | ||
|
|
498770bd88 | ||
|
|
1405df6039 | ||
|
|
ac5e71d6f2 | ||
|
|
650a24a68c | ||
|
|
53f122e503 | ||
|
|
bf0533807a | ||
|
|
efeb047116 | ||
|
|
bea3495b79 | ||
|
|
54c24c50d3 | ||
|
|
b7972f50a8 | ||
|
|
bab1693c82 | ||
|
|
f93844a01b | ||
|
|
26b740fe3c | ||
|
|
b0f5930e01 | ||
|
|
5b05fdefa1 | ||
|
|
b63153b3a1 | ||
|
|
3c5a832bef | ||
|
|
2d943dedc5 | ||
|
|
137f41970d | ||
|
|
c833f1036b | ||
|
|
e15e6d83f7 | ||
|
|
f9ca35b3ae | ||
|
|
7c4a244e05 | ||
|
|
6eab75ec7e |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
||||
## [3.6.1] - 2026-03-26
|
||||
|
||||
Feature: MeshCore Map integration
|
||||
Feature: Add warning screen about bots
|
||||
Feature: Favicon reflects unread message state
|
||||
Feature: Show hop map in larger modal
|
||||
Feature: Add prebuilt frontend install script
|
||||
Feature: Add clean service installer script
|
||||
Feature: Swipe in to show menu
|
||||
Bugfix: Invalid backend API path serves error, not fallback index
|
||||
Bugfix: Fix some spacing/page height issues
|
||||
Misc: Misc. bugfixes and performance and test improvements
|
||||
|
||||
## [3.6.0] - 2026-03-22
|
||||
|
||||
Feature: Add incoming-packet analytics
|
||||
|
||||
33
LICENSES.md
33
LICENSES.md
@@ -1592,6 +1592,39 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### react-swipeable (7.0.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (C) 2014-2022 Josh Perez
|
||||
Copyright (C) 2014-2022 Brian Emil Hartz
|
||||
Copyright (C) 2022 Formidable Labs, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### sonner (2.0.7) — MIT
|
||||
|
||||
<details>
|
||||
|
||||
@@ -89,6 +89,19 @@ Amazon SQS delivery. Config blob:
|
||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||
- Supports both decoded messages and raw packets via normal scope selection
|
||||
|
||||
### map_upload (map_upload.py)
|
||||
Uploads heard repeater and room-server advertisements to map.meshcore.dev. Config blob:
|
||||
- `api_url` (optional, default `""`) — upload endpoint; empty falls back to the public map.meshcore.dev API
|
||||
- `dry_run` (bool, default `true`) — when true, logs the payload at INFO level without sending
|
||||
- `geofence_enabled` (bool, default `false`) — when true, only uploads nodes within `geofence_radius_km` of the radio's own configured lat/lon
|
||||
- `geofence_radius_km` (float, default `0`) — filter radius in kilometres
|
||||
|
||||
Geofence notes:
|
||||
- The reference center is always the radio's own `adv_lat`/`adv_lon` from `radio_runtime.meshcore.self_info`, read **live at upload time** — no lat/lon is stored in the fanout config itself.
|
||||
- If the radio's lat/lon is `(0, 0)` or the radio is not connected, the geofence check is silently skipped so uploads continue normally until coordinates are configured.
|
||||
- Requires the radio to have `ENABLE_PRIVATE_KEY_EXPORT=1` firmware to sign uploads.
|
||||
- Scope is always `{"messages": "none", "raw_packets": "all"}` — only raw RF packets are processed.
|
||||
|
||||
## Adding a New Integration Type
|
||||
|
||||
### Step-by-step checklist
|
||||
@@ -291,6 +304,7 @@ Migrations:
|
||||
- `app/fanout/webhook.py` — Webhook fanout module
|
||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||
- `app/fanout/map_upload.py` — Map Upload fanout module
|
||||
- `app/repository/fanout.py` — Database CRUD
|
||||
- `app/routers/fanout.py` — REST API
|
||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||
|
||||
@@ -20,9 +20,9 @@ from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
import aiomqtt
|
||||
import nacl.bindings
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
from app.keystore import ed25519_sign_expanded
|
||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
@@ -40,9 +40,6 @@ _TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
||||
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
||||
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
||||
|
||||
# Ed25519 group order
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# Route type mapping: bottom 2 bits of first byte
|
||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||
|
||||
@@ -69,28 +66,6 @@ def _base64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _ed25519_sign_expanded(
|
||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
||||
) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
||||
This performs the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
# r = SHA-512(prefix || message) mod L
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
# R = r * B (base point multiplication)
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
# k = SHA-512(R || public_key || message) mod L
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
# s = (r + k * scalar) mod L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def _generate_jwt_token(
|
||||
private_key: bytes,
|
||||
public_key: bytes,
|
||||
@@ -127,7 +102,7 @@ def _generate_jwt_token(
|
||||
|
||||
scalar = private_key[:32]
|
||||
prefix = private_key[32:]
|
||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
signature = ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ def _register_module_types() -> None:
|
||||
return
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.map_upload import MapUploadModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.sqs import SqsModule
|
||||
@@ -32,6 +33,7 @@ def _register_module_types() -> None:
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
_MODULE_TYPES["sqs"] = SqsModule
|
||||
_MODULE_TYPES["map_upload"] = MapUploadModule
|
||||
|
||||
|
||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||
|
||||
320
app/fanout/map_upload.py
Normal file
320
app/fanout/map_upload.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Fanout module for uploading heard advert packets to map.meshcore.dev.
|
||||
|
||||
Mirrors the logic of the standalone map.meshcore.dev-uploader project:
|
||||
- Listens on raw RF packets via on_raw
|
||||
- Filters for ADVERT packets, only processes repeaters (role 2) and rooms (role 3)
|
||||
- Skips nodes with no valid location (lat/lon None)
|
||||
- Applies per-pubkey rate-limiting (1-hour window, matching the uploader)
|
||||
- Signs the upload request with the radio's own Ed25519 private key
|
||||
- POSTs to the map API (or logs in dry-run mode)
|
||||
|
||||
Dry-run mode (default: True) logs the full would-be payload at INFO level
|
||||
without making any HTTP requests. Disable it only after verifying the log
|
||||
output looks correct — in particular the radio params (freq/bw/sf/cr) and
|
||||
the raw hex link.
|
||||
|
||||
Config keys
|
||||
-----------
|
||||
api_url : str, default ""
|
||||
Upload endpoint. Empty string falls back to the public map.meshcore.dev API.
|
||||
dry_run : bool, default True
|
||||
When True, log the payload at INFO level instead of sending it.
|
||||
geofence_enabled : bool, default False
|
||||
When True, only upload nodes whose location falls within geofence_radius_km of
|
||||
the radio's own configured latitude/longitude (read live from the radio at upload
|
||||
time — no lat/lon is stored in this config). When the radio's lat/lon is not set
|
||||
(0, 0) or unavailable, the geofence check is silently skipped so uploads continue
|
||||
normally until coordinates are configured.
|
||||
geofence_radius_km : float, default 0.0
|
||||
Radius of the geofence in kilometres. Nodes further than this distance
|
||||
from the radio's own position are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
from app.decoder import parse_advertisement, parse_packet
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.keystore import ed25519_sign_expanded, get_private_key, get_public_key
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node"
|
||||
|
||||
# Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity)
|
||||
_REUPLOAD_SECONDS = 3600
|
||||
|
||||
# Only upload repeaters (2) and rooms (3). Any other role — including future
|
||||
# roles not yet defined — is rejected. An allowlist is used rather than a
|
||||
# blocklist so that new roles cannot accidentally start populating the map.
|
||||
_ALLOWED_DEVICE_ROLES = {2, 3}
|
||||
|
||||
|
||||
def _get_radio_params() -> dict:
|
||||
"""Read radio frequency parameters from the connected radio's self_info.
|
||||
|
||||
The Python meshcore library returns radio_freq in MHz (e.g. 910.525) and
|
||||
radio_bw in kHz (e.g. 62.5). These are exactly the units the map API
|
||||
expects, matching what the JS reference uploader produces after its own
|
||||
/1000 division on raw integer values. No further scaling is applied here.
|
||||
"""
|
||||
try:
|
||||
mc = radio_runtime.meshcore
|
||||
if not mc:
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
info = mc.self_info
|
||||
if not isinstance(info, dict):
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
freq = info.get("radio_freq", 0) or 0
|
||||
bw = info.get("radio_bw", 0) or 0
|
||||
sf = info.get("radio_sf", 0) or 0
|
||||
cr = info.get("radio_cr", 0) or 0
|
||||
return {
|
||||
"freq": freq,
|
||||
"cr": cr,
|
||||
"sf": sf,
|
||||
"bw": bw,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("MapUpload: could not read radio params: %s", exc)
|
||||
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||
|
||||
|
||||
_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room"}
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return the great-circle distance in kilometres between two lat/lon points."""
|
||||
r = 6371.0
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
class MapUploadModule(FanoutModule):
|
||||
"""Uploads heard ADVERT packets to the MeshCore community map."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._last_error: str | None = None
|
||||
# Per-pubkey rate limiting: pubkey_hex -> last_uploaded_advert_timestamp
|
||||
self._seen: dict[str, int] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
|
||||
self._last_error = None
|
||||
self._seen.clear()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
self._last_error = None
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if data.get("payload_type") != "ADVERT":
|
||||
return
|
||||
|
||||
raw_hex = data.get("data", "")
|
||||
if not raw_hex:
|
||||
return
|
||||
|
||||
try:
|
||||
raw_bytes = bytes.fromhex(raw_hex)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None:
|
||||
return
|
||||
|
||||
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
|
||||
if advert is None:
|
||||
return
|
||||
|
||||
# TODO: advert Ed25519 signature verification is skipped here.
|
||||
# The radio has already validated the packet before passing it to RT,
|
||||
# so re-verification is redundant in practice. If added, verify that
|
||||
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
|
||||
# advert.public_key_bytes) succeeds before proceeding.
|
||||
|
||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||
return
|
||||
|
||||
# Skip nodes with no valid location — the decoder already nulls out
|
||||
# impossible values, so None means either no location flag or bad coords.
|
||||
if advert.lat is None or advert.lon is None:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — no valid location",
|
||||
advert.public_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
pubkey = advert.public_key.lower()
|
||||
|
||||
# Rate-limit: skip if this pubkey's timestamp hasn't advanced enough
|
||||
last_seen = self._seen.get(pubkey)
|
||||
if last_seen is not None:
|
||||
if last_seen >= advert.timestamp:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — possible replay (last=%d, advert=%d)",
|
||||
pubkey[:12],
|
||||
last_seen,
|
||||
advert.timestamp,
|
||||
)
|
||||
return
|
||||
if advert.timestamp < last_seen + _REUPLOAD_SECONDS:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — within 1-hr rate-limit window (delta=%ds)",
|
||||
pubkey[:12],
|
||||
advert.timestamp - last_seen,
|
||||
)
|
||||
return
|
||||
|
||||
await self._upload(
|
||||
pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon
|
||||
)
|
||||
|
||||
async def _upload(
|
||||
self,
|
||||
pubkey: str,
|
||||
advert_timestamp: int,
|
||||
device_role: int,
|
||||
raw_hex: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> None:
|
||||
# Geofence check: if enabled, skip nodes outside the configured radius.
|
||||
# The reference center is the radio's own lat/lon read live from self_info —
|
||||
# no coordinates are stored in the fanout config. If the radio lat/lon is
|
||||
# (0, 0) or unavailable the check is skipped transparently so uploads
|
||||
# continue normally until the operator sets coordinates in radio settings.
|
||||
geofence_dist_km: float | None = None
|
||||
if self.config.get("geofence_enabled"):
|
||||
try:
|
||||
mc = radio_runtime.meshcore
|
||||
sinfo = mc.self_info if mc else None
|
||||
fence_lat = float((sinfo or {}).get("adv_lat", 0) or 0)
|
||||
fence_lon = float((sinfo or {}).get("adv_lon", 0) or 0)
|
||||
except Exception as exc:
|
||||
logger.debug("MapUpload: could not read radio lat/lon for geofence: %s", exc)
|
||||
fence_lat = 0.0
|
||||
fence_lon = 0.0
|
||||
|
||||
if fence_lat == 0.0 and fence_lon == 0.0:
|
||||
logger.debug(
|
||||
"MapUpload: geofence skipped for %s — radio lat/lon not configured",
|
||||
pubkey[:12],
|
||||
)
|
||||
else:
|
||||
fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0)
|
||||
geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon)
|
||||
if geofence_dist_km > fence_radius_km:
|
||||
logger.debug(
|
||||
"MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)",
|
||||
pubkey[:12],
|
||||
geofence_dist_km,
|
||||
fence_radius_km,
|
||||
)
|
||||
return
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
|
||||
if private_key is None or public_key is None:
|
||||
logger.warning(
|
||||
"MapUpload: private key not available — cannot sign upload for %s. "
|
||||
"Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1.",
|
||||
pubkey[:12],
|
||||
)
|
||||
return
|
||||
|
||||
api_url = str(self.config.get("api_url", "") or _DEFAULT_API_URL).strip()
|
||||
dry_run = bool(self.config.get("dry_run", True))
|
||||
role_name = _ROLE_NAMES.get(device_role, f"role={device_role}")
|
||||
|
||||
params = _get_radio_params()
|
||||
upload_data = {
|
||||
"params": params,
|
||||
"links": [f"meshcore://{raw_hex}"],
|
||||
}
|
||||
|
||||
# Sign: SHA-256 the compact JSON, then Ed25519-sign the hash
|
||||
json_str = json.dumps(upload_data, separators=(",", ":"))
|
||||
data_hash = hashlib.sha256(json_str.encode()).digest()
|
||||
scalar = private_key[:32]
|
||||
prefix_bytes = private_key[32:]
|
||||
signature = ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key)
|
||||
|
||||
request_payload = {
|
||||
"data": json_str,
|
||||
"signature": signature.hex(),
|
||||
"publicKey": public_key.hex(),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
geofence_note = (
|
||||
f" | geofence: {geofence_dist_km:.2f} km from observer"
|
||||
if geofence_dist_km is not None
|
||||
else ""
|
||||
)
|
||||
logger.info(
|
||||
"MapUpload [DRY RUN] %s (%s)%s → would POST to %s\n payload: %s",
|
||||
pubkey[:12],
|
||||
role_name,
|
||||
geofence_note,
|
||||
api_url,
|
||||
json.dumps(request_payload, separators=(",", ":")),
|
||||
)
|
||||
# Still update _seen so rate-limiting works during dry-run testing
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
return
|
||||
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
api_url,
|
||||
content=json.dumps(request_payload, separators=(",", ":")),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
self._last_error = None
|
||||
logger.info(
|
||||
"MapUpload: uploaded %s (%s) → HTTP %d",
|
||||
pubkey[:12],
|
||||
role_name,
|
||||
resp.status_code,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._last_error = f"HTTP {exc.response.status_code}"
|
||||
logger.warning(
|
||||
"MapUpload: server returned %d for %s: %s",
|
||||
exc.response.status_code,
|
||||
pubkey[:12],
|
||||
exc.response.text[:200],
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("MapUpload: request error for %s: %s", pubkey[:12], exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._client is None:
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -102,7 +102,7 @@ class BaseMqttPublisher(ABC):
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"%s publish failed on %s. This is usually transient network noise; "
|
||||
"if it self-resolves and reconnects, it is generally not a concern: %s",
|
||||
"if it self-resolves and reconnects, it is generally not a concern. Persistent errors may indicate a problem with your network connection or MQTT broker. Original error: %s",
|
||||
self._integration_label(),
|
||||
topic,
|
||||
e,
|
||||
@@ -239,7 +239,7 @@ class BaseMqttPublisher(ABC):
|
||||
logger.warning(
|
||||
"%s connection error. This is usually transient network noise; "
|
||||
"if it self-resolves, it is generally not a concern: %s "
|
||||
"(reconnecting in %ds)",
|
||||
"(reconnecting in %ds). If this error persists, check your network connection and MQTT broker status.",
|
||||
self._integration_label(),
|
||||
e,
|
||||
backoff,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""
|
||||
Ephemeral keystore for storing sensitive keys in memory.
|
||||
Ephemeral keystore for storing sensitive keys in memory, plus the Ed25519
|
||||
signing primitive used by fanout modules that need to sign requests with the
|
||||
radio's own key.
|
||||
|
||||
The private key is stored in memory only and is never persisted to disk.
|
||||
It's exported from the radio on startup and reconnect, then used for
|
||||
server-side decryption of direct messages.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import nacl.bindings
|
||||
from meshcore import EventType
|
||||
|
||||
from app.decoder import derive_public_key
|
||||
@@ -25,11 +29,30 @@ NO_EVENT_RECEIVED_GUIDANCE = (
|
||||
"issue commands to the radio."
|
||||
)
|
||||
|
||||
# Ed25519 group order (L) — used in the expanded signing primitive below
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# In-memory storage for the private key and derived public key
|
||||
_private_key: bytes | None = None
|
||||
_public_key: bytes | None = None
|
||||
|
||||
|
||||
def ed25519_sign_expanded(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard
|
||||
Ed25519 libraries expect seed format and would re-SHA-512 the key, so we
|
||||
perform the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def clear_keys() -> None:
|
||||
"""Clear any stored private/public key material from memory."""
|
||||
global _private_key, _public_key
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.repository.fanout import FanoutConfigRepository
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
@@ -94,6 +94,8 @@ def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
|
||||
_validate_apprise_config(normalized)
|
||||
elif config_type == "sqs":
|
||||
_validate_sqs_config(normalized)
|
||||
elif config_type == "map_upload":
|
||||
_validate_map_upload_config(normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
@@ -295,10 +297,33 @@ def _validate_sqs_config(config: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_map_upload_config(config: dict) -> None:
|
||||
"""Validate and normalize map_upload config blob."""
|
||||
api_url = str(config.get("api_url", "")).strip()
|
||||
if api_url and not api_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="api_url must start with http:// or https://",
|
||||
)
|
||||
# Persist the cleaned value (empty string means use the module default)
|
||||
config["api_url"] = api_url
|
||||
config["dry_run"] = bool(config.get("dry_run", True))
|
||||
config["geofence_enabled"] = bool(config.get("geofence_enabled", False))
|
||||
try:
|
||||
radius = float(config.get("geofence_radius_km", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="geofence_radius_km must be a number") from None
|
||||
if radius < 0:
|
||||
raise HTTPException(status_code=400, detail="geofence_radius_km must be >= 0")
|
||||
config["geofence_radius_km"] = radius
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "map_upload":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "bot":
|
||||
return {"messages": "all", "raw_packets": "none"}
|
||||
if config_type in ("webhook", "apprise"):
|
||||
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -29,6 +29,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -5695,6 +5696,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-swipeable": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
|
||||
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.6.0",
|
||||
"version": "3.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -37,6 +37,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
@@ -89,6 +90,24 @@ export function AppShell({
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
if (initial[0] < 30 && !sidebarOpen && window.innerWidth < 768) {
|
||||
onSidebarOpenChange(true);
|
||||
}
|
||||
},
|
||||
trackTouch: true,
|
||||
trackMouse: false,
|
||||
preventScrollOnSwipe: true,
|
||||
});
|
||||
|
||||
const closeSwipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => onSidebarOpenChange(false),
|
||||
trackTouch: true,
|
||||
trackMouse: false,
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -153,7 +172,7 @@ export function AppShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full" {...swipeHandlers}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
||||
@@ -196,7 +215,9 @@ export function AppShell({
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
|
||||
<div className="flex-1 overflow-hidden" {...closeSwipeHandlers}>
|
||||
{activeSidebarContent}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
@@ -505,7 +505,7 @@ export function CrackerPanel({
|
||||
? 'GPU Not Available'
|
||||
: !wordlistLoaded
|
||||
? 'Loading dictionary...'
|
||||
: 'Find Rooms'}
|
||||
: 'Find Channels'}
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PathModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-md max-h-[80dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasPaths
|
||||
|
||||
@@ -784,7 +784,7 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogContent className="flex h-[92dvh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
|
||||
className="w-[calc(100vw-1rem)] max-w-[42rem] gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100dvh-2rem)] sm:w-full sm:max-h-[min(85dvh,48rem)] sm:px-6"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -147,8 +147,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
|
||||
|
||||
const settingsContainerClass = externalDesktopSidebarMode
|
||||
? 'w-full h-full overflow-y-auto'
|
||||
: 'w-full h-full overflow-y-auto space-y-3';
|
||||
? 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto [contain:layout_paint]'
|
||||
: 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto space-y-3 [contain:layout_paint]';
|
||||
|
||||
const sectionButtonClasses =
|
||||
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
|
||||
|
||||
@@ -844,7 +844,7 @@ export function Sidebar({
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search rooms/contacts..."
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
||||
@@ -16,11 +16,12 @@ const BotCodeEditor = lazy(() =>
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community MQTT',
|
||||
mqtt_community: 'Community Sharing',
|
||||
bot: 'Python Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
sqs: 'Amazon SQS',
|
||||
map_upload: 'Map Upload',
|
||||
};
|
||||
|
||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||
@@ -100,7 +101,8 @@ type DraftType =
|
||||
| 'webhook'
|
||||
| 'apprise'
|
||||
| 'sqs'
|
||||
| 'bot';
|
||||
| 'bot'
|
||||
| 'map_upload';
|
||||
|
||||
type CreateIntegrationDefinition = {
|
||||
value: DraftType;
|
||||
@@ -143,7 +145,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
value: 'mqtt_community',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'Community MQTT/meshcoretomqtt',
|
||||
section: 'Community MQTT',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||
defaultName: 'Community MQTT',
|
||||
@@ -157,7 +159,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
value: 'mqtt_community_meshrank',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'MeshRank',
|
||||
section: 'Community MQTT',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'MeshRank',
|
||||
@@ -180,7 +182,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
value: 'mqtt_community_letsmesh_us',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'LetsMesh (US)',
|
||||
section: 'Community MQTT',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (US)',
|
||||
@@ -197,7 +199,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
value: 'mqtt_community_letsmesh_eu',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'LetsMesh (EU)',
|
||||
section: 'Community MQTT',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (EU)',
|
||||
@@ -284,6 +286,23 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'map_upload',
|
||||
savedType: 'map_upload',
|
||||
label: 'Map Upload',
|
||||
section: 'Community Sharing',
|
||||
description:
|
||||
'Upload repeaters and room servers to map.meshcore.dev or a compatible map API endpoint.',
|
||||
defaultName: 'Map Upload',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
api_url: '',
|
||||
dry_run: true,
|
||||
},
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
@@ -566,7 +585,9 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
|
||||
|
||||
function getStatusLabel(status: string | undefined, type?: string) {
|
||||
if (status === 'connected')
|
||||
return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected';
|
||||
return type === 'bot' || type === 'webhook' || type === 'apprise' || type === 'map_upload'
|
||||
? 'Active'
|
||||
: 'Connected';
|
||||
if (status === 'error') return 'Error';
|
||||
if (status === 'disconnected') return 'Disconnected';
|
||||
return 'Inactive';
|
||||
@@ -1059,6 +1080,152 @@ function BotConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function MapUploadConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const isDryRun = config.dry_run !== false;
|
||||
const [radioLat, setRadioLat] = useState<number | null>(null);
|
||||
const [radioLon, setRadioLon] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getRadioConfig()
|
||||
.then((rc) => {
|
||||
setRadioLat(rc.lat ?? 0);
|
||||
setRadioLon(rc.lon ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setRadioLat(0);
|
||||
setRadioLon(0);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const radioLatLonConfigured =
|
||||
radioLat !== null && radioLon !== null && !(radioLat === 0 && radioLon === 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically upload heard repeater and room server advertisements to{' '}
|
||||
<a
|
||||
href="https://map.meshcore.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
map.meshcore.dev
|
||||
</a>
|
||||
. Requires the radio's private key to be available (firmware must have{' '}
|
||||
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared — never
|
||||
decrypted messages.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
<strong>Dry Run is {isDryRun ? 'ON' : 'OFF'}.</strong>{' '}
|
||||
{isDryRun
|
||||
? 'No uploads will be sent. Check the backend logs to verify the payload looks correct before enabling live sends.'
|
||||
: 'Live uploads are enabled. Each advert is rate-limited to once per hour per node.'}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDryRun}
|
||||
onChange={(e) => onChange({ ...config, dry_run: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
|
||||
have confirmed the logged output looks correct.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-api-url">API URL (optional)</Label>
|
||||
<Input
|
||||
id="fanout-map-api-url"
|
||||
type="url"
|
||||
placeholder="https://map.meshcore.dev/api/v1/uploader/node"
|
||||
value={(config.api_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave blank to use the default <code>map.meshcore.dev</code> endpoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config.geofence_enabled}
|
||||
onChange={(e) => onChange({ ...config, geofence_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Enable Geofence</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only upload nodes whose location falls within the configured radius of your radio's
|
||||
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
|
||||
latitude/longitude set in Radio Settings.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!!config.geofence_enabled && (
|
||||
<div className="space-y-3 pl-7">
|
||||
{!radioLatLonConfigured && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Your radio does not currently have a latitude/longitude configured. Geofencing will be
|
||||
silently skipped until coordinates are set in{' '}
|
||||
<strong>Settings → Radio → Location</strong>.
|
||||
</div>
|
||||
)}
|
||||
{radioLatLonConfigured && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Using radio position{' '}
|
||||
<code>
|
||||
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
|
||||
</code>{' '}
|
||||
as the geofence center. Update coordinates in Radio Settings to move the center.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-map-geofence-radius">Radius (km)</Label>
|
||||
<Input
|
||||
id="fanout-map-geofence-radius"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="e.g. 100"
|
||||
value={(config.geofence_radius_km as number | undefined) ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nodes further than this distance from your radio's position will not be uploaded.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ScopeMode = 'all' | 'none' | 'only' | 'except';
|
||||
|
||||
function getScopeMode(value: unknown): ScopeMode {
|
||||
@@ -1975,6 +2142,10 @@ export function SettingsFanoutSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailType === 'map_upload' && (
|
||||
<MapUploadConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -9,6 +9,17 @@ const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
interface NotificationEnableToastInfo {
|
||||
level: 'success' | 'warning';
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface NotificationEnvironment {
|
||||
protocol: string;
|
||||
isSecureContext: boolean;
|
||||
}
|
||||
|
||||
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
|
||||
return getStateKey(type, id);
|
||||
}
|
||||
@@ -92,6 +103,40 @@ function buildMessageNotificationHash(message: Message): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNotificationEnableToastInfo(
|
||||
environment?: Partial<NotificationEnvironment>
|
||||
): NotificationEnableToastInfo {
|
||||
if (typeof window === 'undefined') {
|
||||
return { level: 'success', title: 'Notifications enabled' };
|
||||
}
|
||||
|
||||
const protocol = environment?.protocol ?? window.location.protocol;
|
||||
const isSecureContext = environment?.isSecureContext ?? window.isSecureContext;
|
||||
|
||||
if (protocol === 'http:') {
|
||||
return {
|
||||
level: 'warning',
|
||||
title: 'Notifications enabled with warning',
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
|
||||
};
|
||||
}
|
||||
|
||||
// Best-effort heuristic only. Browsers do not expose certificate trust details
|
||||
// directly to page JS, so an HTTPS page that is not a secure context is the
|
||||
// closest signal we have for an untrusted/self-signed setup.
|
||||
if (protocol === 'https:' && !isSecureContext) {
|
||||
return {
|
||||
level: 'warning',
|
||||
title: 'Notifications enabled with warning',
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.',
|
||||
};
|
||||
}
|
||||
|
||||
return { level: 'success', title: 'Notifications enabled' };
|
||||
}
|
||||
|
||||
export function useBrowserNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
||||
const [enabledByConversation, setEnabledByConversation] =
|
||||
@@ -110,8 +155,6 @@ export function useBrowserNotifications() {
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const blockedDescription =
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
@@ -120,20 +163,23 @@ export function useBrowserNotifications() {
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(`${label} notifications disabled`);
|
||||
toast.success('Notifications disabled', {
|
||||
description: `Desktop notifications are off for ${label}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'unsupported') {
|
||||
toast.error('Browser notifications unavailable', {
|
||||
toast.error('Notifications unavailable', {
|
||||
description: 'This browser does not support desktop notifications.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: blockedDescription,
|
||||
toast.error('Notifications blocked', {
|
||||
description:
|
||||
'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -155,13 +201,24 @@ export function useBrowserNotifications() {
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-notification-preview-${conversationKey}`,
|
||||
});
|
||||
toast.success(`${label} notifications enabled`);
|
||||
const toastInfo = getNotificationEnableToastInfo();
|
||||
if (toastInfo.level === 'warning') {
|
||||
toast.warning(toastInfo.title, {
|
||||
description: toastInfo.description,
|
||||
});
|
||||
} else {
|
||||
toast.success(toastInfo.title, {
|
||||
description: `Desktop notifications are on for ${label}.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
toast.error('Notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
|
||||
nextPermission === 'denied'
|
||||
? 'Desktop notifications were denied by your browser. Allow notifications in browser settings, then try again.'
|
||||
: 'The browser permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
|
||||
deleteFanoutConfig: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
getRadioConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -96,6 +97,17 @@ beforeEach(() => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([]);
|
||||
mockedApi.getChannels.mockResolvedValue([]);
|
||||
mockedApi.getContacts.mockResolvedValue([]);
|
||||
mockedApi.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'TestNode',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsFanoutSection', () => {
|
||||
@@ -106,7 +118,7 @@ describe('SettingsFanoutSection', () => {
|
||||
const optionButtons = within(dialog)
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||
expect(optionButtons).toHaveLength(9);
|
||||
expect(optionButtons).toHaveLength(10);
|
||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -138,6 +150,9 @@ describe('SettingsFanoutSection', () => {
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Map Upload') })
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
|
||||
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||
@@ -916,7 +931,7 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Community MQTT #1');
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Community Sharing #1');
|
||||
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Authentication')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument();
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
|
||||
import {
|
||||
getNotificationEnableToastInfo,
|
||||
useBrowserNotifications,
|
||||
} from '../hooks/useBrowserNotifications';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -57,6 +61,10 @@ describe('useBrowserNotifications', () => {
|
||||
configurable: true,
|
||||
value: NotificationMock,
|
||||
});
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores notification opt-in per conversation', async () => {
|
||||
@@ -84,6 +92,10 @@ describe('useBrowserNotifications', () => {
|
||||
icon: '/favicon-256x256.png',
|
||||
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
|
||||
});
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
|
||||
});
|
||||
});
|
||||
|
||||
it('only sends desktop notifications for opted-in conversations', async () => {
|
||||
@@ -164,9 +176,65 @@ describe('useBrowserNotifications', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Notifications blocked', {
|
||||
description:
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
|
||||
'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a warning toast when notifications are enabled on HTTP', async () => {
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.',
|
||||
});
|
||||
expect(mocks.toast.success).not.toHaveBeenCalledWith('Notifications enabled');
|
||||
});
|
||||
|
||||
it('best-effort detects insecure HTTPS for the enable-warning copy', () => {
|
||||
expect(
|
||||
getNotificationEnableToastInfo({
|
||||
protocol: 'https:',
|
||||
isSecureContext: false,
|
||||
})
|
||||
).toEqual({
|
||||
level: 'warning',
|
||||
title: 'Notifications enabled with warning',
|
||||
description:
|
||||
'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a descriptive success toast when notifications are disabled', async () => {
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith('Notifications disabled', {
|
||||
description: 'Desktop notifications are off for #flightless.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.6.0"
|
||||
version = "3.6.1"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.fanout.community_mqtt import (
|
||||
_build_status_topic,
|
||||
_calculate_packet_hash,
|
||||
_decode_packet_fields,
|
||||
_ed25519_sign_expanded,
|
||||
_format_raw_packet,
|
||||
_generate_jwt_token,
|
||||
_get_client_version,
|
||||
@@ -29,6 +28,7 @@ from app.fanout.mqtt_community import (
|
||||
_publish_community_packet,
|
||||
_render_packet_topic,
|
||||
)
|
||||
from app.keystore import ed25519_sign_expanded
|
||||
|
||||
|
||||
def _make_test_keys() -> tuple[bytes, bytes]:
|
||||
@@ -173,13 +173,13 @@ class TestEddsaSignExpanded:
|
||||
def test_produces_64_byte_signature(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
message = b"test message"
|
||||
sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
sig = ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
assert len(sig) == 64
|
||||
|
||||
def test_signature_verifies_with_nacl(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
message = b"hello world"
|
||||
sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
sig = ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
|
||||
signed_message = sig + message
|
||||
verified = nacl.bindings.crypto_sign_open(signed_message, public_key)
|
||||
@@ -187,8 +187,8 @@ class TestEddsaSignExpanded:
|
||||
|
||||
def test_different_messages_produce_different_signatures(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
sig1 = _ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key)
|
||||
sig2 = _ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key)
|
||||
sig1 = ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key)
|
||||
sig2 = ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key)
|
||||
assert sig1 != sig2
|
||||
|
||||
|
||||
|
||||
@@ -707,6 +707,98 @@ class TestSqsValidation:
|
||||
{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"}
|
||||
)
|
||||
|
||||
|
||||
class TestMapUploadValidation:
|
||||
def test_rejects_bad_api_url_scheme(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_map_upload_config({"api_url": "ftp://example.com"})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "api_url" in exc_info.value.detail
|
||||
|
||||
def test_accepts_empty_api_url(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"api_url": ""}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["api_url"] == ""
|
||||
|
||||
def test_accepts_valid_api_url(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"api_url": "https://custom.example.com/upload"}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["api_url"] == "https://custom.example.com/upload"
|
||||
|
||||
def test_normalizes_dry_run_to_bool(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"dry_run": 1}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["dry_run"] is True
|
||||
|
||||
def test_normalizes_geofence_enabled_to_bool(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"geofence_enabled": 1}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["geofence_enabled"] is True
|
||||
|
||||
def test_normalizes_geofence_radius_to_float(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"geofence_radius_km": 100}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["geofence_radius_km"] == 100.0
|
||||
assert isinstance(config["geofence_radius_km"], float)
|
||||
|
||||
def test_rejects_negative_geofence_radius(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_map_upload_config({"geofence_radius_km": -1})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "geofence_radius_km" in exc_info.value.detail
|
||||
|
||||
def test_rejects_non_numeric_geofence_radius(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_map_upload_config({"geofence_radius_km": "bad"})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "geofence_radius_km" in exc_info.value.detail
|
||||
|
||||
def test_accepts_zero_geofence_radius(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {"geofence_radius_km": 0}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["geofence_radius_km"] == 0.0
|
||||
|
||||
def test_defaults_applied_when_keys_absent(self):
|
||||
from app.routers.fanout import _validate_map_upload_config
|
||||
|
||||
config = {}
|
||||
_validate_map_upload_config(config)
|
||||
assert config["api_url"] == ""
|
||||
assert config["dry_run"] is True
|
||||
assert config["geofence_enabled"] is False
|
||||
assert config["geofence_radius_km"] == 0.0
|
||||
|
||||
def test_enforce_scope_map_upload_forces_raw_only(self):
|
||||
"""map_upload scope is always fixed regardless of what the caller passes."""
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
scope = _enforce_scope("map_upload", {"messages": "all", "raw_packets": "none"})
|
||||
assert scope == {"messages": "none", "raw_packets": "all"}
|
||||
|
||||
def test_enforce_scope_sqs_preserves_raw_packets_setting(self):
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
|
||||
@@ -1790,3 +1790,100 @@ class TestManagerRestartFailure:
|
||||
|
||||
assert len(healthy.messages_received) == 1
|
||||
assert len(dead.messages_received) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MapUploadModule integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMapUploadIntegration:
|
||||
"""Integration tests: FanoutManager loads and dispatches to MapUploadModule."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_upload_module_loaded_and_receives_raw(self, integration_db):
|
||||
"""Enabled map_upload config is loaded by the manager and its on_raw is called."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="map_upload",
|
||||
name="Map",
|
||||
config={"dry_run": True, "api_url": ""},
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
await manager.load_from_db()
|
||||
|
||||
assert cfg["id"] in manager._modules
|
||||
module, scope = manager._modules[cfg["id"]]
|
||||
assert scope == {"messages": "none", "raw_packets": "all"}
|
||||
|
||||
# Raw ADVERT event should be dispatched to on_raw
|
||||
advert_data = {
|
||||
"payload_type": "ADVERT",
|
||||
"data": "aabbccdd",
|
||||
"timestamp": 1000,
|
||||
"id": 1,
|
||||
"observation_id": 1,
|
||||
}
|
||||
|
||||
with patch.object(module, "_upload", new_callable=AsyncMock):
|
||||
# Provide a parseable but minimal packet so on_raw gets past hex decode;
|
||||
# parse_packet/parse_advertisement returning None is fine — on_raw silently exits
|
||||
await manager.broadcast_raw(advert_data)
|
||||
# Give the asyncio task a chance to run
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
# _upload may or may not be called depending on parse result, but no exception
|
||||
|
||||
await manager.stop_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_upload_disabled_not_loaded(self, integration_db):
|
||||
"""Disabled map_upload config is not loaded by the manager."""
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="map_upload",
|
||||
name="Map Disabled",
|
||||
config={"dry_run": True, "api_url": ""},
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
await manager.load_from_db()
|
||||
|
||||
assert len(manager._modules) == 0
|
||||
await manager.stop_all()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_upload_does_not_receive_messages(self, integration_db):
|
||||
"""map_upload scope forces raw_packets only — message events must not reach it."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="map_upload",
|
||||
name="Map",
|
||||
config={"dry_run": True, "api_url": ""},
|
||||
scope={"messages": "none", "raw_packets": "all"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
await manager.load_from_db()
|
||||
|
||||
assert cfg["id"] in manager._modules
|
||||
module, _ = manager._modules[cfg["id"]]
|
||||
|
||||
with patch.object(module, "on_message", new_callable=AsyncMock) as mock_msg:
|
||||
await manager.broadcast_message(
|
||||
{"type": "CHAN", "conversation_key": "k1", "text": "hi"}
|
||||
)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
mock_msg.assert_not_called()
|
||||
|
||||
await manager.stop_all()
|
||||
|
||||
1087
tests/test_map_upload.py
Normal file
1087
tests/test_map_upload.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user