diff --git a/config/default.yaml b/config/default.yaml
index b8f7bad9..dd91fedc 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -11,6 +11,11 @@ radio:
sync_word: 0x2B
preamble_length: 16
tx_power_dbm: 22
+ # HAL GPS/PPS (RAK Pi HAT u-blox → concentrator PPS pin). Off by default.
+ # gps_pps_enabled: false
+ # gps_pps_tty_path: "/dev/ttyAMA0"
+ # gps_family: "ubx7"
+ # gps_pps_target_baud: 0 # 0 = HAL default (9600)
meshtastic:
default_key_b64: "AQ=="
@@ -19,6 +24,9 @@ meshtastic:
# MyChannel: "base64encodedPSK=="
# SecretChat: "anotherBase64PSK=="
channel_keys: {}
+ # Remote ADMIN config read (PR 15) — PSK for the shared admin channel on target nodes.
+ # admin_key_b64: "base64PSK=="
+ # admin_channel_name: "admin"
meshcore:
default_key_b64: ""
@@ -50,7 +58,7 @@ device:
longitude: 0.0
altitude: 25
hardware_description: "RAK2287 + Raspberry Pi 4"
- firmware_version: "0.7.6"
+ firmware_version: "0.7.5.1"
relay:
enabled: false
@@ -60,6 +68,18 @@ relay:
burst_size: 5
min_relay_rssi: -110.0
max_relay_rssi: -50.0
+ # blocklist: [] # 8-char hex node IDs — relay only, feed unchanged
+ # priority_list: [] # bypass burst gate under congestion
+ # dedup_ttl_seconds: 300
+ # channel_throttle_percent: # per-channel relay duty budget (est. ToA)
+ # "0": 100 # omit = 100% all channels; EU capped at 1%
+ # storm_guard: # memory-only quarantine (PR 12)
+ # enabled: false
+ # window_seconds: 60
+ # identical_packet_threshold: 5
+ # rate_threshold_per_minute: 30
+ # quarantine_duration_seconds: 300
+ # notify_dashboard: true
upstream:
enabled: true
@@ -80,13 +100,6 @@ transmit:
nodeinfo:
interval_minutes: 180 # 5..1440 min, default 180 (3 hr); 0 = disabled
startup_delay_seconds: 60 # delay before first broadcast on boot
- position:
- interval_minutes: 15
- startup_delay_seconds: 180
- # LoRa mesh POSITION packets (Meshtastic app map). Meshradar fleet pin
- # always uses device.latitude/longitude above, not live GPS.
- coordinate_source: "static" # static | live (live needs gpsd/uart source)
- location_precision: "approximate" # exact | approximate | none (live only)
web_auth:
# First-run state: hashes are empty -> dashboard forces /setup.
@@ -124,18 +137,29 @@ mqtt:
homeassistant_discovery: false
location:
- # Where live GPS fixes come from (skyplot + optional mesh POSITION).
- # Registered coordinates for Meshradar live in device.latitude/longitude
- # and are not overwritten by gpsd.
+ # Where the Meshpoint's reported lat/lon/alt comes from.
# static : use device.latitude/longitude/altitude (default)
# gpsd : poll a local or remote gpsd daemon for live fixes
- # uart : reserved (RAK Pi HAT GPS, not yet wired)
+ # uart : on-board RAK Pi HAT GPS via /dev/ttyAMA0 (NMEA GGA)
source: "static"
# gpsd connection. Defaults match the well-known local socket; only
# change when running gpsd on a peer machine on the LAN.
gpsd_host: "127.0.0.1"
gpsd_port: 2947
+ uart_path: "/dev/ttyAMA0"
+ uart_baud: 9600
# How often the coordinator wakes to read the active source.
update_interval_seconds: 5
# Minimum acceptable fix mode: 0=any (incl. no-fix), 1=2D, 2=3D.
min_fix_quality: 1
+
+# signal_health:
+# green_rssi_floor: -100 # badge green when 1h avg RSSI is above this
+# yellow_rssi_floor: -115 # badge yellow above this, red below
+# min_packets_per_hour: 5 # hide badge when fewer packets in the last hour
+
+# automation:
+# enabled: false
+# # Long random secret for LAN scripts (Home Assistant, Node-RED). Generate with:
+# # openssl rand -hex 32
+# token: ""
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index f12b077d..9745c620 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -160,6 +160,20 @@ The setup wizard configures sources automatically. To add or remove a MeshCore c
When running both Meshtastic concentrator capture and a MeshCore USB companion, pin `meshcore_usb.serial_port` explicitly. Auto-detect can grab the wrong device when multiple Espressif boards are attached.
+### Remote node config read (ADMIN, read-only)
+
+**Node drawer → Remote Configuration** sends a single encrypted ADMIN `get_config_request` (or `get_owner_request`) per click. Responses display read-only in the drawer. Requires native TX (`transmit.enabled: true`) and a shared admin channel PSK on target nodes.
+
+```yaml
+meshtastic:
+ admin_key_b64: "base64PSK==" # PSK for the shared "admin" channel
+ admin_channel_name: "admin" # optional; default "admin"
+```
+
+- Admin-only API; audit action `admin.config_request` (target node + section only — no PSK in logs).
+- 30 s response timeout with retry UI; 30 s debounce between requests per node.
+- **Read-only** — no config writes in this release.
+
---
## Location (GPS) source
diff --git a/frontend/css/node_drawer.css b/frontend/css/node_drawer.css
index 06786d78..f04dbc53 100644
--- a/frontend/css/node_drawer.css
+++ b/frontend/css/node_drawer.css
@@ -322,6 +322,65 @@
pointer-events: auto;
}
+/* ---- Remote ADMIN config (node drawer) ---- */
+
+.nd-remote-config__toolbar {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.nd-remote-config__select {
+ flex: 1;
+ min-width: 0;
+ padding: 0.35rem 0.5rem;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ background: var(--bg-card);
+ color: var(--text-primary);
+ font-size: 0.85rem;
+}
+
+.nd-remote-config__btn {
+ flex-shrink: 0;
+ font-size: 0.8rem;
+ padding: 0.35rem 0.6rem;
+}
+
+.nd-remote-config__status {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0.25rem 0 0.5rem;
+}
+
+.nd-remote-config__hint {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.nd-remote-config__hint code {
+ font-size: 0.75rem;
+}
+
+.nd-remote-config__result {
+ max-height: 220px;
+ overflow-y: auto;
+ border-top: 1px solid var(--border);
+ padding-top: 0.35rem;
+}
+
+.nd-remote-config__retry {
+ background: none;
+ border: none;
+ color: var(--accent);
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+ font-size: inherit;
+}
+
@media (max-width: 480px) {
.nd-drawer {
width: 100%;
diff --git a/frontend/js/node_drawer.js b/frontend/js/node_drawer.js
index b570e3b3..f1b326b8 100644
--- a/frontend/js/node_drawer.js
+++ b/frontend/js/node_drawer.js
@@ -11,6 +11,10 @@ class NodeDrawer {
this._sections = {};
this._metricsChart = null;
this._metricsHours = 24;
+ this._adminConfigAvailable = false;
+ this._adminDebounceSeconds = 30;
+ this._adminPollTimer = null;
+ this._adminConfigDebounceUntil = 0;
if (window.MeshpointNodeFavorites) {
window.MeshpointNodeFavorites.onChange(() => this._refreshFavoriteButton());
@@ -23,10 +27,15 @@ class NodeDrawer {
this._renderSkeleton(node);
this._drawer.classList.add('nd-drawer--open');
- const [detail, metrics] = await Promise.all([
+ const [detail, metrics, adminStatus] = await Promise.all([
this._fetchDetail(node.node_id),
this._fetchMetricsHistory(node.node_id, this._metricsHours),
+ this._fetchAdminConfigStatus(),
]);
+ if (adminStatus) {
+ this._adminConfigAvailable = !!adminStatus.available;
+ this._adminDebounceSeconds = adminStatus.debounce_seconds || 30;
+ }
const merged = { ...node, ...detail };
merged._metricsHistory = metrics;
@@ -38,6 +47,7 @@ class NodeDrawer {
}
close() {
+ this._stopAdminPoll();
if (this._metricsChart) {
this._metricsChart.destroy();
this._metricsChart = null;
@@ -110,6 +120,7 @@ class NodeDrawer {
body.innerHTML = '';
body.appendChild(this._buildActions(n));
+ body.appendChild(this._buildRemoteConfigSection(n));
body.appendChild(this._buildMetricsChartSection(n));
body.appendChild(this._buildInfoSection(n));
body.appendChild(this._buildSignalSection(n));
@@ -145,6 +156,227 @@ class NodeDrawer {
return div;
}
+ _buildRemoteConfigSection(n) {
+ const section = document.createElement('div');
+ section.className = 'nd-section nd-section--remote-config';
+
+ const header = document.createElement('div');
+ header.className = 'nd-section__header';
+ header.innerHTML = `Remote Configuration
+ \u25BC`;
+
+ const content = document.createElement('div');
+ content.className = 'nd-section__content nd-remote-config';
+
+ if (!this._adminConfigAvailable) {
+ content.innerHTML = `
Set meshtastic.admin_key_b64 in local.yaml to enable ADMIN config read.
`;
+ } else {
+ const toolbar = document.createElement('div');
+ toolbar.className = 'nd-remote-config__toolbar';
+
+ const select = document.createElement('select');
+ select.className = 'nd-remote-config__select';
+ select.setAttribute('aria-label', 'Config section');
+ [
+ ['device', 'Device'],
+ ['owner', 'Owner'],
+ ['lora', 'LoRa'],
+ ['position', 'Position'],
+ ].forEach(([value, label]) => {
+ const opt = document.createElement('option');
+ opt.value = value;
+ opt.textContent = label;
+ select.appendChild(opt);
+ });
+ toolbar.appendChild(select);
+
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'nd-action-btn nd-remote-config__btn';
+ btn.textContent = 'Request Config';
+ btn.addEventListener('click', () => {
+ this._requestRemoteConfig(n.node_id, select.value, btn, statusEl, resultEl);
+ });
+ toolbar.appendChild(btn);
+ content.appendChild(toolbar);
+
+ const statusEl = document.createElement('p');
+ statusEl.className = 'nd-remote-config__status';
+ statusEl.textContent = 'No request yet.';
+ content.appendChild(statusEl);
+
+ const resultEl = document.createElement('div');
+ resultEl.className = 'nd-remote-config__result';
+ content.appendChild(resultEl);
+
+ this._refreshRemoteConfigPanel(n.node_id, btn, statusEl, resultEl);
+ }
+
+ header.addEventListener('click', () => {
+ const visible = content.style.display !== 'none';
+ content.style.display = visible ? 'none' : '';
+ header.querySelector('.nd-section__arrow').textContent = visible ? '\u25B6' : '\u25BC';
+ });
+
+ section.appendChild(header);
+ section.appendChild(content);
+ return section;
+ }
+
+ async _requestRemoteConfig(nodeId, section, btn, statusEl, resultEl) {
+ const now = Date.now();
+ if (now < this._adminConfigDebounceUntil) {
+ const left = Math.ceil((this._adminConfigDebounceUntil - now) / 1000);
+ statusEl.textContent = `Wait ${left}s before another request.`;
+ return;
+ }
+
+ btn.disabled = true;
+ statusEl.textContent = 'Sending ADMIN request…';
+ resultEl.innerHTML = '';
+
+ try {
+ const res = await fetch(
+ `/api/admin/nodes/${encodeURIComponent(nodeId)}/config/request`,
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ section }),
+ },
+ );
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ statusEl.textContent = body.detail || `Request failed (${res.status})`;
+ if (res.status === 429) {
+ this._adminConfigDebounceUntil = Date.now() + this._adminDebounceSeconds * 1000;
+ }
+ return;
+ }
+ this._adminConfigDebounceUntil = Date.now() + this._adminDebounceSeconds * 1000;
+ statusEl.textContent = 'Waiting for response (up to 30s)…';
+ this._startAdminPoll(nodeId, btn, statusEl, resultEl);
+ } catch (err) {
+ statusEl.textContent = 'Network error requesting config.';
+ } finally {
+ btn.disabled = Date.now() < this._adminConfigDebounceUntil;
+ }
+ }
+
+ _startAdminPoll(nodeId, btn, statusEl, resultEl) {
+ this._stopAdminPoll();
+ const started = Date.now();
+ const poll = async () => {
+ const data = await this._fetchRemoteConfigStatus(nodeId);
+ if (!data) return;
+
+ if (data.status === 'pending') {
+ const elapsed = Math.floor((Date.now() - started) / 1000);
+ statusEl.textContent = `Waiting for response… ${elapsed}s`;
+ return;
+ }
+
+ this._stopAdminPoll();
+ btn.disabled = Date.now() < this._adminConfigDebounceUntil;
+
+ if (data.status === 'complete') {
+ statusEl.textContent = `Received ${data.section || ''} config.`;
+ resultEl.innerHTML = this._formatRemoteConfigResult(data.config);
+ } else if (data.status === 'timeout') {
+ statusEl.innerHTML = 'No response within 30s. '
+ + '';
+ const retry = statusEl.querySelector('.nd-remote-config__retry');
+ if (retry) {
+ retry.addEventListener('click', () => {
+ const select = this._drawer.querySelector('.nd-remote-config__select');
+ const section = select ? select.value : 'device';
+ this._requestRemoteConfig(nodeId, section, btn, statusEl, resultEl);
+ });
+ }
+ } else {
+ statusEl.textContent = data.error || `Status: ${data.status}`;
+ }
+ };
+ this._adminPollTimer = setInterval(poll, 2000);
+ poll();
+ }
+
+ _stopAdminPoll() {
+ if (this._adminPollTimer) {
+ clearInterval(this._adminPollTimer);
+ this._adminPollTimer = null;
+ }
+ }
+
+ async _refreshRemoteConfigPanel(nodeId, btn, statusEl, resultEl) {
+ const data = await this._fetchRemoteConfigStatus(nodeId);
+ if (!data || data.status === 'idle') return;
+ if (data.status === 'pending') {
+ statusEl.textContent = 'Request in progress…';
+ this._startAdminPoll(nodeId, btn, statusEl, resultEl);
+ return;
+ }
+ if (data.status === 'complete' && data.config) {
+ statusEl.textContent = `Last read: ${data.section || ''}`;
+ resultEl.innerHTML = this._formatRemoteConfigResult(data.config);
+ } else if (data.error) {
+ statusEl.textContent = data.error;
+ }
+ }
+
+ _formatRemoteConfigResult(config) {
+ if (!config) return '';
+ const rows = this._flattenConfigRows(config, '');
+ if (rows.length === 0) {
+ return 'Empty response.
';
+ }
+ return rows.map(([k, v]) => (
+ `${this._esc(k)}`
+ + `${this._esc(String(v))}
`
+ )).join('');
+ }
+
+ _flattenConfigRows(obj, prefix) {
+ const rows = [];
+ if (obj == null || typeof obj !== 'object') {
+ return rows;
+ }
+ Object.entries(obj).forEach(([key, value]) => {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (value != null && typeof value === 'object' && !Array.isArray(value)) {
+ rows.push(...this._flattenConfigRows(value, path));
+ } else if (Array.isArray(value)) {
+ rows.push([path, value.join(', ')]);
+ } else if (value !== '' && value != null) {
+ rows.push([path, value]);
+ }
+ });
+ return rows;
+ }
+
+ async _fetchAdminConfigStatus() {
+ try {
+ const res = await fetch('/api/admin/remote-config/status', { credentials: 'same-origin' });
+ if (!res.ok) return null;
+ return await res.json();
+ } catch {
+ return null;
+ }
+ }
+
+ async _fetchRemoteConfigStatus(nodeId) {
+ try {
+ const res = await fetch(
+ `/api/admin/nodes/${encodeURIComponent(nodeId)}/config`,
+ { credentials: 'same-origin' },
+ );
+ if (!res.ok) return null;
+ return await res.json();
+ } catch {
+ return null;
+ }
+ }
+
_buildInfoSection(n) {
const rows = [];
if (n.hardware_model) rows.push(['Hardware', n.hardware_model]);
diff --git a/src/admin/__init__.py b/src/admin/__init__.py
new file mode 100644
index 00000000..68372f31
--- /dev/null
+++ b/src/admin/__init__.py
@@ -0,0 +1 @@
+"""Remote Meshtastic ADMIN config read (PR 15)."""
diff --git a/src/admin/config_decode.py b/src/admin/config_decode.py
new file mode 100644
index 00000000..3c333cdb
--- /dev/null
+++ b/src/admin/config_decode.py
@@ -0,0 +1,29 @@
+"""Decode Meshtastic ADMIN responses into JSON-safe, redacted dicts."""
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from google.protobuf.json_format import MessageToDict
+from google.protobuf.message import Message
+
+_SENSITIVE_RE = re.compile(
+ r"(psk|private_key|public_key|admin_key|session_passkey|passkey|key)$",
+ re.IGNORECASE,
+)
+
+
+def message_to_redacted_dict(msg: Message) -> dict[str, Any]:
+ """Convert a protobuf message to a dict with secrets redacted."""
+ raw = MessageToDict(msg, preserving_proto_field_name=True)
+ return _redact_value(raw)
+
+
+def _redact_value(value: Any, key: str | None = None) -> Any:
+ if key and _SENSITIVE_RE.search(key):
+ return "***"
+ if isinstance(value, dict):
+ return {k: _redact_value(v, k) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_redact_value(item) for item in value]
+ return value
diff --git a/src/admin/pending_store.py b/src/admin/pending_store.py
new file mode 100644
index 00000000..98a696de
--- /dev/null
+++ b/src/admin/pending_store.py
@@ -0,0 +1,107 @@
+"""In-memory pending remote config read requests (one slot per node)."""
+from __future__ import annotations
+
+import time
+import uuid
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+RequestStatus = Literal["idle", "pending", "complete", "timeout", "error"]
+
+REQUEST_TIMEOUT_SECONDS = 30.0
+DEBOUNCE_SECONDS = 30.0
+
+
+@dataclass
+class ConfigReadState:
+ node_id: str
+ request_id: str = ""
+ section: str = "device"
+ status: RequestStatus = "idle"
+ requested_at: float = 0.0
+ completed_at: float = 0.0
+ packet_id: str = ""
+ config: dict[str, Any] | None = None
+ error: str = ""
+ expected_response: str = "get_config_response"
+
+
+class PendingConfigStore:
+ """Tracks pending/completed config reads keyed by normalized node ID."""
+
+ def __init__(self) -> None:
+ self._by_node: dict[str, ConfigReadState] = {}
+
+ def get(self, node_id: str) -> ConfigReadState | None:
+ state = self._by_node.get(node_id)
+ if state is None:
+ return None
+ self._maybe_timeout(state)
+ return state
+
+ def can_request(self, node_id: str) -> tuple[bool, str]:
+ state = self._by_node.get(node_id)
+ if state is None:
+ return True, ""
+ self._maybe_timeout(state)
+ if state.status == "pending":
+ elapsed = time.time() - state.requested_at
+ if elapsed < DEBOUNCE_SECONDS:
+ remaining = int(DEBOUNCE_SECONDS - elapsed)
+ return False, f"Request in progress; retry in {remaining}s"
+ if state.status in ("pending", "complete", "timeout", "error"):
+ elapsed = time.time() - state.requested_at
+ if elapsed < DEBOUNCE_SECONDS:
+ remaining = int(DEBOUNCE_SECONDS - elapsed)
+ return False, f"Wait {remaining}s before another request"
+ return True, ""
+
+ def begin(
+ self,
+ node_id: str,
+ *,
+ section: str,
+ packet_id: str,
+ expected_response: str,
+ ) -> ConfigReadState:
+ state = ConfigReadState(
+ node_id=node_id,
+ request_id=uuid.uuid4().hex[:12],
+ section=section,
+ status="pending",
+ requested_at=time.time(),
+ packet_id=packet_id,
+ expected_response=expected_response,
+ config=None,
+ error="",
+ )
+ self._by_node[node_id] = state
+ return state
+
+ def complete(
+ self,
+ node_id: str,
+ config: dict[str, Any],
+ ) -> None:
+ state = self._by_node.get(node_id)
+ if state is None or state.status != "pending":
+ return
+ state.status = "complete"
+ state.completed_at = time.time()
+ state.config = config
+
+ def fail(self, node_id: str, error: str) -> None:
+ state = self._by_node.get(node_id)
+ if state is None:
+ return
+ state.status = "error"
+ state.completed_at = time.time()
+ state.error = error
+
+ def _maybe_timeout(self, state: ConfigReadState) -> None:
+ if state.status != "pending":
+ return
+ if time.time() - state.requested_at >= REQUEST_TIMEOUT_SECONDS:
+ state.status = "timeout"
+ state.completed_at = time.time()
+ state.error = "No response within 30 seconds"
diff --git a/src/admin/reader.py b/src/admin/reader.py
new file mode 100644
index 00000000..ced6cbdd
--- /dev/null
+++ b/src/admin/reader.py
@@ -0,0 +1,284 @@
+"""Build ADMIN get-config requests and correlate inbound responses."""
+from __future__ import annotations
+
+import base64
+import logging
+import struct
+from typing import Any
+
+from meshtastic.protobuf import admin_pb2, mesh_pb2
+
+from src.admin.config_decode import message_to_redacted_dict
+from src.admin.pending_store import PendingConfigStore
+from src.decode.crypto_service import CryptoService
+from src.models.packet import Packet
+from src.relay.node_id import normalize_node_id, validate_node_ids
+from src.transmit.tx_service import SendResult, TxService
+
+logger = logging.getLogger(__name__)
+
+PORTNUM_ADMIN = 6
+MESHTASTIC_HEADER_SIZE = 16
+
+_SECTION_CONFIG_TYPE: dict[str, int] = {
+ "device": admin_pb2.AdminMessage.DEVICE_CONFIG,
+ "position": admin_pb2.AdminMessage.POSITION_CONFIG,
+ "power": admin_pb2.AdminMessage.POWER_CONFIG,
+ "network": admin_pb2.AdminMessage.NETWORK_CONFIG,
+ "display": admin_pb2.AdminMessage.DISPLAY_CONFIG,
+ "lora": admin_pb2.AdminMessage.LORA_CONFIG,
+ "bluetooth": admin_pb2.AdminMessage.BLUETOOTH_CONFIG,
+ "security": admin_pb2.AdminMessage.SECURITY_CONFIG,
+}
+
+
+class AdminConfigReader:
+ """Send ADMIN read requests and capture responses from the RX pipeline."""
+
+ def __init__(
+ self,
+ *,
+ tx_service: TxService | None,
+ crypto: CryptoService | None,
+ admin_key_b64: str = "",
+ admin_channel_name: str = "admin",
+ local_node_id: int = 0,
+ ) -> None:
+ self._tx = tx_service
+ self._crypto = crypto
+ self._admin_key_b64 = (admin_key_b64 or "").strip()
+ self._admin_channel_name = (admin_channel_name or "admin").strip() or "admin"
+ self._local_node_id = local_node_id
+ self._store = PendingConfigStore()
+ self._admin_key: bytes | None = None
+ self._refresh_admin_key()
+
+ @property
+ def available(self) -> bool:
+ return bool(self._admin_key_b64 and self._admin_key)
+
+ @property
+ def store(self) -> PendingConfigStore:
+ return self._store
+
+ def update_config(
+ self,
+ *,
+ admin_key_b64: str = "",
+ admin_channel_name: str = "admin",
+ local_node_id: int = 0,
+ ) -> None:
+ self._admin_key_b64 = (admin_key_b64 or "").strip()
+ self._admin_channel_name = (admin_channel_name or "admin").strip() or "admin"
+ self._local_node_id = local_node_id
+ self._refresh_admin_key()
+
+ def _refresh_admin_key(self) -> None:
+ if not self._admin_key_b64:
+ self._admin_key = None
+ return
+ try:
+ raw = base64.b64decode(self._admin_key_b64)
+ self._admin_key = CryptoService._expand_key(raw)
+ except Exception:
+ logger.warning("Invalid meshtastic.admin_key_b64", exc_info=True)
+ self._admin_key = None
+
+ async def request_config(
+ self,
+ node_id: str,
+ section: str = "device",
+ ) -> dict[str, Any]:
+ if not self.available:
+ raise AdminConfigError(
+ "Remote config read unavailable — set meshtastic.admin_key_b64 in local.yaml",
+ status_code=503,
+ )
+ if self._tx is None or not self._tx.meshtastic_enabled:
+ raise AdminConfigError(
+ "Meshtastic TX not available (transmit.enabled required)",
+ status_code=503,
+ )
+
+ normalized = validate_node_ids([node_id])[0]
+ section_key = (section or "device").strip().lower()
+ if section_key not in _SECTION_CONFIG_TYPE and section_key != "owner":
+ raise AdminConfigError(
+ f"Unknown config section {section!r}",
+ status_code=400,
+ )
+
+ ok, reason = self._store.can_request(normalized)
+ if not ok:
+ raise AdminConfigError(reason, status_code=429)
+
+ dest_int = int(normalized, 16)
+ if dest_int in (0, 0xFFFFFFFF):
+ raise AdminConfigError(
+ "Cannot request config from broadcast node",
+ status_code=400,
+ )
+
+ admin_msg = self._build_admin_message(section_key)
+ expected = (
+ "get_owner_response"
+ if section_key == "owner"
+ else "get_config_response"
+ )
+ result = await self._tx.send_admin_message(
+ admin_payload=admin_msg.SerializeToString(),
+ destination=dest_int,
+ admin_key=self._admin_key,
+ admin_channel_name=self._admin_channel_name,
+ want_ack=True,
+ )
+ if not result.success:
+ raise AdminConfigError(
+ result.error or "ADMIN TX failed",
+ status_code=502,
+ )
+
+ state = self._store.begin(
+ normalized,
+ section=section_key,
+ packet_id=result.packet_id,
+ expected_response=expected,
+ )
+ return self._state_payload(state)
+
+ def get_status(self, node_id: str) -> dict[str, Any]:
+ normalized = normalize_node_id(node_id)
+ state = self._store.get(normalized)
+ if state is None:
+ return {
+ "node_id": normalized,
+ "status": "idle",
+ "section": None,
+ "config": None,
+ "error": "",
+ }
+ return self._state_payload(state)
+
+ def try_consume_packet(self, packet: Packet) -> None:
+ if not self.available or self._crypto is None:
+ return
+ if packet.protocol.value != "meshtastic":
+ return
+
+ source = normalize_node_id(packet.source_id or "")
+ state = self._store.get(source)
+ if state is None or state.status != "pending":
+ return
+
+ raw = packet.raw_radio_packet
+ if not raw or len(raw) < MESHTASTIC_HEADER_SIZE:
+ return
+
+ header = self._parse_header(raw[:MESHTASTIC_HEADER_SIZE])
+ if header is None:
+ return
+
+ if self._local_node_id and header["dest_id"] not in (
+ self._local_node_id,
+ 0xFFFFFFFF,
+ ):
+ return
+
+ encrypted = raw[MESHTASTIC_HEADER_SIZE:]
+ decrypted = self._crypto.decrypt_meshtastic(
+ encrypted,
+ header["packet_id"],
+ header["source_id"],
+ key=self._admin_key,
+ )
+ if decrypted is None:
+ return
+
+ data = mesh_pb2.Data()
+ try:
+ data.ParseFromString(decrypted)
+ except Exception:
+ return
+ if data.portnum != PORTNUM_ADMIN:
+ return
+
+ admin_msg = admin_pb2.AdminMessage()
+ try:
+ admin_msg.ParseFromString(data.payload)
+ except Exception:
+ return
+
+ payload = self._extract_response(admin_msg, state.expected_response)
+ if payload is None:
+ return
+
+ self._store.complete(source, payload)
+ logger.info(
+ "ADMIN config response from %s section=%s request_id=%s",
+ source,
+ state.section,
+ state.request_id,
+ )
+
+ @staticmethod
+ def _build_admin_message(section: str) -> admin_pb2.AdminMessage:
+ msg = admin_pb2.AdminMessage()
+ if section == "owner":
+ msg.get_owner_request = True
+ else:
+ msg.get_config_request = _SECTION_CONFIG_TYPE[section]
+ return msg
+
+ @staticmethod
+ def _extract_response(
+ admin_msg: admin_pb2.AdminMessage,
+ expected: str,
+ ) -> dict[str, Any] | None:
+ if expected == "get_owner_response":
+ if not admin_msg.HasField("get_owner_response"):
+ return None
+ return {
+ "section": "owner",
+ "owner": message_to_redacted_dict(admin_msg.get_owner_response),
+ }
+ if not admin_msg.HasField("get_config_response"):
+ return None
+ cfg_dict = message_to_redacted_dict(admin_msg.get_config_response)
+ populated = {
+ k: v for k, v in cfg_dict.items() if v is not None and v != {}
+ }
+ return {"section": "config", "config": populated}
+
+ @staticmethod
+ def _parse_header(header_bytes: bytes) -> dict[str, int] | None:
+ try:
+ dest_id, source_id, packet_id = struct.unpack_from(
+ " dict[str, Any]:
+ return {
+ "node_id": state.node_id,
+ "request_id": state.request_id,
+ "status": state.status,
+ "section": state.section,
+ "packet_id": state.packet_id,
+ "requested_at": state.requested_at,
+ "completed_at": state.completed_at or None,
+ "config": state.config,
+ "error": state.error,
+ }
+
+
+class AdminConfigError(Exception):
+ def __init__(self, message: str, *, status_code: int = 400) -> None:
+ super().__init__(message)
+ self.status_code = status_code
diff --git a/src/api/routes/admin_routes.py b/src/api/routes/admin_routes.py
new file mode 100644
index 00000000..e52d2ecc
--- /dev/null
+++ b/src/api/routes/admin_routes.py
@@ -0,0 +1,102 @@
+"""Remote Meshtastic ADMIN config read routes (PR 15)."""
+from __future__ import annotations
+
+import logging
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+
+from src.admin.pending_store import DEBOUNCE_SECONDS, REQUEST_TIMEOUT_SECONDS
+from src.admin.reader import AdminConfigError, AdminConfigReader
+from src.api.audit import AuditLogWriter
+from src.api.audit.dependencies import get_audit_writer
+from src.api.auth.dependencies import require_admin
+from src.api.auth.jwt_session import SessionClaims
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/admin", tags=["admin"])
+
+_reader: AdminConfigReader | None = None
+
+
+def init_routes(*, reader: AdminConfigReader | None) -> None:
+ global _reader
+ _reader = reader
+
+
+def reset_routes() -> None:
+ global _reader
+ _reader = None
+
+
+class ConfigRequestBody(BaseModel):
+ section: Literal[
+ "device",
+ "owner",
+ "lora",
+ "position",
+ "power",
+ "network",
+ "display",
+ "bluetooth",
+ "security",
+ ] = Field(default="device")
+
+
+@router.get("/remote-config/status")
+async def remote_config_status():
+ """Whether remote ADMIN config read is configured (no secrets)."""
+ if _reader is None:
+ return {
+ "available": False,
+ "debounce_seconds": DEBOUNCE_SECONDS,
+ "timeout_seconds": REQUEST_TIMEOUT_SECONDS,
+ }
+ return {
+ "available": _reader.available,
+ "debounce_seconds": DEBOUNCE_SECONDS,
+ "timeout_seconds": REQUEST_TIMEOUT_SECONDS,
+ }
+
+
+@router.get("/nodes/{node_id}/config")
+async def get_remote_config(node_id: str):
+ """Poll status/result for the latest config read on a node."""
+ if _reader is None:
+ raise HTTPException(503, "Remote config reader not initialized")
+ return _reader.get_status(node_id)
+
+
+@router.post("/nodes/{node_id}/config/request")
+async def request_remote_config(
+ node_id: str,
+ body: ConfigRequestBody | None = None,
+ section: str | None = Query(default=None),
+ claims: SessionClaims = Depends(require_admin),
+ audit: AuditLogWriter = Depends(get_audit_writer),
+):
+ """Send one ADMIN get-config request to a remote node (read-only)."""
+ if _reader is None:
+ raise HTTPException(503, "Remote config reader not initialized")
+
+ chosen = (body.section if body else None) or section or "device"
+ params = {
+ "target_node_id": node_id.strip().lower().lstrip("!"),
+ "section": chosen,
+ }
+
+ with audit.timed_action(
+ user=claims.subject,
+ action="admin.config_request",
+ params=params,
+ ) as ctx:
+ try:
+ result = await _reader.request_config(node_id, section=chosen)
+ ctx.params["packet_id"] = result.get("packet_id", "")
+ ctx.params["request_id"] = result.get("request_id", "")
+ ctx.params["status"] = result.get("status", "pending")
+ return result
+ except AdminConfigError as exc:
+ raise HTTPException(exc.status_code, str(exc)) from exc
diff --git a/src/api/routes/config_enrichment.py b/src/api/routes/config_enrichment.py
index 5f0ae3a6..6097ebb3 100644
--- a/src/api/routes/config_enrichment.py
+++ b/src/api/routes/config_enrichment.py
@@ -52,24 +52,51 @@ def enrich_config_payload(cfg: AppConfig, base: dict) -> dict:
"burst_size": relay.burst_size,
"min_relay_rssi": relay.min_relay_rssi,
"max_relay_rssi": relay.max_relay_rssi,
+ "blocklist": list(relay.blocklist or []),
+ "priority_list": list(relay.priority_list or []),
+ "dedup_ttl_seconds": relay.dedup_ttl_seconds,
+ "channel_throttle_percent": dict(relay.channel_throttle_percent or {}),
+ "storm_guard": {
+ "enabled": relay.storm_guard.enabled,
+ "window_seconds": relay.storm_guard.window_seconds,
+ "identical_packet_threshold": relay.storm_guard.identical_packet_threshold,
+ "rate_threshold_per_minute": relay.storm_guard.rate_threshold_per_minute,
+ "quarantine_duration_seconds": relay.storm_guard.quarantine_duration_seconds,
+ "notify_dashboard": relay.storm_guard.notify_dashboard,
+ },
}
base["radio_advanced"] = {
"spectral_scan_interval_seconds": radio.spectral_scan_interval_seconds,
"sx1261_spi_path": radio.sx1261_spi_path or "",
+ "carrier_type": radio.carrier_type or "",
+ "gps_pps_enabled": radio.gps_pps_enabled,
+ "gps_pps_tty_path": radio.gps_pps_tty_path,
+ "gps_family": radio.gps_family,
+ "gps_pps_target_baud": radio.gps_pps_target_baud,
}
base["location"] = {
"source": location.source,
"gpsd_host": location.gpsd_host,
"gpsd_port": location.gpsd_port,
+ "uart_path": location.uart_path,
+ "uart_baud": location.uart_baud,
"update_interval_seconds": location.update_interval_seconds,
"min_fix_quality": location.min_fix_quality,
}
- pos = cfg.transmit.position
- if "transmit" in base:
- base["transmit"]["position"] = {
- "interval_minutes": pos.interval_minutes,
- "startup_delay_seconds": pos.startup_delay_seconds,
- "coordinate_source": pos.coordinate_source,
- "location_precision": pos.location_precision,
- }
+ sh = cfg.signal_health
+ base["signal_health"] = {
+ "green_rssi_floor": sh.green_rssi_floor,
+ "yellow_rssi_floor": sh.yellow_rssi_floor,
+ "min_packets_per_hour": sh.min_packets_per_hour,
+ }
+ auto = cfg.automation
+ base["automation"] = {
+ "enabled": auto.enabled,
+ "token_set": bool((auto.token or "").strip()),
+ }
+ mt = cfg.meshtastic
+ base["meshtastic_admin"] = {
+ "config_read_available": bool((mt.admin_key_b64 or "").strip()),
+ "channel_name": (mt.admin_channel_name or "admin").strip() or "admin",
+ }
return base
diff --git a/src/api/server.py b/src/api/server.py
index 3bd4fb06..6bcdf598 100644
--- a/src/api/server.py
+++ b/src/api/server.py
@@ -27,6 +27,7 @@
build_restart_service_action,
build_wipe_phantoms_action,
)
+from src.admin.reader import AdminConfigReader
from src.api.meshcore_contacts import (
log_meshcore_contact_peers,
schedule_startup_meshcore_contact_sync,
@@ -34,6 +35,7 @@
sync_meshcore_contacts_to_nodes,
)
from src.api.routes import (
+ admin_routes,
analytics,
auth_config_routes,
auth_routes,
@@ -279,6 +281,7 @@ async def lifespan(app: FastAPI):
app.include_router(system_config_routes.router, dependencies=protected)
app.include_router(meshcore_config_routes.router, dependencies=protected)
app.include_router(config_routes.router, dependencies=protected)
+ app.include_router(admin_routes.router, dependencies=protected)
app.include_router(stats_routes.router, dependencies=protected)
@app.websocket("/ws")
@@ -1268,6 +1271,26 @@ def _init_routes(
system_config_routes.init_routes(config=config)
meshcore_config_routes.init_routes(config=config, tx_service=tx_service)
+ local_node_id = 0
+ if tx_service is not None:
+ local_node_id = tx_service.source_node_id
+ elif config.transmit.node_id:
+ local_node_id = config.transmit.node_id
+
+ admin_reader = AdminConfigReader(
+ tx_service=tx_service,
+ crypto=crypto,
+ admin_key_b64=config.meshtastic.admin_key_b64,
+ admin_channel_name=config.meshtastic.admin_channel_name,
+ local_node_id=local_node_id,
+ )
+ admin_routes.init_routes(reader=admin_reader)
+
+ def _on_admin_config_packet(packet: Packet) -> None:
+ admin_reader.try_consume_packet(packet)
+
+ coord.on_packet(_on_admin_config_packet)
+
def _init_dangerous_registry(coord: PipelineCoordinator) -> None:
"""Compose the Settings → Dangerous registry now that the pipeline is live.
diff --git a/src/config.py b/src/config.py
index 4622e2b4..2e8f2dbe 100644
--- a/src/config.py
+++ b/src/config.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import dataclasses
-import logging
import os
import sys
from dataclasses import dataclass, field
@@ -12,8 +11,6 @@
from src.version import __version__
-logger = logging.getLogger(__name__)
-
# Band-start frequencies (MHz) for the Meshtastic slot formula
# freq = freqStart + BW/2000 + (slot-1) * BW/1000
@@ -60,14 +57,26 @@ class RadioConfig:
# (default; spectral scan stays unavailable, packet-derived
# noise floor remains in use).
#
- # On RAK2287 / RAK5146 / SenseCap M1 this is typically
- # ``/dev/spidev0.1`` (separate from the SX1302's
- # ``/dev/spidev0.0``). Some carriers daisy-chain the SX1261
- # behind the SX1302's SPI router and want this set to the same
- # path as the SX1302 SPI device. Wrong path = HAL refuses to
- # ``lgw_start`` after our config attempt, so we ship empty by
- # default and ask interested users to opt in explicitly.
+ # On RAK2287 / RAK5146 / SenseCap M1 the SX1261 is behind the
+ # concentrator's internal SPI router, not on a Pi chip-select —
+ # leave empty. Only the Semtech reference kit and custom carriers
+ # with a dedicated SX1261 CE line need a path (e.g.
+ # ``/dev/spidev0.1``). Wrong path can brick ``lgw_start`` on
+ # boards without Pi-visible SX1261; ``carrier_type`` guard clears
+ # mistaken values on RAK/SenseCap.
sx1261_spi_path: str = ""
+ # Carrier board signature from setup wizard I2C probe (``rak``,
+ # ``sensecap_m1``, or empty). Used to block Pi-visible SX1261 SPI
+ # paths that brick ``lgw_start()`` on RAK/SenseCap concentrators.
+ carrier_type: str = ""
+ # HAL GPS/PPS: align concentrator packet timestamps with GPS time via
+ # libloragw ``lgw_gps_*`` + ``sx1302_gps_enable``. Requires a HAL build
+ # that includes loragw_gps.c. Exclusive with ``location.source: uart`` on
+ # the same TTY (only one process may open the GPS serial port).
+ gps_pps_enabled: bool = False
+ gps_pps_tty_path: str = "/dev/ttyAMA0"
+ gps_family: str = "ubx7"
+ gps_pps_target_baud: int = 0
@dataclass
@@ -75,6 +84,9 @@ class MeshtasticConfig:
default_key_b64: str = "AQ=="
primary_channel_name: str = "LongFast"
channel_keys: dict[str, str] = field(default_factory=dict)
+ # PSK for the shared "admin" channel — required for remote ADMIN config read.
+ admin_key_b64: str = ""
+ admin_channel_name: str = "admin"
@dataclass
@@ -114,6 +126,45 @@ class StorageConfig:
cleanup_interval_seconds: int = 3600
+@dataclass
+class StrayFramesConfig:
+ """Undecodable RF frame logger (PR 08)."""
+
+ enabled: bool = True
+ max_retained: int = 10_000
+ retention_hours: float = 168.0
+
+
+@dataclass
+class MetricsConfig:
+ """Prometheus-compatible /metrics scrape endpoint (PR 09)."""
+
+ enabled: bool = False
+ require_auth: bool = True
+
+
+@dataclass
+class WebhookRuleConfig:
+ """One outbound HTTP rule for a mesh event (PR 10)."""
+
+ name: str = ""
+ url: str = ""
+ event: str = ""
+ enabled: bool = True
+ cooldown_seconds: float = 300.0
+ keyword: Optional[str] = None
+ battery_threshold_percent: float = 20.0
+ duty_threshold_percent: float = 80.0
+
+
+@dataclass
+class WebhookConfig:
+ """Event-driven outbound webhooks (PR 10)."""
+
+ enabled: bool = False
+ rules: list[WebhookRuleConfig] = field(default_factory=list)
+
+
@dataclass
class DashboardConfig:
host: str = "0.0.0.0" # nosec B104 -- intentional for local device dashboard
@@ -141,6 +192,18 @@ class DeviceConfig:
firmware_version: str = __version__
+@dataclass
+class StormGuardConfig:
+ """Memory-only storm/replay quarantine (PR 12)."""
+
+ enabled: bool = False
+ window_seconds: int = 60
+ identical_packet_threshold: int = 5
+ rate_threshold_per_minute: int = 30
+ quarantine_duration_seconds: int = 300
+ notify_dashboard: bool = True
+
+
@dataclass
class RelayConfig:
enabled: bool = False
@@ -150,29 +213,11 @@ class RelayConfig:
burst_size: int = 5
min_relay_rssi: float = -110.0
max_relay_rssi: float = -50.0
-
-
-@dataclass
-class TelemetryConfig:
- """Periodic device_metrics telemetry broadcast settings."""
-
- interval_minutes: int = 30
- startup_delay_seconds: int = 120
-
-
-@dataclass
-class PositionConfig:
- """Periodic POSITION broadcast settings."""
-
- interval_minutes: int = 15
- startup_delay_seconds: int = 180
- # Coordinates sent on the public LoRa mesh (Meshtastic POSITION packets).
- # ``static`` uses ``device.{latitude,longitude,altitude}`` (wizard pin).
- # ``live`` reads the active ``LocationSource`` (gpsd/uart) when a fix exists.
- coordinate_source: str = "static"
- # Privacy when ``coordinate_source`` is ``live``: exact, approximate
- # (~1.1 km rounding), or none (skip position on mesh). Ignored for static.
- location_precision: str = "approximate"
+ blocklist: list[str] = field(default_factory=list)
+ priority_list: list[str] = field(default_factory=list)
+ dedup_ttl_seconds: int = 300
+ channel_throttle_percent: dict[str, float] = field(default_factory=dict)
+ storm_guard: StormGuardConfig = field(default_factory=StormGuardConfig)
@dataclass
@@ -184,8 +229,6 @@ class MqttConfig:
password: str = "large4cats"
topic_root: str = "msh"
region: str = "US"
- tls_enabled: bool = False
- tls_ca_cert: str = ""
# Optional ``!xxxxxxxx`` override; blank uses MD5 hash of device name.
gateway_id: Optional[str] = None
publish_channels: list[str] = field(default_factory=lambda: ["LongFast", "MeshCore"])
@@ -230,8 +273,6 @@ class TransmitConfig:
short_name: str = "MPNT"
hop_limit: int = 3
nodeinfo: NodeInfoConfig = field(default_factory=NodeInfoConfig)
- telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
- position: PositionConfig = field(default_factory=PositionConfig)
@dataclass
@@ -241,15 +282,12 @@ class LocationConfig:
``source`` values:
- ``"static"`` : use ``device.latitude/longitude/altitude`` from
``local.yaml``. Backward-compatible default.
- - ``"gpsd"`` : connect to a local or remote ``gpsd`` daemon for
- live fixes (skyplot, optional mesh POSITION).
- Does not change ``device.{lat,lon,alt}`` (Meshradar
- pin). Auto-installed by ``scripts/install.sh``.
- - ``"uart"`` : reserved for direct on-board UART NMEA reading
- (RAK Pi HAT GPS). Plumbing exists in
- ``src.hal.gps_reader`` but is not wired into
- the runtime yet; treated as ``static`` until
- the source is implemented.
+ - ``"gpsd"`` : connect to a local or remote ``gpsd`` daemon and
+ overwrite ``device.{lat,lon,alt}`` when fixes
+ arrive. Auto-installed by ``scripts/install.sh``.
+ - ``"uart"`` : read NMEA GGA from an on-board UART GPS (RAK Pi
+ HAT on ``/dev/ttyAMA0``). Uses
+ ``src.hal.gps_reader.GpsReader``.
``gpsd_host`` / ``gpsd_port`` default to gpsd's well-known
localhost socket. Override only when running gpsd on a peer
@@ -269,6 +307,8 @@ class LocationConfig:
source: str = "static"
gpsd_host: str = "127.0.0.1"
gpsd_port: int = 2947
+ uart_path: str = "/dev/ttyAMA0"
+ uart_baud: int = 9600
update_interval_seconds: int = 5
min_fix_quality: int = 1
@@ -304,6 +344,23 @@ class WebAuthConfig:
session_version: int = 1
+@dataclass
+class SignalHealthConfig:
+ """Thresholds for node-card RSSI health badges and sparklines."""
+
+ green_rssi_floor: float = -100
+ yellow_rssi_floor: float = -115
+ min_packets_per_hour: int = 5
+
+
+@dataclass
+class ApiAutomationConfig:
+ """LAN automation API for Home Assistant / Node-RED scripting."""
+
+ enabled: bool = False
+ token: str = ""
+
+
@dataclass
class AppConfig:
radio: RadioConfig = field(default_factory=RadioConfig)
@@ -319,6 +376,11 @@ class AppConfig:
transmit: TransmitConfig = field(default_factory=TransmitConfig)
web_auth: WebAuthConfig = field(default_factory=WebAuthConfig)
location: LocationConfig = field(default_factory=LocationConfig)
+ signal_health: SignalHealthConfig = field(default_factory=SignalHealthConfig)
+ automation: ApiAutomationConfig = field(default_factory=ApiAutomationConfig)
+ stray_frames: StrayFramesConfig = field(default_factory=StrayFramesConfig)
+ metrics: MetricsConfig = field(default_factory=MetricsConfig)
+ webhooks: WebhookConfig = field(default_factory=WebhookConfig)
def _resolve_radio_frequency(radio: "RadioConfig") -> None:
@@ -356,25 +418,6 @@ def _merge_dataclass(instance, overrides: dict):
setattr(instance, key, value)
-def _collect_unknown_keys(instance, overrides: dict, prefix: str = "") -> list[str]:
- """Return dotted paths of override keys with no matching dataclass field.
-
- Mirrors the descent rules in :func:`_merge_dataclass`: it only recurses
- into a nested dataclass (e.g. ``transmit.nodeinfo``), so user-supplied
- mapping fields such as ``meshtastic.channel_keys`` are treated as opaque
- values rather than scanned for "unknown" keys.
- """
- unknown: list[str] = []
- for key, value in overrides.items():
- if not hasattr(instance, key):
- unknown.append(f"{prefix}{key}")
- continue
- current = getattr(instance, key)
- if dataclasses.is_dataclass(current) and isinstance(value, dict):
- unknown.extend(_collect_unknown_keys(current, value, f"{prefix}{key}."))
- return unknown
-
-
def _apply_yaml(cfg: AppConfig, path: Path) -> None:
"""Merge a single YAML file into an existing AppConfig."""
if not path.exists():
@@ -383,10 +426,6 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None:
with open(path, "r") as fh:
raw = yaml.safe_load(fh) or {}
- if not isinstance(raw, dict):
- logger.warning("Ignoring %s: top-level YAML is not a mapping.", path)
- return
-
section_map = {
"radio": cfg.radio,
"meshtastic": cfg.meshtastic,
@@ -401,28 +440,16 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None:
"transmit": cfg.transmit,
"web_auth": cfg.web_auth,
"location": cfg.location,
+ "signal_health": cfg.signal_health,
+ "automation": cfg.automation,
+ "stray_frames": cfg.stray_frames,
+ "metrics": cfg.metrics,
+ "webhooks": cfg.webhooks,
}
- unknown_keys: list[str] = []
- for section_name, section_value in raw.items():
- section_instance = section_map.get(section_name)
- if section_instance is None:
- unknown_keys.append(section_name)
- continue
- _merge_dataclass(section_instance, section_value)
- if isinstance(section_value, dict):
- unknown_keys.extend(
- _collect_unknown_keys(section_instance, section_value, f"{section_name}.")
- )
-
- if unknown_keys:
- logger.warning(
- "Ignoring %d unknown config key(s) in %s: %s. "
- "These were not applied -- check for typos against the documented schema.",
- len(unknown_keys),
- path,
- ", ".join(sorted(unknown_keys)),
- )
+ for section_name, section_instance in section_map.items():
+ if section_name in raw:
+ _merge_dataclass(section_instance, raw[section_name])
_VALID_CONFIG_EXTENSIONS = {".yaml", ".yml"}
@@ -449,10 +476,92 @@ def load_config(config_path: Optional[str] = None) -> AppConfig:
local = config_path or os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml")
_apply_yaml(cfg, _validated_config_path(local))
_resolve_radio_frequency(cfg.radio)
+ _normalize_webhook_rules(cfg.webhooks)
+ validate_config_consistency(cfg)
+ validate_webhook_config(cfg.webhooks)
return cfg
+def _normalize_webhook_rules(webhooks: WebhookConfig) -> None:
+ """Convert YAML dict rules into WebhookRuleConfig instances."""
+ normalized: list[WebhookRuleConfig] = []
+ for item in webhooks.rules:
+ if isinstance(item, WebhookRuleConfig):
+ normalized.append(item)
+ elif isinstance(item, dict):
+ normalized.append(WebhookRuleConfig(**item))
+ webhooks.rules = normalized
+
+
+def validate_webhook_config(webhooks: WebhookConfig) -> None:
+ """Reject invalid webhook rules at startup."""
+ if not webhooks.enabled:
+ return
+
+ if not webhooks.rules:
+ raise ValueError(
+ "webhooks.enabled is true but webhooks.rules is empty"
+ )
+
+ seen_names: set[str] = set()
+ for rule in webhooks.rules:
+ name = (rule.name or "").strip()
+ if not name:
+ raise ValueError("each webhooks.rules entry requires a name")
+ if name in seen_names:
+ raise ValueError(f"duplicate webhook rule name: {name!r}")
+ seen_names.add(name)
+
+ event = (rule.event or "").strip().lower()
+ if event not in {
+ "battery_low",
+ "node_offline",
+ "node_online",
+ "keyword_match",
+ "duty_spike",
+ "storm_quarantine",
+ }:
+ raise ValueError(
+ f"webhook rule {name!r} has unknown event {rule.event!r}"
+ )
+
+ url = (rule.url or "").strip()
+ if not url:
+ raise ValueError(f"webhook rule {name!r} requires a url")
+ if not (url.startswith("http://") or url.startswith("https://")):
+ raise ValueError(
+ f"webhook rule {name!r} url must start with http:// or https://"
+ )
+
+ if rule.cooldown_seconds < 0:
+ raise ValueError(
+ f"webhook rule {name!r} cooldown_seconds must be >= 0"
+ )
+
+ if event == "keyword_match" and not (rule.keyword or "").strip():
+ raise ValueError(
+ f"webhook rule {name!r} (keyword_match) requires keyword"
+ )
+
+
+def validate_config_consistency(config: AppConfig) -> None:
+ """Reject impossible radio/location combinations before hardware starts."""
+ if not config.radio.gps_pps_enabled:
+ return
+ if config.location.source != "uart":
+ return
+ pps_tty = os.path.normpath(config.radio.gps_pps_tty_path)
+ uart_tty = os.path.normpath(config.location.uart_path)
+ if pps_tty == uart_tty:
+ raise ValueError(
+ "radio.gps_pps_enabled and location.source=uart cannot share "
+ f"the same serial device ({pps_tty!r}). Use location.source=gpsd "
+ "or static for dashboard coordinates while PPS owns the HAT UART, "
+ "or disable gps_pps_enabled when using UART for location only."
+ )
+
+
def _get_local_yaml_path() -> Path:
"""Resolve the local.yaml path used for user overrides."""
raw = os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml")
diff --git a/src/relay/node_id.py b/src/relay/node_id.py
new file mode 100644
index 00000000..f7b5be1d
--- /dev/null
+++ b/src/relay/node_id.py
@@ -0,0 +1,28 @@
+"""Normalize and validate Meshtastic node IDs for relay filter lists."""
+
+from __future__ import annotations
+
+import re
+
+_NODE_ID_RE = re.compile(r"^[0-9a-f]{8}$", re.IGNORECASE)
+
+
+def normalize_node_id(node_id: str) -> str:
+ """Strip optional ``!`` prefix and lowercase for consistent matching."""
+ return (node_id or "").strip().lower().lstrip("!")
+
+
+def validate_node_ids(node_ids: list[str]) -> list[str]:
+ """Return normalized unique node IDs or raise ValueError."""
+ seen: set[str] = set()
+ normalized: list[str] = []
+ for raw in node_ids:
+ nid = normalize_node_id(raw)
+ if not _NODE_ID_RE.match(nid):
+ raise ValueError(
+ f"Invalid node ID {raw!r} — expected 8 hex chars (no ! prefix)"
+ )
+ if nid not in seen:
+ seen.add(nid)
+ normalized.append(nid)
+ return normalized
diff --git a/src/transmit/meshtastic_builder.py b/src/transmit/meshtastic_builder.py
index 2fb20b41..88948a99 100644
--- a/src/transmit/meshtastic_builder.py
+++ b/src/transmit/meshtastic_builder.py
@@ -9,7 +9,6 @@
import logging
import struct
-from typing import Sequence
from src.decode.crypto_service import CryptoService
@@ -17,13 +16,9 @@
BROADCAST_ADDR = 0xFFFFFFFF
PORTNUM_TEXT_MESSAGE = 1
-PORTNUM_POSITION = 3
PORTNUM_NODEINFO = 4
-PORTNUM_ROUTING = 5
-PORTNUM_TELEMETRY = 67
-PORTNUM_TRACEROUTE = 70
+PORTNUM_ADMIN = 6
HW_MODEL_PRIVATE_HW = 255
-MAINS_BATTERY_LEVEL = 101
class MeshtasticPacketBuilder:
@@ -43,32 +38,26 @@ def build_text_message(
hop_limit: int = 3,
hop_start: int = 3,
want_ack: bool = False,
- recipient_public_key: bytes | None = None,
) -> bytes | None:
- """Build a complete encrypted TEXT_MESSAGE_APP packet."""
+ """Build a complete encrypted TEXT_MESSAGE_APP packet.
+
+ Returns the full on-air byte sequence (header + ciphertext),
+ or None if encryption fails.
+ """
inner = self._serialize_data(PORTNUM_TEXT_MESSAGE, text.encode("utf-8"))
- ciphertext = self._encrypt_payload(
- inner,
- packet_id,
- source_id,
- dest,
- channel_key,
- channel_hash,
- recipient_public_key,
+ ciphertext = self._crypto.encrypt_meshtastic(
+ inner, packet_id, source_id, key=channel_key
)
if ciphertext is None:
logger.error("Encryption failed for packet %d", packet_id)
return None
- on_air_hash = 0 if recipient_public_key else channel_hash
header = self._build_header(
- dest,
- source_id,
- packet_id,
+ dest, source_id, packet_id,
hop_limit=hop_limit,
hop_start=hop_start,
want_ack=want_ack,
- channel_hash=on_air_hash,
+ channel_hash=channel_hash,
)
return header + ciphertext
@@ -79,16 +68,20 @@ def build_nodeinfo(
long_name: str,
short_name: str,
hw_model: int = HW_MODEL_PRIVATE_HW,
- public_key: bytes | None = None,
channel_key: bytes | None = None,
channel_hash: int = 0x08,
hop_limit: int = 3,
hop_start: int = 3,
) -> bytes | None:
- """Build a broadcast NODEINFO_APP packet announcing this node."""
+ """Build a broadcast NODEINFO_APP packet announcing this node.
+
+ Wraps a serialized ``User`` protobuf in the standard encrypted
+ Meshtastic envelope. Recipients use this to populate their
+ contact list so DMs can be addressed by name.
+ """
node_id_str = f"!{source_id:08x}"
user_payload = self._serialize_user(
- node_id_str, long_name, short_name, hw_model, public_key
+ node_id_str, long_name, short_name, hw_model
)
inner = self._serialize_data(PORTNUM_NODEINFO, user_payload)
ciphertext = self._crypto.encrypt_meshtastic(
@@ -99,9 +92,7 @@ def build_nodeinfo(
return None
header = self._build_header(
- BROADCAST_ADDR,
- source_id,
- packet_id,
+ BROADCAST_ADDR, source_id, packet_id,
hop_limit=hop_limit,
hop_start=hop_start,
want_ack=False,
@@ -109,316 +100,54 @@ def build_nodeinfo(
)
return header + ciphertext
- def build_routing_ack(
+ def build_admin_message(
self,
- source_id: int,
+ admin_payload: bytes,
dest: int,
- packet_id: int,
- request_id: int,
- channel_key: bytes | None = None,
- channel_hash: int = 0x08,
- hop_limit: int = 3,
- hop_start: int = 3,
- recipient_public_key: bytes | None = None,
- ) -> bytes | None:
- """Build a ROUTING ACK for an inbound direct message."""
- routing_payload = b""
- inner = self._serialize_data(
- PORTNUM_ROUTING, routing_payload, request_id=request_id
- )
- ciphertext = self._encrypt_payload(
- inner,
- packet_id,
- source_id,
- dest,
- channel_key,
- channel_hash,
- recipient_public_key,
- )
- if ciphertext is None:
- return None
- on_air_hash = 0 if recipient_public_key else channel_hash
- header = self._build_header(
- dest,
- source_id,
- packet_id,
- hop_limit=hop_limit,
- hop_start=hop_start,
- want_ack=False,
- channel_hash=on_air_hash,
- )
- return header + ciphertext
-
- def build_telemetry(
- self,
- source_id: int,
- packet_id: int,
- *,
- battery_level: int = MAINS_BATTERY_LEVEL,
- voltage: float = 5.0,
- channel_utilization: float = 0.0,
- air_util_tx: float = 0.0,
- uptime_seconds: int = 0,
- channel_key: bytes | None = None,
- channel_hash: int = 0x08,
- hop_limit: int = 3,
- hop_start: int = 3,
- ) -> bytes | None:
- """Build a broadcast TELEMETRY device_metrics packet."""
- try:
- from meshtastic.protobuf import telemetry_pb2
-
- telem = telemetry_pb2.Telemetry()
- telem.device_metrics.battery_level = battery_level
- telem.device_metrics.voltage = voltage
- telem.device_metrics.channel_utilization = channel_utilization
- telem.device_metrics.air_util_tx = air_util_tx
- telem.device_metrics.uptime_seconds = uptime_seconds
- payload = telem.SerializeToString()
- except Exception:
- logger.exception("Telemetry protobuf build failed")
- return None
-
- inner = self._serialize_data(PORTNUM_TELEMETRY, payload)
- ciphertext = self._crypto.encrypt_meshtastic(
- inner, packet_id, source_id, key=channel_key
- )
- if ciphertext is None:
- return None
- header = self._build_header(
- BROADCAST_ADDR,
- source_id,
- packet_id,
- hop_limit=hop_limit,
- hop_start=hop_start,
- channel_hash=channel_hash,
- )
- return header + ciphertext
-
- def build_telemetry_reply(
- self,
source_id: int,
- dest: int,
packet_id: int,
- request_id: int,
- *,
- variant: str = "device_metrics",
- battery_level: int = MAINS_BATTERY_LEVEL,
- voltage: float = 5.0,
- channel_utilization: float = 0.0,
- air_util_tx: float = 0.0,
- uptime_seconds: int = 0,
- num_packets_tx: int = 0,
- num_packets_rx: int = 0,
- num_packets_rx_bad: int = 0,
- num_online_nodes: int = 0,
- num_total_nodes: int = 0,
- num_tx_relay: int = 0,
- noise_floor: int | None = None,
- telemetry_time: int = 0,
- channel_key: bytes | None = None,
- channel_hash: int = 0x08,
+ admin_key: bytes,
+ admin_channel_name: str = "admin",
hop_limit: int = 3,
hop_start: int = 3,
- recipient_public_key: bytes | None = None,
+ want_ack: bool = True,
) -> bytes | None:
- """Build a unicast TELEMETRY reply to a telemetry request."""
- try:
- from meshtastic.protobuf import telemetry_pb2
-
- telem = telemetry_pb2.Telemetry()
- if telemetry_time:
- telem.time = telemetry_time
- if variant == "local_stats":
- ls = telem.local_stats
- ls.uptime_seconds = uptime_seconds
- ls.channel_utilization = channel_utilization
- ls.air_util_tx = air_util_tx
- ls.num_packets_tx = num_packets_tx
- ls.num_packets_rx = num_packets_rx
- ls.num_packets_rx_bad = num_packets_rx_bad
- ls.num_online_nodes = num_online_nodes
- ls.num_total_nodes = num_total_nodes
- ls.num_tx_relay = num_tx_relay
- if noise_floor is not None:
- ls.noise_floor = noise_floor
- else:
- telem.device_metrics.battery_level = battery_level
- telem.device_metrics.voltage = voltage
- telem.device_metrics.channel_utilization = channel_utilization
- telem.device_metrics.air_util_tx = air_util_tx
- telem.device_metrics.uptime_seconds = uptime_seconds
- payload = telem.SerializeToString()
- except Exception:
- logger.exception("Telemetry reply protobuf build failed")
- return None
-
- inner = self._serialize_data(
- PORTNUM_TELEMETRY, payload, request_id=request_id
- )
- ciphertext = self._encrypt_payload(
- inner,
- packet_id,
- source_id,
- dest,
- channel_key,
- channel_hash,
- recipient_public_key,
+ """Build an encrypted ADMIN_APP packet using the admin channel PSK."""
+ inner = self._serialize_data(PORTNUM_ADMIN, admin_payload)
+ channel_hash = self._crypto.compute_channel_hash(
+ admin_channel_name, admin_key
)
- if ciphertext is None:
- return None
- on_air_hash = 0 if recipient_public_key else channel_hash
- header = self._build_header(
- dest,
- source_id,
- packet_id,
- hop_limit=hop_limit,
- hop_start=hop_start,
- channel_hash=on_air_hash,
- )
- return header + ciphertext
-
- def build_position(
- self,
- source_id: int,
- packet_id: int,
- latitude: float,
- longitude: float,
- altitude: float | None = None,
- channel_key: bytes | None = None,
- channel_hash: int = 0x08,
- hop_limit: int = 3,
- hop_start: int = 3,
- ) -> bytes | None:
- """Build a broadcast POSITION packet."""
- try:
- from meshtastic.protobuf import mesh_pb2
-
- pos = mesh_pb2.Position()
- pos.latitude_i = int(latitude * 1e7)
- pos.longitude_i = int(longitude * 1e7)
- if altitude is not None:
- pos.altitude = int(altitude)
- payload = pos.SerializeToString()
- except Exception:
- logger.exception("Position protobuf build failed")
- return None
-
- inner = self._serialize_data(PORTNUM_POSITION, payload)
ciphertext = self._crypto.encrypt_meshtastic(
- inner, packet_id, source_id, key=channel_key
+ inner, packet_id, source_id, key=admin_key
)
if ciphertext is None:
- return None
- header = self._build_header(
- BROADCAST_ADDR,
- source_id,
- packet_id,
- hop_limit=hop_limit,
- hop_start=hop_start,
- channel_hash=channel_hash,
- )
- return header + ciphertext
-
- def build_traceroute_reply(
- self,
- source_id: int,
- dest: int,
- packet_id: int,
- route_nodes: Sequence[int],
- *,
- request_id: int = 0,
- snr_towards: Sequence[int] | None = None,
- route_back: Sequence[int] | None = None,
- snr_back: Sequence[int] | None = None,
- channel_key: bytes | None = None,
- channel_hash: int = 0x08,
- hop_limit: int = 3,
- hop_start: int = 3,
- recipient_public_key: bytes | None = None,
- ) -> bytes | None:
- """Build a TRACEROUTE reply with a RouteDiscovery payload."""
- try:
- from meshtastic.protobuf import mesh_pb2
-
- rd = mesh_pb2.RouteDiscovery()
- for node in route_nodes:
- rd.route.append(node)
- if snr_towards:
- rd.snr_towards.extend(snr_towards)
- if route_back:
- rd.route_back.extend(route_back)
- if snr_back:
- rd.snr_back.extend(snr_back)
- payload = rd.SerializeToString()
- except Exception:
- logger.exception("Traceroute protobuf build failed")
+ logger.error("Admin encryption failed for packet %d", packet_id)
return None
- inner = self._serialize_data(
- PORTNUM_TRACEROUTE, payload, request_id=request_id
- )
- ciphertext = self._encrypt_payload(
- inner,
- packet_id,
- source_id,
- dest,
- channel_key,
- channel_hash,
- recipient_public_key,
- )
- if ciphertext is None:
- return None
- on_air_hash = 0 if recipient_public_key else channel_hash
header = self._build_header(
dest,
source_id,
packet_id,
hop_limit=hop_limit,
hop_start=hop_start,
- channel_hash=on_air_hash,
+ want_ack=want_ack,
+ channel_hash=channel_hash,
)
return header + ciphertext
- def _encrypt_payload(
- self,
- inner: bytes,
- packet_id: int,
- source_id: int,
- dest: int,
- channel_key: bytes | None,
- channel_hash: int,
- recipient_public_key: bytes | None,
- ) -> bytes | None:
- if (
- recipient_public_key
- and dest != BROADCAST_ADDR
- and self._crypto.has_pki()
- ):
- return self._crypto.encrypt_meshtastic_pki(
- inner,
- packet_id,
- source_id,
- recipient_public_key,
- )
- return self._crypto.encrypt_meshtastic(
- inner, packet_id, source_id, key=channel_key
- )
-
@staticmethod
- def _serialize_data(
- portnum: int, payload: bytes, request_id: int = 0
- ) -> bytes:
- """Serialize a mesh_pb2.Data protobuf manually."""
+ def _serialize_data(portnum: int, payload: bytes) -> bytes:
+ """Serialize a mesh_pb2.Data protobuf manually.
+
+ Avoids importing the full protobuf library at runtime.
+ Wire format: field 1 (portnum) varint + field 2 (payload) bytes.
+ """
result = bytearray()
result.append(0x08)
result.extend(_encode_varint(portnum))
result.append(0x12)
result.extend(_encode_varint(len(payload)))
result.extend(payload)
- if request_id:
- result.append(0x35)
- result.extend(struct.pack(" bytes:
- """Serialize a mesh_pb2.User protobuf manually."""
+ """Serialize a mesh_pb2.User protobuf manually.
+
+ Wire format used here: field 1 (id, string), field 2
+ (long_name, string), field 3 (short_name, string), and field 5
+ (hw_model, varint). The ``macaddr`` field (4) is intentionally
+ omitted since the Meshpoint has no canonical radio MAC and
+ clients tolerate its absence.
+ """
result = bytearray()
for tag, text in (
(0x0A, node_id_str),
@@ -442,10 +177,6 @@ def _serialize_user(
result.extend(encoded)
result.append(0x28)
result.extend(_encode_varint(hw_model))
- if public_key:
- result.append(0x42)
- result.extend(_encode_varint(len(public_key)))
- result.extend(public_key)
return bytes(result)
@staticmethod
diff --git a/src/transmit/tx_service.py b/src/transmit/tx_service.py
index 79b646fc..3bc0f438 100644
--- a/src/transmit/tx_service.py
+++ b/src/transmit/tx_service.py
@@ -17,7 +17,6 @@
from src.models.packet import Protocol
from src.transmit.duty_cycle import DutyCycleTracker
-from src.transmit.reply_hop_policy import MeshtasticReplyHopPolicy
logger = logging.getLogger(__name__)
@@ -88,8 +87,6 @@ def __init__(
self._source_node_id = self._resolve_node_id()
if persist_derived_node_id:
self._persist_derived_node_id_if_needed()
- self._device_metrics_provider = None
- self._local_stats_provider = None
@property
def meshtastic_enabled(self) -> bool:
@@ -107,35 +104,6 @@ def meshcore_enabled(self) -> bool:
def source_node_id(self) -> int:
return self._source_node_id
- def set_telemetry_reply_providers(
- self,
- device_metrics_provider=None,
- local_stats_provider=None,
- ) -> None:
- self._device_metrics_provider = device_metrics_provider
- self._local_stats_provider = local_stats_provider
-
- @staticmethod
- def _recipient_pubkey_for_reply(original, requester: int, crypto) -> bytes | None:
- """Use PKI only when the inbound request was PKI-encrypted (ch=0x00)."""
- if original.channel_hash != 0:
- return None
- if crypto is None:
- return None
- key = crypto.lookup_public_key(requester)
- if key is None:
- key = crypto.refresh_public_key_from_db(requester)
- return key
-
- @staticmethod
- def _reply_hop_fields(original, configured_hop_limit: int) -> tuple[int, int]:
- """Mirror firmware hop limits for want_response replies."""
- return MeshtasticReplyHopPolicy.reply_hop_fields(
- original.hop_limit,
- original.hop_start,
- configured_hop_limit,
- )
-
@property
def node_id_source(self) -> str:
"""Where the resolved node_id came from: 'config', 'derived', or 'random'."""
@@ -190,9 +158,6 @@ async def send_nodeinfo(
packet_id = self._next_packet_id()
channel_hash, channel_key = self._resolve_channel(0)
- public_key = None
- if self._crypto is not None:
- public_key = self._crypto.public_key
try:
nodeinfo_hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
@@ -202,7 +167,6 @@ async def send_nodeinfo(
long_name=long_name,
short_name=short_name,
hw_model=hw_model,
- public_key=public_key,
channel_key=channel_key,
channel_hash=channel_hash,
hop_limit=nodeinfo_hop_limit,
@@ -287,9 +251,6 @@ async def _send_meshtastic(
dest_int = self._resolve_destination(destination, Protocol.MESHTASTIC)
packet_id = self._next_packet_id()
channel_hash, channel_key = self._resolve_channel(channel)
- recipient_pubkey = None
- if dest_int != BROADCAST_ADDR_MT and self._crypto is not None:
- recipient_pubkey = self._crypto.lookup_public_key(dest_int)
hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
packet_bytes = builder.build_text_message(
@@ -302,7 +263,6 @@ async def _send_meshtastic(
hop_limit=hop_limit,
hop_start=hop_limit,
want_ack=want_ack,
- recipient_public_key=recipient_pubkey,
)
if packet_bytes is None:
return SendResult(
@@ -368,285 +328,78 @@ async def _send_meshtastic(
error=f"lgw_send returned {result_code}",
)
- async def send_routing_ack(self, original) -> SendResult:
- """Reply with a Meshtastic routing ACK to an inbound DM."""
+ async def send_admin_message(
+ self,
+ admin_payload: bytes,
+ destination: int | str,
+ admin_key: bytes,
+ admin_channel_name: str = "admin",
+ want_ack: bool = True,
+ ) -> SendResult:
+ """Transmit an ADMIN_APP packet encrypted with the admin channel PSK."""
if not self.meshtastic_enabled:
- return SendResult(success=False, protocol="meshtastic", error="TX unavailable")
-
- builder = self._get_builder()
- if builder is None or not hasattr(builder, "build_routing_ack"):
- return SendResult(success=False, protocol="meshtastic", error="Builder unavailable")
-
- try:
- request_id = int(original.packet_id, 16)
- dest = int(original.source_id, 16)
- except ValueError:
- return SendResult(success=False, protocol="meshtastic", error="Invalid packet ids")
-
- packet_id = self._next_packet_id()
- channel_hash = original.channel_hash
- _, channel_key = self._resolve_channel_by_hash(channel_hash)
- recipient_pubkey = self._recipient_pubkey_for_reply(
- original, dest, self._crypto
- )
- if channel_hash == 0 and recipient_pubkey is None:
return SendResult(
success=False,
protocol="meshtastic",
- error="No public_key for PKI routing ACK recipient",
+ error="Meshtastic TX not available",
)
- configured = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
- hop_limit, hop_start = self._reply_hop_fields(original, configured)
-
- packet_bytes = builder.build_routing_ack(
- source_id=self._source_node_id,
- dest=dest,
- packet_id=packet_id,
- request_id=request_id,
- channel_key=channel_key,
- channel_hash=channel_hash,
- hop_limit=hop_limit,
- hop_start=hop_start,
- recipient_public_key=recipient_pubkey,
- )
- if packet_bytes is None:
- return SendResult(success=False, protocol="meshtastic", error="ACK build failed")
-
- return await self._send_built_packet(packet_bytes, packet_id, label="routing ACK")
-
- async def send_traceroute_reply(self, original) -> SendResult:
- """Reply to a traceroute probe addressed to this node."""
- if not self.meshtastic_enabled:
- return SendResult(success=False, protocol="meshtastic", error="TX unavailable")
builder = self._get_builder()
- if builder is None or not hasattr(builder, "build_traceroute_reply"):
- return SendResult(success=False, protocol="meshtastic", error="Builder unavailable")
-
- try:
- requester = int(original.source_id, 16)
- request_id = int(original.packet_id, 16)
- except ValueError:
- return SendResult(success=False, protocol="meshtastic", error="Invalid source id")
-
- rx_snr = (
- float(original.signal.snr)
- if original.signal and original.signal.snr is not None
- else None
- )
- route_nodes, snr_towards, route_back, snr_back = (
- self._build_traceroute_reply_data(original, rx_snr)
- )
-
- packet_id = self._next_packet_id()
- channel_hash = original.channel_hash
- _, channel_key = self._resolve_channel_by_hash(channel_hash)
- recipient_pubkey = self._recipient_pubkey_for_reply(
- original, requester, self._crypto
- )
- if channel_hash == 0 and recipient_pubkey is None:
+ if builder is None or not hasattr(builder, "build_admin_message"):
return SendResult(
success=False,
protocol="meshtastic",
- error="No public_key for PKI traceroute reply recipient",
+ error="Admin packet builder unavailable",
)
- configured = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
- hop_limit, hop_start = self._reply_hop_fields(original, configured)
- packet_bytes = builder.build_traceroute_reply(
- source_id=self._source_node_id,
- dest=requester,
- packet_id=packet_id,
- route_nodes=route_nodes,
- request_id=request_id,
- snr_towards=snr_towards or None,
- route_back=route_back or None,
- snr_back=snr_back or None,
- channel_key=channel_key,
- channel_hash=channel_hash,
- hop_limit=hop_limit,
- hop_start=hop_start,
- recipient_public_key=recipient_pubkey,
- )
- if packet_bytes is None:
+ dest_int = self._resolve_destination(destination, Protocol.MESHTASTIC)
+ if dest_int in RESERVED_NODE_IDS:
return SendResult(
- success=False, protocol="meshtastic", error="Traceroute build failed"
+ success=False,
+ protocol="meshtastic",
+ error="Invalid destination node",
)
- return await self._send_built_packet(
- packet_bytes, packet_id, label="traceroute reply"
- )
-
- async def send_telemetry(
- self,
- *,
- battery_level: int = 101,
- voltage: float = 5.0,
- channel_utilization: float = 0.0,
- air_util_tx: float = 0.0,
- uptime_seconds: int = 0,
- ) -> SendResult:
- if not self.meshtastic_enabled:
- return SendResult(success=False, protocol="meshtastic", error="TX unavailable")
-
- builder = self._get_builder()
- if builder is None or not hasattr(builder, "build_telemetry"):
- return SendResult(success=False, protocol="meshtastic", error="Builder unavailable")
-
packet_id = self._next_packet_id()
- channel_hash, channel_key = self._resolve_channel(0)
hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
-
- packet_bytes = builder.build_telemetry(
- source_id=self._source_node_id,
- packet_id=packet_id,
- battery_level=battery_level,
- voltage=voltage,
- channel_utilization=channel_utilization,
- air_util_tx=air_util_tx,
- uptime_seconds=uptime_seconds,
- channel_key=channel_key,
- channel_hash=channel_hash,
- hop_limit=hop_limit,
- hop_start=hop_limit,
- )
- if packet_bytes is None:
- return SendResult(success=False, protocol="meshtastic", error="Telemetry build failed")
-
- return await self._send_built_packet(packet_bytes, packet_id, label="telemetry")
-
- async def send_telemetry_reply(self, original) -> SendResult:
- """Reply to an inbound telemetry request addressed to this node."""
- if not self.meshtastic_enabled:
- return SendResult(success=False, protocol="meshtastic", error="TX unavailable")
-
- builder = self._get_builder()
- if builder is None or not hasattr(builder, "build_telemetry_reply"):
- return SendResult(success=False, protocol="meshtastic", error="Builder unavailable")
-
try:
- requester = int(original.source_id, 16)
- request_id = int(original.packet_id, 16)
- except ValueError:
- return SendResult(success=False, protocol="meshtastic", error="Invalid source id")
-
- payload = original.decoded_payload or {}
- variant = payload.get("telemetry_variant", "device_metrics")
- if variant == "local_stats":
- metrics = (
- self._local_stats_provider() if self._local_stats_provider else {}
- )
- else:
- metrics = (
- self._device_metrics_provider() if self._device_metrics_provider else {}
+ packet_bytes = builder.build_admin_message(
+ admin_payload=admin_payload,
+ dest=dest_int,
+ source_id=self._source_node_id,
+ packet_id=packet_id,
+ admin_key=admin_key,
+ admin_channel_name=admin_channel_name,
+ hop_limit=hop_limit,
+ hop_start=hop_limit,
+ want_ack=want_ack,
)
-
- packet_id = self._next_packet_id()
- channel_hash = original.channel_hash
- _, channel_key = self._resolve_channel_by_hash(channel_hash)
- recipient_pubkey = self._recipient_pubkey_for_reply(
- original, requester, self._crypto
- )
- if channel_hash == 0 and recipient_pubkey is None:
+ except Exception as exc:
+ logger.exception("Admin packet build failed")
return SendResult(
success=False,
protocol="meshtastic",
- error="No public_key for PKI telemetry reply recipient",
- )
- configured = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
- hop_limit, hop_start = self._reply_hop_fields(original, configured)
-
- build_kwargs = {
- "source_id": self._source_node_id,
- "dest": requester,
- "packet_id": packet_id,
- "request_id": request_id,
- "variant": variant,
- "telemetry_time": int(time.time()),
- "channel_key": channel_key,
- "channel_hash": channel_hash,
- "hop_limit": hop_limit,
- "hop_start": hop_start,
- "recipient_public_key": recipient_pubkey,
- }
- if variant == "local_stats":
- build_kwargs.update(
- {
- "uptime_seconds": int(metrics.get("uptime_seconds", 0)),
- "channel_utilization": float(
- metrics.get("channel_utilization", 0.0)
- ),
- "air_util_tx": float(metrics.get("air_util_tx", 0.0)),
- "num_packets_tx": int(metrics.get("num_packets_tx", 0)),
- "num_packets_rx": int(metrics.get("num_packets_rx", 0)),
- "num_packets_rx_bad": int(metrics.get("num_packets_rx_bad", 0)),
- "num_online_nodes": int(metrics.get("num_online_nodes", 0)),
- "num_total_nodes": int(metrics.get("num_total_nodes", 0)),
- "num_tx_relay": int(metrics.get("num_tx_relay", 0)),
- }
- )
- noise_floor = metrics.get("noise_floor")
- if noise_floor is not None:
- build_kwargs["noise_floor"] = int(noise_floor)
- else:
- build_kwargs.update(
- {
- "battery_level": int(metrics.get("battery_level", 101)),
- "voltage": float(metrics.get("voltage", 5.0)),
- "channel_utilization": float(
- metrics.get("channel_utilization", 0.0)
- ),
- "air_util_tx": float(metrics.get("air_util_tx", 0.0)),
- "uptime_seconds": int(metrics.get("uptime_seconds", 0)),
- }
+ packet_id=f"{packet_id:08x}",
+ error=f"Admin packet build failed: {exc}",
)
- packet_bytes = builder.build_telemetry_reply(**build_kwargs)
if packet_bytes is None:
return SendResult(
- success=False, protocol="meshtastic", error="Telemetry reply build failed"
+ success=False,
+ protocol="meshtastic",
+ packet_id=f"{packet_id:08x}",
+ error="Admin packet build returned None",
)
- return await self._send_built_packet(
- packet_bytes, packet_id, label="telemetry reply"
- )
-
- async def send_position(
- self,
- latitude: float,
- longitude: float,
- altitude: float | None = None,
- ) -> SendResult:
- if not self.meshtastic_enabled:
- return SendResult(success=False, protocol="meshtastic", error="TX unavailable")
-
- builder = self._get_builder()
- if builder is None or not hasattr(builder, "build_position"):
- return SendResult(success=False, protocol="meshtastic", error="Builder unavailable")
-
- packet_id = self._next_packet_id()
- channel_hash, channel_key = self._resolve_channel(0)
- hop_limit = self._config.hop_limit if self._config else DEFAULT_HOP_LIMIT
-
- packet_bytes = builder.build_position(
- source_id=self._source_node_id,
- packet_id=packet_id,
- latitude=latitude,
- longitude=longitude,
- altitude=altitude,
- channel_key=channel_key,
- channel_hash=channel_hash,
- hop_limit=hop_limit,
- hop_start=hop_limit,
+ logger.info(
+ "TX ADMIN: dest=%08x src=%08x id=%08x channel=%r len=%d",
+ dest_int,
+ self._source_node_id,
+ packet_id,
+ admin_channel_name,
+ len(packet_bytes),
)
- if packet_bytes is None:
- return SendResult(success=False, protocol="meshtastic", error="Position build failed")
- return await self._send_built_packet(packet_bytes, packet_id, label="position")
-
- async def _send_built_packet(
- self, packet_bytes: bytes, packet_id: int, *, label: str
- ) -> SendResult:
tx_pkt = self._build_hal_packet(packet_bytes)
airtime_ms = await self._get_airtime(tx_pkt)
@@ -663,7 +416,6 @@ async def _send_built_packet(
if result_code == 0:
if self._duty:
self._duty.record_tx(airtime_ms)
- logger.info("TX %s OK: id=%08x airtime=%dms", label, packet_id, airtime_ms)
return SendResult(
success=True,
protocol="meshtastic",
@@ -676,6 +428,7 @@ async def _send_built_packet(
protocol="meshtastic",
packet_id=f"{packet_id:08x}",
error=f"lgw_send returned {result_code}",
+ airtime_ms=airtime_ms,
)
async def send_raw_relay(
@@ -982,68 +735,6 @@ def _resolve_channel(self, channel: int) -> tuple[int, bytes | None]:
logger.debug("Channel hash fallback to 0x08", exc_info=True)
return 0x08, None
- @staticmethod
- def _build_traceroute_reply_data(
- original, rx_snr: float | None
- ) -> tuple[list[int], list[int], list[int], list[int]]:
- """Build RouteDiscovery fields like Meshtastic firmware at the destination.
-
- Relays append node ids plus SNR on the way in. The target only appends the
- final-hop SNR (SNRonly) and does not add itself to ``route``. Meshtastic 2.5+
- also expects ``route_back`` / ``snr_back`` on the response.
- """
- payload = original.decoded_payload or {}
- route_nodes: list[int] = []
- for node_hex in payload.get("route") or []:
- try:
- route_nodes.append(int(node_hex, 16))
- except (TypeError, ValueError):
- continue
-
- snr_towards: list[int] = []
- for val in payload.get("snr_towards") or []:
- try:
- snr_towards.append(int(val))
- except (TypeError, ValueError):
- continue
-
- encoded_snr: int | None = None
- if rx_snr is not None:
- encoded_snr = int(round(float(rx_snr) * 4))
-
- if encoded_snr is not None:
- snr_towards.append(encoded_snr)
-
- requester = int(original.source_id, 16)
- route_back = [requester]
- snr_back = [encoded_snr] if encoded_snr is not None else []
-
- return route_nodes, snr_towards, route_back, snr_back
-
- def _resolve_channel_by_hash(self, channel_hash: int) -> tuple[int, bytes | None]:
- """Resolve encryption key from a captured on-air channel hash."""
- if self._crypto is None:
- return channel_hash, None
- try:
- primary_name = self._primary_channel_name
- all_keys = self._crypto.get_all_keys()
- if all_keys:
- primary_hash = self._crypto.compute_channel_hash(
- primary_name, all_keys[0]
- )
- if primary_hash == channel_hash:
- return channel_hash, all_keys[0]
- for i, (ch_name, key) in enumerate(self._crypto._keys.items(), start=1):
- if i < len(all_keys):
- h = self._crypto.compute_channel_hash(ch_name, all_keys[i])
- if h == channel_hash:
- return channel_hash, all_keys[i]
- if all_keys:
- return channel_hash, all_keys[0]
- except Exception:
- logger.debug("Channel-by-hash lookup failed", exc_info=True)
- return channel_hash, None
-
def _get_preset_name(self) -> str:
"""Derive the Meshtastic modem preset display name from radio params."""
if not self._radio_config:
diff --git a/tests/test_admin_reader.py b/tests/test_admin_reader.py
new file mode 100644
index 00000000..e5c0370a
--- /dev/null
+++ b/tests/test_admin_reader.py
@@ -0,0 +1,133 @@
+"""Unit tests for remote ADMIN config read (PR 15)."""
+from __future__ import annotations
+
+import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from meshtastic.protobuf import admin_pb2, config_pb2, mesh_pb2
+
+from src.admin.config_decode import _redact_value, message_to_redacted_dict
+from src.admin.pending_store import PendingConfigStore
+from src.admin.reader import AdminConfigError, AdminConfigReader, PORTNUM_ADMIN
+from src.decode.crypto_service import CryptoService
+from src.models.packet import Packet, PacketType, Protocol
+from src.transmit.meshtastic_builder import MeshtasticPacketBuilder
+
+
+class TestAdminMessageBuild(unittest.TestCase):
+ def test_device_config_request_serializes(self):
+ msg = admin_pb2.AdminMessage()
+ msg.get_config_request = admin_pb2.AdminMessage.DEVICE_CONFIG
+ self.assertEqual(msg.SerializeToString(), b"\x28\x00")
+
+ def test_owner_request_serializes(self):
+ msg = admin_pb2.AdminMessage()
+ msg.get_owner_request = True
+ self.assertEqual(msg.SerializeToString(), b"\x18\x01")
+
+
+class TestConfigRedaction(unittest.TestCase):
+ def test_psk_fields_redacted(self):
+ raw = {
+ "security": {"private_key": "secret", "public_key": "pub"},
+ "lora": {"bandwidth": 250, "psk": "hide-me"},
+ }
+ out = _redact_value(raw)
+ self.assertEqual(out["security"]["private_key"], "***")
+ self.assertEqual(out["security"]["public_key"], "***")
+ self.assertEqual(out["lora"]["psk"], "***")
+ self.assertEqual(out["lora"]["bandwidth"], 250)
+
+ def test_message_to_dict_keeps_safe_fields(self):
+ cfg = config_pb2.Config()
+ cfg.lora.bandwidth = 250
+ out = message_to_redacted_dict(cfg)
+ self.assertEqual(out["lora"]["bandwidth"], 250)
+
+
+class TestPendingStore(unittest.TestCase):
+ def test_debounce_blocks_second_request(self):
+ store = PendingConfigStore()
+ store.begin("a3f2b1c0", section="device", packet_id="00000001", expected_response="get_config_response")
+ ok, _ = store.can_request("a3f2b1c0")
+ self.assertFalse(ok)
+
+
+class TestAdminPacketBuild(unittest.TestCase):
+ def test_build_admin_packet_encrypts(self):
+ crypto = CryptoService(default_key_b64="AQ==")
+ admin_key = crypto._expand_key(b"\x01")
+ builder = MeshtasticPacketBuilder(crypto)
+ admin_msg = admin_pb2.AdminMessage()
+ admin_msg.get_config_request = admin_pb2.AdminMessage.DEVICE_CONFIG
+ packet = builder.build_admin_message(
+ admin_payload=admin_msg.SerializeToString(),
+ dest=0xA3F2B1C0,
+ source_id=0x12345678,
+ packet_id=99,
+ admin_key=admin_key,
+ admin_channel_name="admin",
+ )
+ self.assertIsNotNone(packet)
+ self.assertGreater(len(packet), 16)
+
+
+class TestAdminResponseConsume(unittest.IsolatedAsyncioTestCase):
+ async def test_consumes_matching_admin_response(self):
+ crypto = CryptoService(default_key_b64="AQ==")
+ admin_key = crypto._expand_key(b"\x01")
+ builder = MeshtasticPacketBuilder(crypto)
+
+ response_admin = admin_pb2.AdminMessage()
+ response_admin.get_config_response.device.role = 2
+ inner = builder._serialize_data(
+ PORTNUM_ADMIN, response_admin.SerializeToString()
+ )
+
+ packet_id = 42
+ source_id = 0xA3F2B1C0
+ ciphertext = crypto.encrypt_meshtastic(inner, packet_id, source_id, key=admin_key)
+ header = builder._build_header(
+ 0x12345678,
+ source_id,
+ packet_id,
+ channel_hash=crypto.compute_channel_hash("admin", admin_key),
+ )
+ raw = header + ciphertext
+
+ tx = MagicMock()
+ tx.meshtastic_enabled = True
+ tx.send_admin_message = AsyncMock(
+ return_value=MagicMock(success=True, packet_id="0000002a", error="")
+ )
+
+ reader = AdminConfigReader(
+ tx_service=tx,
+ crypto=crypto,
+ admin_key_b64="AQ==",
+ admin_channel_name="admin",
+ local_node_id=0x12345678,
+ )
+ await reader.request_config("!a3f2b1c0", section="device")
+
+ packet = Packet(
+ packet_id="0000002a",
+ source_id="a3f2b1c0",
+ destination_id="12345678",
+ protocol=Protocol.MESHTASTIC,
+ packet_type=PacketType.ADMIN,
+ raw_radio_packet=raw,
+ )
+ reader.try_consume_packet(packet)
+
+ status = reader.get_status("a3f2b1c0")
+ self.assertEqual(status["status"], "complete")
+ self.assertIn("config", status["config"])
+
+
+class TestAdminReaderUnavailable(unittest.IsolatedAsyncioTestCase):
+ async def test_503_without_admin_key(self):
+ reader = AdminConfigReader(tx_service=MagicMock(), crypto=CryptoService())
+ with self.assertRaises(AdminConfigError) as ctx:
+ await reader.request_config("a3f2b1c0")
+ self.assertEqual(ctx.exception.status_code, 503)
diff --git a/tests/test_admin_routes.py b/tests/test_admin_routes.py
new file mode 100644
index 00000000..b891624a
--- /dev/null
+++ b/tests/test_admin_routes.py
@@ -0,0 +1,90 @@
+"""API route tests for remote ADMIN config read."""
+from __future__ import annotations
+
+import unittest
+from unittest.mock import AsyncMock, MagicMock
+
+from src.admin.reader import AdminConfigError, AdminConfigReader
+
+
+class TestAdminRoutes(unittest.TestCase):
+ def setUp(self):
+ try:
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+ from src.api.auth.dependencies import require_admin
+ from src.api.auth.jwt_session import ROLE_ADMIN, SessionClaims
+ from src.api.routes import admin_routes
+ except ImportError as exc:
+ raise unittest.SkipTest(f"API test deps unavailable: {exc}") from exc
+
+ self.admin_routes = admin_routes
+
+ def _admin_claims() -> SessionClaims:
+ return SessionClaims(subject="admin", role=ROLE_ADMIN, session_version=1)
+
+ self.reader = MagicMock(spec=AdminConfigReader)
+ self.reader.available = True
+ self.reader.get_status.return_value = {
+ "node_id": "a3f2b1c0",
+ "status": "idle",
+ "section": None,
+ "config": None,
+ "error": "",
+ }
+ self.reader.request_config = AsyncMock(
+ return_value={
+ "node_id": "a3f2b1c0",
+ "request_id": "abc",
+ "status": "pending",
+ "section": "device",
+ "packet_id": "00000001",
+ }
+ )
+
+ admin_routes.init_routes(reader=self.reader)
+ self.app = FastAPI()
+ self.app.dependency_overrides[require_admin] = _admin_claims
+ from src.api.audit.dependencies import get_audit_writer
+
+ audit = MagicMock()
+
+ class _Ctx:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ return False
+
+ audit.timed_action.return_value = _Ctx()
+ self.app.dependency_overrides[get_audit_writer] = lambda: audit
+ self.app.include_router(admin_routes.router)
+ self.client = TestClient(self.app)
+
+ def tearDown(self):
+ self.admin_routes.reset_routes()
+
+ def test_status_endpoint(self):
+ self.reader.available = True
+ resp = self.client.get("/api/admin/remote-config/status")
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(resp.json()["available"])
+
+ def test_request_config_success(self):
+ resp = self.client.post(
+ "/api/admin/nodes/a3f2b1c0/config/request",
+ json={"section": "device"},
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.json()["status"], "pending")
+ self.reader.request_config.assert_awaited_once()
+
+ def test_request_config_maps_admin_error(self):
+ self.reader.request_config = AsyncMock(
+ side_effect=AdminConfigError("no key", status_code=503)
+ )
+ resp = self.client.post(
+ "/api/admin/nodes/a3f2b1c0/config/request",
+ json={"section": "device"},
+ )
+ self.assertEqual(resp.status_code, 503)