Compare commits

..

20 Commits

Author SHA1 Message Date
Jack Kingsman e695d629b9 Updating changelog + build for 3.12.2 2026-04-21 13:10:25 -07:00
Jack Kingsman 300677aca3 Disambiguate colliding sensors and display all. Closes #211. 2026-04-21 10:14:09 -07:00
jkingsman b89f7ce76b Add missing docs around pk export 2026-04-20 20:10:21 -07:00
Jack Kingsman 82bd25a09f Merge pull request #210 from jkingsman/radio-config-export
Add config export
2026-04-20 19:58:58 -07:00
Jack Kingsman 7528e4121f Add config export 2026-04-20 19:55:25 -07:00
Jack Kingsman b8f0228f68 Merge pull request #209 from kizniche/fix-stale-mqtt-radio-values
Fix Community MQTT publishing stale firmware_version and model
2026-04-20 19:48:11 -07:00
Kizniche 25089930f1 fIX Community MQTT publishing stale firmware_version and model 2026-04-20 21:47:38 -04:00
Jack Kingsman 291bd85c78 Better env var/config knob exposure 2026-04-20 16:43:43 -07:00
Jack Kingsman 4bc87b4a0f Add debug radio details to radio pane 2026-04-20 16:10:24 -07:00
Jack Kingsman 6d0434d59e Add more intense logging on errors 2026-04-20 16:10:24 -07:00
Jack Kingsman f22184c166 Update README.md to be more clear about core purpose 2026-04-19 23:40:23 -07:00
Jack Kingsman d10de8abf7 CI/CD improvements for codeql 2026-04-19 23:33:45 -07:00
Jack Kingsman 5f78294cd1 Longer linger for web push mobile error 2026-04-19 23:04:36 -07:00
Jack Kingsman 6b81dd3082 Updating changelog + build for 3.12.1 2026-04-19 21:18:26 -07:00
Jack Kingsman cc2b16e53f Test fix 2026-04-19 21:14:38 -07:00
Jack Kingsman 330007e120 Be smarter about web push not being available on snakeoil certs for mobile 2026-04-19 21:10:17 -07:00
Jack Kingsman f5a2a21f11 Fix e2e tests 2026-04-19 20:45:11 -07:00
Jack Kingsman a3e62885d4 Merge pull request #206 from jkingsman/dependabot/uv/uv-2c6491f7af
Bump the uv group across 1 directory with 2 updates
2026-04-19 19:36:12 -07:00
Jack Kingsman dbdd722c48 Merge pull request #207 from jkingsman/channel-mute
Add channel mute
2026-04-19 19:35:52 -07:00
dependabot[bot] b8683e57d8 Bump the uv group across 1 directory with 2 updates
Bumps the uv group with 2 updates in the / directory: [pytest](https://github.com/pytest-dev/pytest) and [requests](https://github.com/psf/requests).


Updates `pytest` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

Updates `requests` from 2.32.5 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
  dependency-group: uv
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 01:44:21 +00:00
33 changed files with 935 additions and 65 deletions
+10
View File
@@ -0,0 +1,10 @@
name: "RemoteTerm CodeQL config"
# Exclude rules that flag intentional design decisions:
# - AES-ECB is required by the MeshCore radio protocol wire format
# - Repeater/room passwords are not meaningfully sensitive secrets
query-filters:
- exclude:
id: py/weak-cryptographic-algorithm
- exclude:
id: js/clear-text-storage-of-sensitive-data
+3
View File
@@ -4,6 +4,9 @@ on:
push:
pull_request:
permissions:
contents: read
jobs:
backend-checks:
runs-on: ubuntu-latest
+35
View File
@@ -0,0 +1,35 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"
permissions:
contents: read
security-events: write
jobs:
analyze:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [javascript-typescript, python]
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
config-file: .github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
+3
View File
@@ -25,6 +25,9 @@ concurrency:
group: publish-aur
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-aur:
runs-on: ubuntu-latest
+2
View File
@@ -321,6 +321,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
| GET | `/api/radio/private-key` | Export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`) |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
@@ -504,6 +505,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
+17
View File
@@ -1,3 +1,20 @@
## [3.12.2] - 2026-04-21
* Feature: Auto-disambiguate colliding LPP sensor names
* Feature: Radio config import/export
* Bugfix: Don't push stale firmware version/model on community MQTT
* Misc: Expose env vars in debug blob
* Misc: Longer linger for web push error
* Misc: Docs, test, & CI/CD improvements
## [3.12.1] - 2026-04-19
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
* Feature: Channel mute
* Misc: HA Documentation improvements
* Misc: Bump deps & update tests
* Misc: Improve warnings around web push in untrusted contexts
## [3.12.0] - 2026-04-17
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
+5 -3
View File
@@ -1,6 +1,8 @@
# RemoteTerm for MeshCore
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
Backend server + browser interface for MeshCore mesh radio networks, providing a rich, web-based power-user management and messaging system through a companion radio.
Connect your radio over Serial, TCP, or BLE, and then you can:
* Send and receive DMs and channel messages
* Cache all received packets, decrypting as you gain keys
@@ -8,8 +10,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
* Access your radio remotely over your network or VPN
* Search for hashtag channel names for channels you don't have keys for yet
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14 firmwares which support multibyte pathing
* Forward packets, messages, and automatic repeater telemetry to MQTT, Home Assistant, LetsMesh, MeshRank, SQS, Apprise, etc.
* Use the more recent 1.14+ firmwares which support multibyte pathing
* Visualize the mesh as a map or node set, view repeater stats, and more!
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
+1
View File
@@ -9,6 +9,7 @@ These are intended for diagnosing or working around radios that behave oddly.
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading (see [Contact Loading Issues](#contact-loading-issues) below) |
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Only enable on a trusted network when you need to retrieve the key (e.g. for backup or migration). |
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
+1
View File
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
enable_local_private_key_export: bool = False
load_with_autoevict: bool = False
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
+15 -1
View File
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
device_info = await self._fetch_device_info()
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
# radio_lifecycle) over the per-module _cached_device_info, which was only
# cleared on module restart and therefore served stale firmware versions after
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
# where device_info_loaded is False.
if radio_manager.device_info_loaded:
raw_ver = radio_manager.firmware_version or "unknown"
fw_build = radio_manager.firmware_build or ""
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
device_info = {
"model": radio_manager.device_model or "unknown",
"firmware_version": fw_str,
}
else:
device_info = await self._fetch_device_info()
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
status_topic = _build_status_topic(settings, pubkey_hex)
+24 -8
View File
@@ -115,6 +115,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
return f"lpp_{type_name}_ch{channel}"
def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]:
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
"""
counts: dict[str, int] = {}
result: list[tuple[dict, str, int]] = []
for sensor in lpp_sensors:
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
n = counts.get(base, 0) + 1
counts[base] = n
result.append((sensor, base if n == 1 else f"{base}_{n}", n))
return result
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Build the flat HA state payload for a repeater telemetry snapshot."""
payload: dict[str, Any] = {}
@@ -123,8 +139,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
if field is not None:
payload[field] = data.get(field)
for sensor in data.get("lpp_sensors", []) or []:
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
payload[key] = sensor.get("value")
return payload
@@ -139,16 +154,19 @@ def _lpp_discovery_configs(
) -> list[tuple[str, dict]]:
"""Build HA discovery configs for a repeater's LPP sensors."""
configs: list[tuple[str, dict]] = []
for sensor in lpp_sensors:
for sensor, field, occurrence in _assign_lpp_keys(lpp_sensors):
type_name = sensor.get("type_name", "unknown")
channel = sensor.get("channel", 0)
field = _lpp_sensor_key(type_name, channel)
meta = _LPP_HA_META.get(type_name, {})
nid = _node_id(pub_key)
object_id = field
display = type_name.replace("_", " ").title()
name = f"{display} (Ch {channel})"
name = (
f"{display} (Ch {channel})"
if occurrence == 1
else f"{display} (Ch {channel}) #{occurrence}"
)
cfg: dict[str, Any] = {
"name": name,
@@ -731,9 +749,7 @@ class MqttHaModule(FanoutModule):
payload = _repeater_telemetry_payload(data)
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
rediscover = False
for sensor in lpp_sensors:
# Check if discovery for this sensor has been published yet
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
for _, key, _ in _assign_lpp_keys(lpp_sensors):
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
if expected_topic not in self._discovery_topics:
rediscover = True
+19
View File
@@ -180,6 +180,25 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
@app.middleware("http")
async def log_server_errors(request: Request, call_next):
"""Capture 5xx errors and unhandled exceptions into the log ring buffer.
Starlette writes unhandled-exception tracebacks to stderr, bypassing
Python logging, so they never reach the debug dump. This middleware
catches them and logs via ``logger.exception()`` so the full traceback
is preserved in the ring buffer for the ``GET /api/debug`` snapshot.
"""
try:
response = await call_next(request)
except Exception:
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
raise
if response.status_code >= 500:
logger.error("HTTP %d on %s %s", response.status_code, request.method, request.url.path)
return response
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(debug.router, prefix="/api")
+34 -5
View File
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
remediation_flags: dict[str, bool]
class DebugContactAudit(BaseModel):
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
basic_auth_enabled: bool = False
class DebugEnvironment(BaseModel):
connection_type: str
serial_port: str
serial_baudrate: int
tcp_host: str
tcp_port: int
ble_address: str
log_level: str
database_path: str
disable_bots: bool
enable_message_poll_fallback: bool
force_channel_slot_reconfigure: bool
load_with_autoevict: bool
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
environment: DebugEnvironment
health: DebugHealthSummary
settings: DebugAppSettings
runtime: DebugRuntimeInfo
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None
def _build_environment() -> DebugEnvironment:
return DebugEnvironment(
connection_type=settings.connection_type,
serial_port=settings.serial_port,
serial_baudrate=settings.serial_baudrate,
tcp_host=settings.tcp_host,
tcp_port=settings.tcp_port,
ble_address=settings.ble_address,
log_level=settings.log_level,
database_path=settings.database_path,
disable_bots=settings.disable_bots,
enable_message_poll_fallback=settings.enable_message_poll_fallback,
force_channel_slot_reconfigure=settings.force_channel_slot_reconfigure,
load_with_autoevict=settings.load_with_autoevict,
)
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
return DebugAppSettings(
max_radio_contacts=app_settings.max_radio_contacts,
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
captured_at=datetime.now(UTC).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
environment=_build_environment(),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo(
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
remediation_flags={
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
},
),
database=DebugDatabaseInfo(
total_dms=message_totals["total_dms"],
+4
View File
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
# Core stats
battery_mv: int | None = None
uptime_secs: int | None = None
queue_len: int | None = None
errors: int | None = None
# Radio stats
noise_floor: int | None = None
last_rssi: int | None = None
@@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"timestamp": raw_stats.get("timestamp"),
"battery_mv": raw_stats.get("battery_mv"),
"uptime_secs": raw_stats.get("uptime_secs"),
"queue_len": raw_stats.get("queue_len"),
"errors": raw_stats.get("errors"),
"noise_floor": raw_stats.get("noise_floor"),
"last_rssi": raw_stats.get("last_rssi"),
"last_snr": raw_stats.get("last_snr"),
+24
View File
@@ -385,6 +385,30 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
return await get_radio_config()
@router.get("/private-key")
async def get_private_key() -> dict:
"""Return the in-memory private key (exported from radio on startup).
Gated behind MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true.
"""
from app.config import settings
from app.keystore import get_private_key as ks_get
if not settings.enable_local_private_key_export:
raise HTTPException(
status_code=403,
detail="Private key export is disabled (set MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true)",
)
key = ks_get()
if key is None:
raise HTTPException(
status_code=404,
detail="Private key not available (not exported from radio)",
)
return {"private_key": key.hex()}
@router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only."""
+6
View File
@@ -258,6 +258,12 @@ async def send_channel_message_with_effective_scope(
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if send_result.type == EventType.ERROR:
logger.error(
"Radio returned error during %s for channel %s: %s",
action_label,
channel.name,
send_result.payload,
)
radio_manager.invalidate_cached_channel_slot(channel_key)
else:
radio_manager.note_channel_slot_used(channel_key)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.12.0",
"version": "3.12.2",
"type": "module",
"scripts": {
"dev": "vite",
+1
View File
@@ -96,6 +96,7 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(config),
}),
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
setPrivateKey: (privateKey: string) =>
fetchJson<{ status: string }>('/radio/private-key', {
method: 'PUT',
@@ -1,4 +1,5 @@
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
import { useMemo } from 'react';
import { RepeaterPane, NotFetched, LppSensorRow, formatLppLabel } from './repeaterPaneShared';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
disabled?: boolean;
}) {
const { distanceUnit } = useDistanceUnit();
// Build disambiguated labels matching the telemetry history chart names
const labels = useMemo(() => {
if (!data) return [];
const counts = new Map<string, number>();
return data.sensors.map((s) => {
const base = `${s.type_name}_${s.channel}`;
const n = (counts.get(base) ?? 0) + 1;
counts.set(base, n);
return formatLppLabel(s.type_name) + ` Ch${s.channel}` + (n > 1 ? ` (${n})` : '');
});
}, [data]);
return (
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
) : (
<div className="space-y-0.5">
{data.sensors.map((sensor, i) => (
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
))}
</div>
)}
@@ -37,9 +37,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
// Stable color rotation for dynamic LPP sensors
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */
function lppKey(s: TelemetryLppSensor): string {
return `lpp_${s.type_name}_ch${s.channel}`;
/** Assign disambiguated flat keys to an array of LPP sensors.
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
function assignLppKeys(
sensors: TelemetryLppSensor[]
): { sensor: TelemetryLppSensor; key: string; occurrence: number }[] {
const counts = new Map<string, number>();
return sensors.map((s) => {
const base = `lpp_${s.type_name}_ch${s.channel}`;
const n = (counts.get(base) ?? 0) + 1;
counts.set(base, n);
return { sensor: s, key: n === 1 ? base : `${base}_${n}`, occurrence: n };
});
}
const TOOLTIP_STYLE = {
@@ -93,11 +102,10 @@ export function TelemetryHistoryPane({
// Discover unique LPP sensors across all history entries
const lppMetrics = useMemo(() => {
const seen = new Map<string, { type_name: string; channel: number }>();
const seen = new Map<string, { type_name: string; channel: number; occurrence: number }>();
for (const e of entries) {
for (const s of e.data.lpp_sensors ?? []) {
const k = lppKey(s);
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel });
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
}
}
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
@@ -106,7 +114,8 @@ export function TelemetryHistoryPane({
const label =
info.type_name.charAt(0).toUpperCase() +
info.type_name.slice(1).replace(/_/g, ' ') +
` Ch${info.channel}`;
` Ch${info.channel}` +
(info.occurrence > 1 ? ` (${info.occurrence})` : '');
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
result.push({
key: k,
@@ -148,9 +157,9 @@ export function TelemetryHistoryPane({
uptime_seconds: d.uptime_seconds,
};
// Flatten LPP sensors into the point, converting units as needed
for (const s of d.lpp_sensors ?? []) {
for (const { sensor: s, key } of assignLppKeys(d.lpp_sensors ?? [])) {
if (typeof s.value === 'number') {
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
}
}
return point;
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
}
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
const label = formatLppLabel(sensor.type_name);
export function LppSensorRow({
sensor,
unitPref,
label: labelOverride,
}: {
sensor: LppSensor;
unitPref?: string;
label?: string;
}) {
const label = labelOverride ?? formatLppLabel(sensor.type_name);
if (typeof sensor.value === 'object' && sensor.value !== null) {
// Multi-value sensor (GPS, accelerometer, etc.)
@@ -1,11 +1,20 @@
import { useState, useEffect, useMemo } from 'react';
import { MapPinned } from 'lucide-react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { Checkbox } from '../ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { api } from '../../api';
import { RADIO_PRESETS } from '../../utils/radioPresets';
import { stripRegionScopePrefix } from '../../utils/regionScope';
import type {
@@ -17,8 +26,116 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RadioStatsSnapshot,
} from '../../types';
function formatUptime(secs: number): string {
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
const minutes = Math.floor((secs % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatAirtime(secs: number): string {
if (secs < 60) return `${secs}s`;
const hours = Math.floor(secs / 3600);
const minutes = Math.floor((secs % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
return (
<div className="flex items-center justify-between gap-2 py-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={`text-xs font-mono tabular-nums ${warn ? 'text-warning font-semibold' : ''}`}
>
{value}
</span>
</div>
);
}
function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) {
const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null;
const packets = {
recv: stats.packets_recv,
sent: stats.packets_sent,
flood_tx: stats.flood_tx,
direct_tx: stats.direct_tx,
flood_rx: stats.flood_rx,
direct_rx: stats.direct_rx,
};
return (
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
Radio Details
</summary>
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
{age !== null && (
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Updated {age < 5 ? 'just now' : `${age}s ago`}
</p>
)}
{/* Core */}
{stats.uptime_secs != null && (
<StatRow label="Uptime" value={formatUptime(stats.uptime_secs)} />
)}
{stats.battery_mv != null && stats.battery_mv > 0 && (
<StatRow label="Battery" value={`${(stats.battery_mv / 1000).toFixed(2)}V`} />
)}
{stats.queue_len != null && (
<StatRow
label="TX Queue"
value={`${stats.queue_len} / 16`}
warn={stats.queue_len >= 14}
/>
)}
{stats.errors != null && (
<StatRow label="Errors" value={String(stats.errors)} warn={stats.errors > 0} />
)}
{/* RF */}
{stats.noise_floor != null && (
<StatRow label="Noise Floor" value={`${stats.noise_floor} dBm`} />
)}
{stats.last_rssi != null && <StatRow label="Last RSSI" value={`${stats.last_rssi} dBm`} />}
{stats.last_snr != null && <StatRow label="Last SNR" value={`${stats.last_snr} dB`} />}
{/* Airtime */}
{(stats.tx_air_secs != null || stats.rx_air_secs != null) && (
<>
{stats.tx_air_secs != null && (
<StatRow label="TX Airtime" value={formatAirtime(stats.tx_air_secs)} />
)}
{stats.rx_air_secs != null && (
<StatRow label="RX Airtime" value={formatAirtime(stats.rx_air_secs)} />
)}
</>
)}
{/* Packets */}
{packets.recv != null && <StatRow label="Packets Received" value={String(packets.recv)} />}
{packets.sent != null && <StatRow label="Packets Sent" value={String(packets.sent)} />}
{packets.flood_tx != null && <StatRow label="Flood TX" value={String(packets.flood_tx)} />}
{packets.flood_rx != null && <StatRow label="Flood RX" value={String(packets.flood_rx)} />}
{packets.direct_tx != null && (
<StatRow label="Direct TX" value={String(packets.direct_tx)} />
)}
{packets.direct_rx != null && (
<StatRow label="Direct RX" value={String(packets.direct_rx)} />
)}
</div>
</details>
);
}
export function SettingsRadioSection({
config,
health,
@@ -320,6 +437,169 @@ export function SettingsRadioSection({
}
};
const importInputRef = useRef<HTMLInputElement>(null);
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
const buildConfigProfile = () => ({
version: 1,
exported_at: new Date().toISOString(),
name: config.name,
lat: config.lat,
lon: config.lon,
tx_power: config.tx_power,
radio: { ...config.radio },
path_hash_mode: config.path_hash_mode,
advert_location_source: config.advert_location_source ?? 'current',
multi_acks_enabled: config.multi_acks_enabled ?? false,
});
const downloadJson = (profile: object, suffix: string) => {
const blob = new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeName = (config.name || 'radio').replace(/[^a-zA-Z0-9_-]/g, '_');
const timestamp = new Date()
.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.replace(/[/:, ]+/g, '-');
a.download = `${safeName}-${suffix}-${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleExportConfig = async () => {
const profile = buildConfigProfile();
try {
const { private_key } = await api.getPrivateKey();
downloadJson({ ...profile, private_key }, 'config');
toast.success('Export generated with private key');
} catch {
downloadJson(profile, 'config');
toast.info('Export generated without private key', {
description: 'See README_ADVANCED.md for private key export enable',
});
}
};
const validateImportData = (
data: unknown
): data is {
name: string;
radio: { freq: number; bw: number; sf: number; cr: number };
[k: string]: unknown;
} =>
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof (data as Record<string, unknown>).name === 'string' &&
'radio' in data &&
typeof (data as Record<string, unknown>).radio === 'object' &&
(data as Record<string, unknown>).radio !== null &&
typeof (data as Record<string, Record<string, unknown>>).radio.freq === 'number' &&
typeof (data as Record<string, Record<string, unknown>>).radio.bw === 'number' &&
typeof (data as Record<string, Record<string, unknown>>).radio.sf === 'number' &&
typeof (data as Record<string, Record<string, unknown>>).radio.cr === 'number';
const populateFormFromImport = (data: Record<string, unknown>) => {
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
setName(data.name as string);
if (typeof data.lat === 'number') setLat(String(data.lat));
if (typeof data.lon === 'number') setLon(String(data.lon));
if (typeof data.tx_power === 'number') setTxPower(String(data.tx_power));
setFreq(String(radio.freq));
setBw(String(radio.bw));
setSf(String(radio.sf));
setCr(String(radio.cr));
if (typeof data.path_hash_mode === 'number') setPathHashMode(String(data.path_hash_mode));
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
setAdvertLocationSource(data.advert_location_source);
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
};
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
const update: RadioConfigUpdate = {
name: data.name as string,
lat: typeof data.lat === 'number' ? data.lat : config.lat,
lon: typeof data.lon === 'number' ? data.lon : config.lon,
tx_power: typeof data.tx_power === 'number' ? (data.tx_power as number) : config.tx_power,
radio,
};
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
update.advert_location_source = data.advert_location_source;
if (typeof data.multi_acks_enabled === 'boolean')
update.multi_acks_enabled = data.multi_acks_enabled;
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
update.path_hash_mode = data.path_hash_mode as number;
return update;
};
const applyImport = async (data: Record<string, unknown>) => {
populateFormFromImport(data);
const update = buildUpdateFromImport(data);
setBusy(true);
setRebooting(true);
try {
if (typeof data.private_key === 'string' && data.private_key) {
await onSetPrivateKey(data.private_key);
toast.success('Config + private key imported, saving & rebooting...');
} else {
toast.success('Config imported, saving & rebooting...');
}
await onSave(update);
await onReboot();
if (!pageMode) onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import config');
} finally {
setRebooting(false);
setBusy(false);
}
};
const handleImportConfig = async (file: File) => {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!validateImportData(data)) {
toast.error('Invalid config file', {
description: 'File must contain name and radio parameters (freq, bw, sf, cr)',
});
return;
}
if (typeof data.private_key === 'string' && data.private_key) {
// Private key present — show warning dialog before applying
pendingImportRef.current = data;
setKeyImportDialogOpen(true);
} else {
await applyImport(data);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import config');
} finally {
if (importInputRef.current) importInputRef.current.value = '';
}
};
const handleConfirmKeyImport = async () => {
setKeyImportDialogOpen(false);
const data = pendingImportRef.current;
pendingImportRef.current = null;
if (data) await applyImport(data);
};
const radioState =
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
const connectionActionLabel =
@@ -414,6 +694,9 @@ export function SettingsRadioSection({
</span>
</div>
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
<Button
type="button"
variant="outline"
@@ -678,6 +961,37 @@ export function SettingsRadioSection({
Some settings may require a reboot to take effect on some radios.
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExportConfig} className="flex-1">
<Download className="mr-1.5 h-4 w-4" aria-hidden="true" />
Export Config
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importInputRef.current?.click()}
disabled={busy || rebooting}
className="flex-1"
>
<Upload className="mr-1.5 h-4 w-4" aria-hidden="true" />
Import &amp; Reboot
</Button>
<input
ref={importInputRef}
type="file"
accept=".json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImportConfig(file);
}}
/>
</div>
<p className="text-[0.8125rem] text-muted-foreground">
Export saves the current server config to a JSON file. Import loads a config file, applies
it, and reboots the radio.
</p>
<Separator />
{/* ── Messaging ── */}
@@ -733,9 +1047,9 @@ export function SettingsRadioSection({
placeholder="MyRegion"
/>
<p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
that region can forward the traffic, while repeaters configured to deny other regions may
drop it. Leave empty to disable.
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
region can forward the traffic, while repeaters configured to deny other regions may drop
it. Leave empty to disable.
</p>
</div>
@@ -907,6 +1221,44 @@ export function SettingsRadioSection({
)}
</div>
</div>
{/* ── Private Key Import Warning ── */}
<Dialog
open={keyImportDialogOpen}
onOpenChange={(open) => {
setKeyImportDialogOpen(open);
if (!open) pendingImportRef.current = null;
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Import includes Private Key</DialogTitle>
<DialogDescription>
This config file contains a private key. Importing it will change your radio&apos;s
identity &mdash; your radio will have a new public key and other nodes will see it as
a different device. This cannot be undone without the original key.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setKeyImportDialogOpen(false);
pendingImportRef.current = null;
}}
>
Cancel
</Button>
<Button
onClick={handleConfirmKeyImport}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
Import Config &amp; Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+41 -4
View File
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
return arr;
}
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`${label} timed out — the service worker may have failed to install. ` +
'Mobile browsers require a trusted TLS certificate for service workers, ' +
'even if the page itself loads with a self-signed cert.'
)
),
ms
);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
}
);
});
}
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
if (!a || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
// Check if THIS browser has an active push subscription and match it
// to a backend record.
navigator.serviceWorker.ready
// to a backend record. Use a timeout so we don't hang forever when the
// service worker failed to install (e.g. mobile + self-signed cert).
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
.then((reg) => reg.pushManager.getSubscription())
.then(async (sub) => {
const existing = await subsPromise;
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
const refreshSubscriptions = useCallback(async () => {
try {
const subs = await api.getPushSubscriptions();
const reg = await navigator.serviceWorker.ready;
const reg = await withTimeout(
navigator.serviceWorker.ready,
10_000,
'Service worker activation'
);
const sub = await reg.pushManager.getSubscription();
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
return subs;
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
vapidKeyRef.current = resp.public_key;
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
const reg = await navigator.serviceWorker.ready;
const reg = await withTimeout(
navigator.serviceWorker.ready,
3_000,
'Service worker activation'
);
let pushSub = await reg.pushManager.getSubscription();
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
const requiresRecreate =
@@ -188,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
console.error('Push subscribe failed:', err);
toast.error('Failed to enable push notifications', {
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
duration: 8_000,
});
return null;
} finally {
+3 -1
View File
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
// Register service worker for Web Push (requires secure context)
if ('serviceWorker' in navigator && window.isSecureContext) {
navigator.serviceWorker.register('./sw.js').catch(() => {});
navigator.serviceWorker.register('./sw.js').catch((err) => {
console.warn('Service worker registration failed:', err);
});
}
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
expect(result.current.allSubscriptions).toEqual([]);
});
it('times out and shows a toast when service worker never activates', async () => {
// Replace serviceWorker.ready with a promise that never resolves
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: new Promise(() => {}),
},
});
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => {
expect(result.current.isSupported).toBe(true);
});
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
await act(async () => {
await result.current.subscribe();
});
expect(result.current.loading).toBe(false);
expect(mocks.toast.error).toHaveBeenCalledWith(
'Failed to enable push notifications',
expect.objectContaining({
description: expect.stringContaining('trusted TLS certificate for service workers'),
})
);
}, 5_000);
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
const oldSubscription = activeSubscription;
mocks.api.getPushSubscriptions
+2
View File
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
timestamp: number | null;
battery_mv: number | null;
uptime_secs: number | null;
queue_len: number | null;
errors: number | null;
noise_floor: number | null;
last_rssi: number | null;
last_snr: number | null;
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.12.0"
version = "3.12.2"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
dev = [
"httpx>=0.28.1",
"pip-licenses>=5.0.0",
"pytest>=9.0.2",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"pytest-xdist>=3.0",
"ruff>=0.8.0",
+7 -1
View File
@@ -52,6 +52,12 @@ test.describe('Favorites persistence', () => {
return channels.some((c) => c.key === channelKey && c.favorite);
})
.toBe(false);
await expect(page.getByText('Favorites')).not.toBeVisible();
// The test channel should no longer appear under the Favorites header —
// but the Favorites section itself may remain if radio-synced contacts are favorited.
const channelsSectionHeader = page.getByText('Channels');
await expect(channelsSectionHeader).toBeVisible();
// Verify the channel now appears in the non-favorites Channels section
const channelEntry = page.getByText(channelName, { exact: true }).first();
await expect(channelEntry).toBeVisible();
});
});
+26
View File
@@ -203,6 +203,30 @@ class TestHealthEndpoint:
class TestDebugEndpoint:
"""Test the debug support snapshot endpoint."""
def test_build_environment_exposes_env_settings(self):
"""_build_environment should expose env config without secrets."""
from app.config import Settings
from app.routers.debug import _build_environment
with patch(
"app.routers.debug.settings",
Settings(
serial_port="/dev/ttyUSB0",
serial_baudrate=115200,
log_level="DEBUG",
database_path="data/test.db",
),
):
env = _build_environment()
assert env.connection_type == "serial"
assert env.serial_port == "/dev/ttyUSB0"
assert env.log_level == "DEBUG"
assert env.database_path == "data/test.db"
assert not hasattr(env, "ble_pin")
assert not hasattr(env, "basic_auth_password")
assert not hasattr(env, "basic_auth_username")
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
"""Debug radio probe should redact advertised lat/lon from self_info."""
from app.routers.debug import _sanitize_radio_probe_self_info
@@ -300,6 +324,8 @@ class TestDebugEndpoint:
assert "multi_acks_enabled" not in payload["radio_probe"]
assert "max_channels" not in payload["runtime"]
assert "path_hash_mode" not in payload["runtime"]
assert "environment" in payload
assert payload["environment"]["connection_type"] in ("serial", "tcp", "ble")
assert payload["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0
+87 -12
View File
@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "v2.2.2"
mock_radio.firmware_build = "2025-01-15"
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
patch.object(
pub,
"_fetch_device_info",
new_callable=AsyncMock,
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
),
@@ -852,6 +850,82 @@ class TestLwtAndStatusPublish:
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
assert payload["stats"] == {"battery_mv": 4200}
@pytest.mark.asyncio
async def test_publish_status_uses_fallback_fetch_when_device_info_not_loaded(self):
"""When device_info_loaded is False, _fetch_device_info() should be called as fallback."""
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "OldNode"}
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
patch.object(
pub,
"_fetch_device_info",
new_callable=AsyncMock,
return_value={"model": "LegacyBoard", "firmware_version": "v2"},
) as mock_fetch,
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm/0-x"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
mock_fetch.assert_awaited_once()
payload = mock_publish.call_args[0][1]
assert payload["model"] == "LegacyBoard"
assert payload["firmware_version"] == "v2"
@pytest.mark.asyncio
async def test_publish_status_reflects_updated_firmware_version_after_reconnect(self):
"""After firmware update + radio reconnect, the published firmware_version must be fresh.
This is a regression test for the stale-cache bug: previously _cached_device_info
was never cleared between reconnects, so a radio firmware update was invisible to
the Community MQTT status payload until the fanout module itself restarted.
"""
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "MyNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "1.14.1"
mock_radio.firmware_build = ""
async def _publish_once(radio_mock):
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", radio_mock),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RT/0-x"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_pub,
):
await pub._publish_status(settings)
return mock_pub.call_args[0][1]
first_payload = await _publish_once(mock_radio)
assert first_payload["firmware_version"] == "1.14.1"
# Simulate firmware update: radio reboots, radio_lifecycle refreshes the manager fields
mock_radio.firmware_version = "1.15.0"
second_payload = await _publish_once(mock_radio)
assert second_payload["firmware_version"] == "1.15.0", (
"Expected updated firmware version after reconnect; stale cache bug would return v1.14.1"
)
def test_lwt_and_online_share_same_topic(self):
"""LWT and on-connect status should use the same topic path."""
pub = CommunityMqttPublisher()
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "v2.2.2"
mock_radio.firmware_build = "2025-01-15"
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
patch.object(
pub,
"_fetch_device_info",
new_callable=AsyncMock,
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch(
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
before = time.monotonic()
+90
View File
@@ -7,6 +7,7 @@ import pytest
from app.fanout.mqtt_ha import (
MqttHaModule,
_assign_lpp_keys,
_contact_tracker_discovery_config,
_device_payload,
_lpp_discovery_configs,
@@ -552,6 +553,45 @@ class TestLppSensorKey:
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
class TestAssignLppKeys:
def test_no_duplicates(self):
sensors = [
{"type_name": "temperature", "channel": 1, "value": 20},
{"type_name": "humidity", "channel": 2, "value": 45},
]
result = _assign_lpp_keys(sensors)
assert [(k, n) for _, k, n in result] == [
("lpp_temperature_ch1", 1),
("lpp_humidity_ch2", 1),
]
def test_duplicate_type_and_channel(self):
sensors = [
{"type_name": "temperature", "channel": 1, "value": 20},
{"type_name": "humidity", "channel": 2, "value": 45},
{"type_name": "temperature", "channel": 1, "value": 53},
]
result = _assign_lpp_keys(sensors)
assert [(k, n) for _, k, n in result] == [
("lpp_temperature_ch1", 1),
("lpp_humidity_ch2", 1),
("lpp_temperature_ch1_2", 2),
]
def test_triple_duplicate(self):
sensors = [
{"type_name": "voltage", "channel": 0, "value": 3.3},
{"type_name": "voltage", "channel": 0, "value": 5.0},
{"type_name": "voltage", "channel": 0, "value": 12.0},
]
result = _assign_lpp_keys(sensors)
keys = [k for _, k, _ in result]
assert keys == ["lpp_voltage_ch0", "lpp_voltage_ch0_2", "lpp_voltage_ch0_3"]
def test_empty_list(self):
assert _assign_lpp_keys([]) == []
class TestLppDiscoveryConfigs:
def test_produces_config_per_sensor(self):
nid = "ccdd11223344"
@@ -583,6 +623,27 @@ class TestLppDiscoveryConfigs:
assert cfg["suggested_display_precision"] == 1
assert "lpp_temperature_ch1" in cfg["value_template"]
def test_duplicate_type_channel_gets_indexed_keys(self):
nid = "ccdd11223344"
device = _device_payload(nid, "Rep1", "Repeater")
sensors = [
{"channel": 1, "type_name": "temperature", "value": 20.0},
{"channel": 2, "type_name": "humidity", "value": 45.0},
{"channel": 1, "type_name": "temperature", "value": 53.0},
]
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
assert len(configs) == 3
topics = [t for t, _ in configs]
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config" in topics
assert f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config" in topics
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config" in topics
# First temperature keeps base name, second gets #2 suffix
names = {cfg["unique_id"]: cfg["name"] for _, cfg in configs}
assert names[f"meshcore_{nid}_lpp_temperature_ch1"] == "Temperature (Ch 1)"
assert names[f"meshcore_{nid}_lpp_temperature_ch1_2"] == "Temperature (Ch 1) #2"
def test_unknown_sensor_type_no_device_class(self):
nid = "ccdd11223344"
device = _device_payload(nid, "Rep1", "Repeater")
@@ -712,6 +773,35 @@ class TestMqttHaTelemetryWithLpp:
mod._publish_discovery.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_telemetry_duplicate_lpp_sensors_not_overwritten(self):
"""Two sensors with same (type_name, channel) get distinct keys."""
key = "ccdd11223344"
nid = _node_id(key)
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
mod._publisher = MagicMock()
mod._publisher.connected = True
mod._publisher.publish = AsyncMock()
mod._discovery_topics = [
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config",
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config",
]
await mod.on_telemetry(
{
"public_key": key,
"battery_volts": 4.1,
"lpp_sensors": [
{"channel": 1, "type_name": "temperature", "value": 20.0},
{"channel": 1, "type_name": "temperature", "value": 53.0},
],
}
)
payload = mod._publisher.publish.call_args[0][1]
assert payload["lpp_temperature_ch1"] == 20.0
assert payload["lpp_temperature_ch1_2"] == 53.0
@pytest.mark.asyncio
async def test_on_telemetry_without_lpp_sensors(self):
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
+33
View File
@@ -20,6 +20,7 @@ from app.routers.radio import (
RadioSettings,
disconnect_radio,
discover_mesh,
get_private_key,
get_radio_config,
reboot_radio,
reconnect_radio,
@@ -283,6 +284,38 @@ class TestUpdateRadioConfig:
mc.commands.send_appstart.assert_not_awaited()
class TestPrivateKeyExport:
@pytest.mark.asyncio
async def test_returns_403_when_export_disabled(self):
with patch("app.config.settings") as mock_settings:
mock_settings.enable_local_private_key_export = False
with pytest.raises(HTTPException) as exc:
await get_private_key()
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_returns_404_when_no_key_available(self):
with (
patch("app.config.settings") as mock_settings,
patch("app.keystore.get_private_key", return_value=None),
):
mock_settings.enable_local_private_key_export = True
with pytest.raises(HTTPException) as exc:
await get_private_key()
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_returns_key_hex_when_enabled_and_available(self):
key_bytes = bytes.fromhex("ab" * 64)
with (
patch("app.config.settings") as mock_settings,
patch("app.keystore.get_private_key", return_value=key_bytes),
):
mock_settings.enable_local_private_key_export = True
result = await get_private_key()
assert result == {"private_key": "ab" * 64}
class TestPrivateKeyImport:
@pytest.mark.asyncio
async def test_rejects_invalid_hex(self):
Generated
+8 -8
View File
@@ -1399,7 +1399,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1408,9 +1408,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
]
[[package]]
@@ -1533,7 +1533,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.12.0"
version = "3.12.2"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },
@@ -1582,7 +1582,7 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pip-licenses", specifier = ">=5.0.0" },
{ name = "pyright", specifier = ">=1.1.390" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-xdist", specifier = ">=3.0" },
{ name = "ruff", specifier = ">=0.8.0" },
@@ -1590,7 +1590,7 @@ dev = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1598,9 +1598,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]