23 Commits

Author SHA1 Message Date
Jack Kingsman
7151cf3846 Be much, much clearer about room server ops. Closes #78. 2026-03-27 13:01:34 -07:00
Jack Kingsman
6e5256acce Be more flexible about radio offload. Closes #118. 2026-03-27 12:49:01 -07:00
Jack Kingsman
7d27567ae9 Merge pull request #109 from jkingsman/fix-room-server-ordering
Order room server messages by sender timestamp, not packet-receipt time
2026-03-27 10:18:21 -07:00
Jack Kingsman
5f0d042252 Fix time rendering unit issue 2026-03-26 21:32:23 -07:00
Jack Kingsman
6f68dfc609 Deal with non-existent hashes better 2026-03-26 20:36:13 -07:00
Jack Kingsman
a32ddda79d Cut down bloat in unreads endpoint 2026-03-26 20:36:04 -07:00
Jack Kingsman
ac6a5774af Updating changelog + build for 3.6.1 2026-03-26 19:14:44 -07:00
Jack Kingsman
b12e612596 Merge pull request #117 from jkingsman/settings-scroll-fix. Closes #112
More content-paint patchy patchy bs
2026-03-26 18:27:55 -07:00
Jack Kingsman
d1499ad75f Merge pull request #116 from kizniche/feat-int-mc-map-auto-uploader
Add automatic mesh map upload (integration/fanout module). Closes #108. Thank you!!
2026-03-26 18:08:34 -07:00
jkingsman
79d5e69ee0 Format + lint 2026-03-26 17:59:59 -07:00
jkingsman
1405df6039 Beef up some noopy tests 2026-03-26 17:22:42 -07:00
jkingsman
ac5e71d6f2 Validate geofence radius to be positive 2026-03-26 17:20:13 -07:00
jkingsman
650a24a68c Centralize duplicated crypto code 2026-03-26 17:18:28 -07:00
Kizniche
53f122e503 formatting changes to satisfy check 2026-03-26 20:08:42 -04:00
Kizniche
efeb047116 Switching to using radio lat/lon, rename Community MQTT to Community Sharing, update AGENTS_fanout.md 2026-03-26 19:55:30 -04:00
Kizniche
b7972f50a8 Fix issues identified in failing checks 2026-03-25 19:06:33 -04:00
Kizniche
bab1693c82 Fix freq and BW values, add geofence calc to dry run log 2026-03-25 18:39:27 -04:00
Kyle Gabriel
f93844a01b Merge branch 'jkingsman:main' into feat-int-mc-map-auto-uploader 2026-03-25 14:40:59 -04:00
Kyle Gabriel
e15e6d83f7 Merge branch 'jkingsman:main' into feat-int-mc-map-auto-uploader 2026-03-24 19:55:14 -04:00
Kizniche
f9ca35b3ae Switch from block list to allow list, add test to ensure certain nodes are skipped, fix test 2026-03-24 19:41:25 -04:00
Kizniche
7c4a244e05 Add geofence option 2026-03-24 19:41:25 -04:00
Kyle
6eab75ec7e Add Map Upload Integration and tests 2026-03-24 19:41:18 -04:00
jkingsman
95c874e643 Order room server messages by sender timestamp, not arrival-at-our-radio timestamp 2026-03-24 15:55:28 -07:00
38 changed files with 2778 additions and 203 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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()}"

View File

@@ -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
View 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"

View File

@@ -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

View File

@@ -17,6 +17,7 @@ from app.frontend_static import (
)
from app.radio import RadioDisconnectedError
from app.radio_sync import (
stop_background_contact_reconciliation,
stop_message_polling,
stop_periodic_advert,
stop_periodic_sync,
@@ -95,6 +96,7 @@ async def lifespan(app: FastAPI):
pass
await fanout_manager.stop_all()
await radio_manager.stop_connection_monitor()
await stop_background_contact_reconciliation()
await stop_message_polling()
await stop_periodic_advert()
await stop_periodic_sync()

View File

@@ -548,11 +548,14 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
from app.radio_sync import stop_background_contact_reconciliation
clear_keys()
self._reset_reconnect_error_broadcasts()
if self._meshcore is None:
return
await stop_background_contact_reconciliation()
await self._acquire_operation_lock("disconnect", blocking=True)
try:
mc = self._meshcore

View File

@@ -166,6 +166,9 @@ async def pause_polling():
# Background task handle
_sync_task: asyncio.Task | None = None
# Startup/background contact reconciliation task handle
_contact_reconcile_task: asyncio.Task | None = None
# Periodic maintenance check interval in seconds (5 minutes)
SYNC_INTERVAL = 300
@@ -266,30 +269,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
# LIBRARY INTERNAL FIXUP: The MeshCore library's
# commands.remove_contact() sends the remove command over
# the wire but does NOT update the library's in-memory
# contact cache (mc._contacts). This is a gap in the
# library — there's no public API to clear a single
# contact from the cache, and the library only refreshes
# it on a full get_contacts() call.
#
# Why this matters: sync_recent_contacts_to_radio() uses
# mc.get_contact_by_key_prefix() to check whether a
# contact is already loaded on the radio. That method
# searches mc._contacts. If we don't evict the removed
# contact from the cache here, get_contact_by_key_prefix()
# will still find it and skip the add_contact() call —
# meaning contacts never get loaded back onto the radio
# after offload. The result: no DM ACKs, degraded routing
# for potentially minutes until the next periodic sync
# refreshes the cache from the (now-empty) radio.
#
# We access mc._contacts directly because the library
# exposes it as a read-only property (mc.contacts) with
# no removal API. The dict is keyed by public_key string.
mc._contacts.pop(public_key, None)
_evict_removed_contact_from_library_cache(mc, public_key)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
@@ -461,28 +441,28 @@ async def ensure_default_channels() -> None:
async def sync_and_offload_all(mc: MeshCore) -> dict:
"""Sync and offload both contacts and channels, then ensure defaults exist."""
"""Run fast startup sync, then background contact reconcile."""
logger.info("Starting full radio sync and offload")
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
# cycle so old rows stop claiming radio residency we do not actively track.
await ContactRepository.clear_on_radio_except([])
contacts_result = await sync_and_offload_contacts(mc)
contacts_result = await sync_contacts_from_radio(mc)
channels_result = await sync_and_offload_channels(mc)
# Ensure default channels exist
await ensure_default_channels()
# Reload favorites plus a working-set fill back onto the radio immediately.
# Pass mc directly since the caller already holds the radio operation lock
# (asyncio.Lock is not reentrant).
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
start_background_contact_reconciliation(
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
expected_mc=mc,
)
return {
"contacts": contacts_result,
"channels": channels_result,
"reloaded": reload_result,
"contact_reconcile_started": True,
}
@@ -1036,6 +1016,270 @@ async def stop_periodic_sync():
# Throttling for contact sync to radio
_last_contact_sync: float = 0.0
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
CONTACT_RECONCILE_BATCH_SIZE = 2
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
"""Keep the library's contact cache consistent after a successful removal."""
# LIBRARY INTERNAL FIXUP: The MeshCore library's remove_contact() sends the
# remove command over the wire but does NOT update the library's in-memory
# contact cache (mc._contacts). This is a gap in the library — there's no
# public API to clear a single contact from the cache, and the library only
# refreshes it on a full get_contacts() call.
#
# Why this matters: contact sync and targeted ensure/load paths use
# mc.get_contact_by_key_prefix() to check whether a contact is already
# loaded on the radio. That method searches mc._contacts. If we don't evict
# the removed contact from the cache here, later syncs will still find it
# and skip add_contact() calls, leaving the radio without the contact even
# though the app thinks it is resident.
mc._contacts.pop(public_key, None)
def _normalize_radio_contacts_payload(contacts: dict | None) -> dict[str, dict]:
"""Return radio contacts keyed by normalized lowercase full public key."""
normalized: dict[str, dict] = {}
for public_key, contact_data in (contacts or {}).items():
normalized[str(public_key).lower()] = contact_data
return normalized
async def sync_contacts_from_radio(mc: MeshCore) -> dict:
"""Pull contacts from the radio and persist them to the database without removing them."""
synced = 0
try:
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
contacts = _normalize_radio_contacts_payload(result.payload)
logger.info("Found %d contacts on radio", len(contacts))
for public_key, contact_data in contacts.items():
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
logger.info("Synced %d contacts from radio snapshot", synced)
return {"synced": synced, "radio_contacts": contacts}
except Exception as e:
logger.error("Error during contact snapshot sync: %s", e)
return {"synced": synced, "radio_contacts": {}, "error": str(e)}
async def _reconcile_radio_contacts_in_background(
*,
initial_radio_contacts: dict[str, dict],
expected_mc: MeshCore,
) -> None:
"""Converge radio contacts toward the desired favorites+recents working set."""
radio_contacts = dict(initial_radio_contacts)
removed = 0
loaded = 0
failed = 0
try:
while True:
if not radio_manager.is_connected or radio_manager.meshcore is not expected_mc:
logger.info("Stopping background contact reconcile: radio transport changed")
break
selected_contacts = await get_contacts_selected_for_radio_sync()
desired_contacts = {
contact.public_key.lower(): contact
for contact in selected_contacts
if len(contact.public_key) >= 64
}
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
missing_contacts = [
contact for key, contact in desired_contacts.items() if key not in radio_contacts
]
if not removable_keys and not missing_contacts:
logger.info(
"Background contact reconcile complete: %d contacts on radio working set",
len(radio_contacts),
)
break
progressed = False
try:
async with radio_manager.radio_operation(
"background_contact_reconcile",
blocking=False,
) as mc:
if mc is not expected_mc:
logger.info(
"Stopping background contact reconcile: radio transport changed"
)
break
budget = CONTACT_RECONCILE_BATCH_SIZE
selected_contacts = await get_contacts_selected_for_radio_sync()
desired_contacts = {
contact.public_key.lower(): contact
for contact in selected_contacts
if len(contact.public_key) >= 64
}
for public_key in list(radio_contacts):
if budget <= 0:
break
if public_key in desired_contacts:
continue
remove_payload = (
mc.get_contact_by_key_prefix(public_key[:12])
or radio_contacts.get(public_key)
or {"public_key": public_key}
)
try:
remove_result = await mc.commands.remove_contact(remove_payload)
except Exception as exc:
failed += 1
budget -= 1
logger.warning(
"Error removing contact %s during background reconcile: %s",
public_key[:12],
exc,
)
continue
budget -= 1
if remove_result.type == EventType.OK:
radio_contacts.pop(public_key, None)
_evict_removed_contact_from_library_cache(mc, public_key)
removed += 1
progressed = True
else:
failed += 1
logger.warning(
"Failed to remove contact %s during background reconcile: %s",
public_key[:12],
remove_result.payload,
)
if budget > 0:
for public_key, contact in desired_contacts.items():
if budget <= 0:
break
if public_key in radio_contacts:
continue
if mc.get_contact_by_key_prefix(public_key[:12]):
radio_contacts[public_key] = {"public_key": public_key}
continue
try:
add_payload = contact.to_radio_dict()
add_result = await mc.commands.add_contact(add_payload)
except Exception as exc:
failed += 1
budget -= 1
logger.warning(
"Error adding contact %s during background reconcile: %s",
public_key[:12],
exc,
exc_info=True,
)
continue
budget -= 1
if add_result.type == EventType.OK:
radio_contacts[public_key] = add_payload
loaded += 1
progressed = True
else:
failed += 1
reason = add_result.payload
hint = ""
if reason is None:
hint = (
" (no response from radio — if this repeats, check for "
"serial port contention from another process or try a "
"power cycle)"
)
logger.warning(
"Failed to add contact %s during background reconcile: %s%s",
public_key[:12],
reason,
hint,
)
except RadioOperationBusyError:
logger.debug("Background contact reconcile yielding: radio busy")
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
if not progressed:
continue
except asyncio.CancelledError:
logger.info("Background contact reconcile task cancelled")
raise
except Exception as exc:
logger.error("Background contact reconcile failed: %s", exc, exc_info=True)
finally:
if removed > 0 or loaded > 0 or failed > 0:
logger.info(
"Background contact reconcile summary: removed %d, loaded %d, failed %d",
removed,
loaded,
failed,
)
def start_background_contact_reconciliation(
*,
initial_radio_contacts: dict[str, dict],
expected_mc: MeshCore,
) -> None:
"""Start or replace the background contact reconcile task for the current radio."""
global _contact_reconcile_task
if _contact_reconcile_task is not None and not _contact_reconcile_task.done():
_contact_reconcile_task.cancel()
_contact_reconcile_task = asyncio.create_task(
_reconcile_radio_contacts_in_background(
initial_radio_contacts=initial_radio_contacts,
expected_mc=expected_mc,
)
)
logger.info(
"Started background contact reconcile for %d radio contact(s)",
len(initial_radio_contacts),
)
async def stop_background_contact_reconciliation() -> None:
"""Stop the background contact reconcile task."""
global _contact_reconcile_task
if _contact_reconcile_task and not _contact_reconcile_task.done():
_contact_reconcile_task.cancel()
try:
await _contact_reconcile_task
except asyncio.CancelledError:
pass
_contact_reconcile_task = None
async def get_contacts_selected_for_radio_sync() -> list[Contact]:

View File

@@ -723,6 +723,11 @@ class MessageRepository:
state_key = f"{prefix}-{row['conversation_key']}"
last_message_times[state_key] = row["last_message_time"]
# Only include last_read_ats for conversations that actually have messages.
# Without this filter, every contact heard via advertisement (even without
# any DMs) bloats the payload — 391KB down to ~46KB on a typical database.
last_read_ats = {k: v for k, v in last_read_ats.items() if k in last_message_times}
return {
"counts": counts,
"mentions": mention_flags,

View File

@@ -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"):

View File

@@ -62,7 +62,7 @@ def _login_rejected_message(label: str) -> str:
def _login_send_failed_message(label: str) -> str:
return (
f"The login request could not be sent to the {label}. "
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
)
@@ -70,7 +70,7 @@ def _login_timeout_message(label: str) -> str:
return (
f"No login confirmation was heard from the {label}. "
"That can mean the password was wrong or the reply was missed in transit. "
"The control panel is still available; try logging in again if authenticated actions fail."
"You're free to attempt interaction; try logging in again if authenticated actions fail."
)

View File

@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.0",
"version": "3.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.0",
"version": "3.6.1",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.6.0",
"version": "3.6.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -24,6 +24,7 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker {
channelId: string;
@@ -251,6 +252,21 @@ export function App() {
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
// so the display reflects the original send order rather than our radio's receipt order.
const activeContactIsRoom =
activeConversation?.type === 'contact' &&
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
const sortedMessages = useMemo(() => {
if (!activeContactIsRoom || messages.length === 0) return messages;
return [...messages].sort((a, b) => {
const aTs = a.sender_timestamp ?? a.received_at;
const bTs = b.sender_timestamp ?? b.received_at;
return aTs !== bTs ? aTs - bTs : a.id - b.id;
});
}, [activeContactIsRoom, messages]);
const {
unreadCounts,
mentions,
@@ -427,7 +443,7 @@ export function App() {
config,
health,
favorites,
messages,
messages: sortedMessages,
messagesLoading,
loadingOlder,
hasOlderMessages,

View File

@@ -5,6 +5,7 @@ import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
loggedIn,
loginLoading,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
/>
) : (
<div className="space-y-4">
<ServerLoginStatusBanner
attempt={lastLoginAttempt}
loading={loginLoading}
canRetryPassword={password.trim().length > 0}
onRetryPassword={() => handleRepeaterLogin(password)}
onRetryBlank={handleRepeaterGuestLogin}
blankRetryLabel="Retry Existing-Access Login"
/>
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
<div className="flex flex-col gap-4">

View File

@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
interface RoomServerPanelProps {
contact: Contact;
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
status: null,
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
setLoginLoading(false);
setLoginError(null);
setAuthenticated(false);
setLastLoginAttempt(null);
setAdvancedOpen(false);
setPaneData({
status: null,
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const performLogin = useCallback(
async (password: string) => {
async (nextPassword: string, method: 'password' | 'blank') => {
if (loginLoading) return;
setLoginLoading(true);
setLoginError(null);
try {
const result = await api.roomLogin(contact.public_key, password);
const result = await api.roomLogin(contact.public_key, nextPassword);
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
setAuthenticated(true);
if (result.authenticated) {
toast.success('Room login confirmed');
toast.success('Login confirmed by the room server.');
} else {
toast.warning('Room login not confirmed', {
description: result.message ?? 'Room login was not confirmed',
toast.warning("Couldn't confirm room login", {
description:
result.message ??
'No confirmation came back from the room server. You can still open tools and try again.',
});
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
setAuthenticated(true);
setLoginError(message);
toast.error('Room login failed', { description: message });
toast.error('Room login request failed', {
description: `${message}. You can still open tools and retry the login from here.`,
});
} finally {
setLoginLoading(false);
}
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const handleLogin = useCallback(
async (password: string) => {
await performLogin(password);
persistAfterLogin(password);
async (nextPassword: string) => {
await performLogin(nextPassword, 'password');
persistAfterLogin(nextPassword);
},
[performLogin, persistAfterLogin]
);
const handleLoginAsGuest = useCallback(async () => {
await performLogin('');
await performLogin('', 'blank');
persistAfterLogin('');
}, [performLogin, persistAfterLogin]);
@@ -207,6 +221,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
const showLoginFailureState =
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
if (!authenticated) {
return (
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest"
guestLabel="Login with Existing Access / Guest"
/>
</div>
</div>
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
<div className="space-y-3">
{showLoginFailureState ? (
<ServerLoginStatusBanner
attempt={lastLoginAttempt}
loading={loginLoading}
canRetryPassword={password.trim().length > 0}
onRetryPassword={() => handleLogin(password)}
onRetryBlank={handleLoginAsGuest}
blankRetryLabel="Retry Existing-Access Login"
showRetryActions={false}
/>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
{showLoginFailureState ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleLogin(password)}
disabled={loginLoading || password.trim().length === 0}
>
Retry Password Login
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Retry Existing-Access Login
</Button>
</div>
) : (
<div />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div>
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
<p className="text-sm text-muted-foreground">{panelTitle}</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
className="self-start sm:self-auto"
>
Refresh ACL Login
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">

View File

@@ -0,0 +1,76 @@
import { Button } from './ui/button';
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
import { cn } from '../lib/utils';
interface ServerLoginStatusBannerProps {
attempt: ServerLoginAttemptState | null;
loading: boolean;
canRetryPassword: boolean;
onRetryPassword: () => Promise<void> | void;
onRetryBlank: () => Promise<void> | void;
passwordRetryLabel?: string;
blankRetryLabel?: string;
showRetryActions?: boolean;
}
export function ServerLoginStatusBanner({
attempt,
loading,
canRetryPassword,
onRetryPassword,
onRetryBlank,
passwordRetryLabel = 'Retry Password Login',
blankRetryLabel = 'Retry Existing-Access Login',
showRetryActions = true,
}: ServerLoginStatusBannerProps) {
if (attempt?.outcome === 'confirmed') {
return null;
}
const tone = getServerLoginAttemptTone(attempt);
const shouldShowActions = showRetryActions;
const toneClassName =
tone === 'success'
? 'border-success/30 bg-success/10 text-success'
: tone === 'warning'
? 'border-warning/30 bg-warning/10 text-warning'
: tone === 'destructive'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: 'border-border bg-muted/40 text-foreground';
return (
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
</p>
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
</div>
{shouldShowActions ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onRetryPassword()}
disabled={loading || !canRetryPassword}
>
{passwordRetryLabel}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onRetryBlank()}
disabled={loading}
>
{blankRetryLabel}
</Button>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -112,7 +112,10 @@ export function RadioSettingsPane({
<NotFetched />
) : (
<div>
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
<KvRow
label="Local Advert"
value={formatAdvertInterval(advertData.advert_interval, 'minutes')}
/>
<KvRow
label="Flood Advert"
value={formatAdvertInterval(advertData.flood_advert_interval)}

View File

@@ -76,11 +76,19 @@ export function formatClockDrift(
return { text: parts.join(''), isLarge: false };
}
export function formatAdvertInterval(val: string | null): string {
export function formatAdvertInterval(
val: string | null,
unit: 'minutes' | 'hours' = 'hours'
): string {
if (val == null) return '—';
const trimmed = val.trim();
if (trimmed === '0') return '<disabled>';
return `${trimmed}h`;
if (unit === 'hours') return `${trimmed}h`;
const mins = parseInt(trimmed, 10);
if (isNaN(mins)) return trimmed;
if (mins >= 60 && mins % 60 === 0) return `${mins / 60}h`;
if (mins >= 60) return `${Math.floor(mins / 60)}h${mins % 60}m`;
return `${mins}m`;
}
function formatFetchedRelative(fetchedAt: number): string {

View File

@@ -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&apos;s private key to be available (firmware must have{' '}
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared &mdash; 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&apos;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 &rarr; Radio &rarr; 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&apos;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">

View File

@@ -13,6 +13,7 @@ import {
} from '../utils/lastViewedConversation';
import { findPublicChannel } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey';
import { toast } from '../components/ui/sonner';
import type { Channel, Contact, Conversation } from '../types';
interface UseConversationRouterArgs {
@@ -137,6 +138,11 @@ export function useConversationRouter({
// No hash or unresolvable — default to Public
const publicConversation = getPublicChannelConversation();
if (publicConversation) {
if (hashConv?.type === 'channel') {
const token =
hashConv.name.length > 16 ? hashConv.name.substring(0, 16) + '…' : hashConv.name;
toast.error(`Channel not found: ${token}`);
}
setActiveConversationState(publicConversation);
hasSetDefaultConversation.current = true;
}
@@ -162,6 +168,9 @@ export function useConversationRouter({
}
// Contact hash didn't match — fall back to Public if channels loaded.
const token =
hashConv.name.length > 16 ? hashConv.name.substring(0, 16) + '…' : hashConv.name;
toast.error(`Contact not found: ${token}`);
const publicConversation = getPublicChannelConversation();
if (publicConversation) {
setActiveConversationState(publicConversation);

View File

@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
type ServerLoginKind = 'repeater' | 'room';
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
type StoredPassword = {
password: string;
};
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
const inMemoryPasswords = new Map<string, StoredPassword>();
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
}
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
useEffect(() => {
const stored = loadStoredPassword(kind, publicKey);
if (!stored) {
setPassword('');
if (stored) {
setPassword(stored.password);
setRememberPassword(true);
return;
}
const inMemoryStored = inMemoryPasswords.get(storageKey);
if (inMemoryStored) {
setPassword(inMemoryStored.password);
setRememberPassword(false);
return;
}
setPassword(stored.password);
setRememberPassword(true);
}, [kind, publicKey]);
setPassword('');
setRememberPassword(false);
}, [kind, publicKey, storageKey]);
const persistAfterLogin = useCallback(
(submittedPassword: string) => {
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
if (!rememberPassword) {
try {
localStorage.removeItem(storageKey);
} catch {
// localStorage may be unavailable
}
setPassword('');
return;
} else {
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
}
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
setPassword(trimmedPassword);
},
[rememberPassword, storageKey]

View File

@@ -15,6 +15,11 @@ import type {
RepeaterLppTelemetryResponse,
CommandResponse,
} from '../types';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
@@ -41,6 +46,7 @@ interface PaneData {
interface RepeaterDashboardCacheEntry {
loggedIn: boolean;
loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
@@ -119,6 +125,7 @@ function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry |
return {
loggedIn: cached.loggedIn,
loginError: cached.loginError,
lastLoginAttempt: cached.lastLoginAttempt,
paneData: clonePaneData(cached.paneData),
paneStates: normalizePaneStates(cached.paneStates),
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
@@ -130,6 +137,7 @@ function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
repeaterDashboardCache.set(publicKey, {
loggedIn: entry.loggedIn,
loginError: entry.loginError,
lastLoginAttempt: entry.lastLoginAttempt,
paneData: clonePaneData(entry.paneData),
paneStates: normalizePaneStates(entry.paneStates),
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
@@ -173,6 +181,7 @@ export interface UseRepeaterDashboardResult {
loggedIn: boolean;
loginLoading: boolean;
loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
@@ -203,6 +212,9 @@ export function useRepeaterDashboard(
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
cachedState?.lastLoginAttempt ?? null
);
const [paneData, setPaneData] = useState<PaneData>(
cachedState?.paneData ?? createInitialPaneData
@@ -243,11 +255,20 @@ export function useRepeaterDashboard(
cacheState(conversationId, {
loggedIn,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
});
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
}, [
consoleHistory,
conversationId,
loggedIn,
loginError,
lastLoginAttempt,
paneData,
paneStates,
]);
useEffect(() => {
paneDataRef.current = paneData;
@@ -267,12 +288,14 @@ export function useRepeaterDashboard(
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
const method = password.trim().length > 0 ? 'password' : 'blank';
setLoginLoading(true);
setLoginError(null);
try {
const result = await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'repeater'));
setLoggedIn(true);
if (!result.authenticated) {
const msg = result.message ?? 'Repeater login was not confirmed';
@@ -282,6 +305,7 @@ export function useRepeaterDashboard(
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, msg, 'repeater'));
setLoggedIn(true);
setLoginError(msg);
toast.error('Login request failed', {
@@ -475,6 +499,7 @@ export function useRepeaterDashboard(
loggedIn,
loginLoading,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,

View File

@@ -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();

View File

@@ -11,6 +11,7 @@ const mockHook: {
loggedIn: false,
loginLoading: false,
loginError: null,
lastLoginAttempt: null,
paneData: {
status: null,
nodeInfo: null,

View File

@@ -56,22 +56,84 @@ describe('RoomServerPanel', () => {
status: 'timeout',
authenticated: false,
message:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
});
const onAuthenticatedChange = vi.fn();
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
fireEvent.click(screen.getByText('Login with ACL / Guest'));
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
await waitFor(() => {
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.getByText('Show Tools')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
expect(screen.getByText('Retry Existing-Access Login')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith("Couldn't confirm room login", {
description:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
});
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
});
it('retains the last password for one-click retry after unlocking the panel', async () => {
mockApi.roomLogin
.mockResolvedValueOnce({
status: 'timeout',
authenticated: false,
message: 'No reply heard',
})
.mockResolvedValueOnce({
status: 'ok',
authenticated: true,
message: null,
});
render(<RoomServerPanel contact={roomContact} />);
fireEvent.change(screen.getByLabelText('Repeater password'), {
target: { value: 'secret-room-password' },
});
fireEvent.click(screen.getByText('Login with Password'));
await waitFor(() => {
expect(screen.getByText('Retry Password Login')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Retry Password Login'));
await waitFor(() => {
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
1,
roomContact.public_key,
'secret-room-password'
);
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
2,
roomContact.public_key,
'secret-room-password'
);
});
});
it('shows only a success toast after a confirmed login', async () => {
mockApi.roomLogin.mockResolvedValueOnce({
status: 'ok',
authenticated: true,
message: null,
});
render(<RoomServerPanel contact={roomContact} />);
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
await waitFor(() => {
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.queryByText('Login confirmed by the room server.')).not.toBeInTheDocument();
expect(screen.queryByText('Retry Password Login')).not.toBeInTheDocument();
expect(screen.queryByText('Retry Existing-Access Login')).not.toBeInTheDocument();
expect(mockToast.success).toHaveBeenCalledWith('Login confirmed by the room server.');
});
});

View File

@@ -8,70 +8,24 @@ describe('useRememberedServerPassword', () => {
localStorage.clear();
});
it('loads remembered passwords from localStorage', () => {
localStorage.setItem(
'remoteterm-server-password:repeater:abc123',
JSON.stringify({ password: 'stored-secret' })
it('restores the last in-memory password when local remember is disabled', () => {
const { result, unmount } = renderHook(() =>
useRememberedServerPassword('room', 'aa'.repeat(32))
);
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
expect(result.current.password).toBe('stored-secret');
expect(result.current.rememberPassword).toBe(true);
});
it('stores passwords after login when remember is enabled', () => {
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
act(() => {
result.current.setRememberPassword(true);
result.current.setPassword('room-secret');
result.current.persistAfterLogin('room-secret');
});
act(() => {
result.current.persistAfterLogin(' hello ');
});
expect(result.current.password).toBe('room-secret');
unmount();
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
JSON.stringify({ password: 'hello' })
);
expect(result.current.password).toBe('hello');
});
it('clears stored passwords when login is done with remember disabled', () => {
localStorage.setItem(
'remoteterm-server-password:repeater:abc123',
JSON.stringify({ password: 'stored-secret' })
const { result: remounted } = renderHook(() =>
useRememberedServerPassword('room', 'aa'.repeat(32))
);
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
act(() => {
result.current.setRememberPassword(false);
});
act(() => {
result.current.persistAfterLogin('new-secret');
});
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
expect(result.current.password).toBe('');
});
it('preserves remembered passwords on guest login when remember stays enabled', () => {
localStorage.setItem(
'remoteterm-server-password:room:room-key',
JSON.stringify({ password: 'stored-secret' })
);
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
act(() => {
result.current.persistAfterLogin('');
});
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
JSON.stringify({ password: 'stored-secret' })
);
expect(result.current.password).toBe('stored-secret');
expect(remounted.current.password).toBe('room-secret');
expect(remounted.current.rememberPassword).toBe(false);
});
});

View File

@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe(null);
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
expect(result.current.lastLoginAttempt?.outcome).toBe('confirmed');
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
});
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe('Auth failed');
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
expect(result.current.lastLoginAttempt?.outcome).toBe('not_confirmed');
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
description: 'Auth failed',
});
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe('Network error');
expect(result.current.lastLoginAttempt?.heardBack).toBe(false);
expect(result.current.lastLoginAttempt?.outcome).toBe('request_failed');
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
description:
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',

View File

@@ -0,0 +1,107 @@
import type { RepeaterLoginResponse } from '../types';
export type ServerLoginMethod = 'password' | 'blank';
export type ServerLoginAttemptState =
| {
method: ServerLoginMethod;
outcome: 'confirmed';
summary: string;
details: string | null;
heardBack: true;
at: number;
}
| {
method: ServerLoginMethod;
outcome: 'not_confirmed';
summary: string;
details: string | null;
heardBack: boolean;
at: number;
}
| {
method: ServerLoginMethod;
outcome: 'request_failed';
summary: string;
details: string | null;
heardBack: false;
at: number;
};
export function getServerLoginMethodLabel(
method: ServerLoginMethod,
blankLabel = 'existing-access'
): string {
return method === 'password' ? 'password' : blankLabel;
}
export function getServerLoginAttemptTone(
attempt: ServerLoginAttemptState | null
): 'success' | 'warning' | 'destructive' | 'muted' {
if (!attempt) return 'muted';
if (attempt.outcome === 'confirmed') return 'success';
if (attempt.outcome === 'not_confirmed') return 'warning';
return 'destructive';
}
export function buildServerLoginAttemptFromResponse(
method: ServerLoginMethod,
result: RepeaterLoginResponse,
entityLabel: string
): ServerLoginAttemptState {
const methodLabel = getServerLoginMethodLabel(method);
const at = Date.now();
const target = `the ${entityLabel}`;
if (result.authenticated) {
return {
method,
outcome: 'confirmed',
summary: `Login confirmed by ${target}.`,
details: null,
heardBack: true,
at,
};
}
if (result.status === 'timeout') {
return {
method,
outcome: 'not_confirmed',
summary: `We couldn't confirm the login.`,
details:
result.message ??
`No confirmation came back from ${target} after the ${methodLabel} login attempt.`,
heardBack: false,
at,
};
}
return {
method,
outcome: 'not_confirmed',
summary: `Login was not confirmed.`,
details:
result.message ??
`${target} responded, but did not confirm the ${methodLabel} login attempt.`,
heardBack: true,
at,
};
}
export function buildServerLoginAttemptFromError(
method: ServerLoginMethod,
message: string,
entityLabel: string
): ServerLoginAttemptState {
const methodLabel = getServerLoginMethodLabel(method);
const target = `the ${entityLabel}`;
return {
method,
outcome: 'request_failed',
summary: `We couldn't send the login request.`,
details: `${target} never acknowledged the ${methodLabel} login attempt. ${message}`,
heardBack: false,
at: Date.now(),
};
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,14 @@ contact/channel sync operations, and default channel management.
"""
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from meshcore import EventType
from meshcore.events import Event
import app.radio_sync as radio_sync
from app.models import Favorite
from app.radio import RadioManager, radio_manager
from app.radio_sync import (
@@ -36,8 +38,6 @@ from app.repository import (
@pytest.fixture(autouse=True)
def reset_sync_state():
"""Reset polling pause state, sync timestamp, and radio_manager before/after each test."""
import app.radio_sync as radio_sync
prev_mc = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_max_channels = radio_manager.max_channels
@@ -45,12 +45,20 @@ def reset_sync_state():
prev_slot_by_key = radio_manager._channel_slot_by_key.copy()
prev_key_by_slot = radio_manager._channel_key_by_slot.copy()
prev_pending_channel_key_by_slot = radio_manager._pending_message_channel_key_by_slot.copy()
prev_contact_reconcile_task = radio_sync._contact_reconcile_task
radio_sync._polling_pause_count = 0
radio_sync._last_contact_sync = 0.0
yield
if (
radio_sync._contact_reconcile_task is not None
and radio_sync._contact_reconcile_task is not prev_contact_reconcile_task
and not radio_sync._contact_reconcile_task.done()
):
radio_sync._contact_reconcile_task.cancel()
radio_sync._polling_pause_count = 0
radio_sync._last_contact_sync = 0.0
radio_sync._contact_reconcile_task = prev_contact_reconcile_task
radio_manager._meshcore = prev_mc
radio_manager._operation_lock = prev_lock
radio_manager.max_channels = prev_max_channels
@@ -433,7 +441,7 @@ class TestSyncAndOffloadAll:
"""Test session-local contact radio residency reset behavior."""
@pytest.mark.asyncio
async def test_clears_stale_contact_on_radio_flags_before_reload(self, test_db):
async def test_clears_stale_contact_on_radio_flags_before_background_reconcile(self, test_db):
await _insert_contact(KEY_A, "Alice", on_radio=True)
await _insert_contact(KEY_B, "Bob", on_radio=True)
@@ -441,8 +449,8 @@ class TestSyncAndOffloadAll:
with (
patch(
"app.radio_sync.sync_and_offload_contacts",
new=AsyncMock(return_value={"synced": 0, "removed": 0}),
"app.radio_sync.sync_contacts_from_radio",
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}}),
),
patch(
"app.radio_sync.sync_and_offload_channels",
@@ -450,8 +458,7 @@ class TestSyncAndOffloadAll:
),
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
patch(
"app.radio_sync.sync_recent_contacts_to_radio",
new=AsyncMock(return_value={"loaded": 0, "already_on_radio": 0, "failed": 0}),
"app.radio_sync.start_background_contact_reconciliation",
),
):
await sync_and_offload_all(mock_mc)
@@ -461,6 +468,30 @@ class TestSyncAndOffloadAll:
assert alice is not None and alice.on_radio is False
assert bob is not None and bob.on_radio is False
@pytest.mark.asyncio
async def test_starts_background_contact_reconcile_with_radio_snapshot(self, test_db):
mock_mc = MagicMock()
radio_contacts = {KEY_A: {"public_key": KEY_A}}
with (
patch(
"app.radio_sync.sync_contacts_from_radio",
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
),
patch(
"app.radio_sync.sync_and_offload_channels",
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
),
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
):
result = await sync_and_offload_all(mock_mc)
mock_start.assert_called_once_with(
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
)
assert result["contact_reconcile_started"] is True
@pytest.mark.asyncio
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
@@ -1036,6 +1067,98 @@ class TestSyncAndOffloadContacts:
assert KEY_A in mock_mc._contacts
class TestBackgroundContactReconcile:
"""Test the yielding background contact reconcile loop."""
@pytest.mark.asyncio
async def test_rechecks_desired_set_before_deleting_contact(self, test_db):
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
alice = await ContactRepository.get_by_key(KEY_A)
bob = await ContactRepository.get_by_key(KEY_B)
assert alice is not None
assert bob is not None
mock_mc = MagicMock()
mock_mc.is_connected = True
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
mock_mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
radio_manager._meshcore = mock_mc
@asynccontextmanager
async def _radio_operation(*args, **kwargs):
del args, kwargs
yield mock_mc
with (
patch.object(
radio_sync.radio_manager,
"radio_operation",
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
),
patch(
"app.radio_sync.get_contacts_selected_for_radio_sync",
side_effect=[[bob], [alice, bob], [alice, bob]],
),
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
):
await radio_sync._reconcile_radio_contacts_in_background(
initial_radio_contacts={KEY_A: {"public_key": KEY_A}},
expected_mc=mock_mc,
)
mock_mc.commands.remove_contact.assert_not_called()
mock_mc.commands.add_contact.assert_awaited_once()
payload = mock_mc.commands.add_contact.call_args.args[0]
assert payload["public_key"] == KEY_B
@pytest.mark.asyncio
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
extra_key = "cc" * 32
await _insert_contact(extra_key, "Carol", last_contacted=1000)
mock_mc = MagicMock()
mock_mc.is_connected = True
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
mock_mc.commands.add_contact = AsyncMock()
radio_manager._meshcore = mock_mc
acquire_count = 0
@asynccontextmanager
async def _radio_operation(*args, **kwargs):
del args, kwargs
nonlocal acquire_count
acquire_count += 1
yield mock_mc
with (
patch.object(
radio_sync.radio_manager,
"radio_operation",
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
),
patch("app.radio_sync.get_contacts_selected_for_radio_sync", return_value=[]),
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
):
await radio_sync._reconcile_radio_contacts_in_background(
initial_radio_contacts={
KEY_A: {"public_key": KEY_A},
KEY_B: {"public_key": KEY_B},
extra_key: {"public_key": extra_key},
},
expected_mc=mock_mc,
)
assert acquire_count == 2
assert mock_mc.commands.remove_contact.await_count == 3
mock_mc.commands.add_contact.assert_not_called()
class TestSyncAndOffloadChannels:
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""

2
uv.lock generated
View File

@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.6.0"
version = "3.6.1"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },