Merge pull request #229 from jkingsman/tracked-contact-telemetry

Tracked contact telemetry
This commit is contained in:
Jack Kingsman authored and GitHub committed 2026-05-13 17:52:32 -07:00
commit 9addb2b403
34 files changed
+1885 -490

No files matched your search

+5 -1
View File
@@ -350,6 +350,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
@@ -380,6 +382,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
| POST | `/api/settings/tracked-telemetry-contacts/toggle` | Toggle tracked LPP telemetry for any contact |
| GET | `/api/settings/tracked-telemetry-contacts/schedule` | Contact telemetry scheduling derivation (shared ceiling with repeaters) |
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
@@ -508,7 +512,7 @@ mc.subscribe(EventType.ACK, handler)
| `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`.
**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`, `tracked_telemetry_contacts`, `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`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+8 -2
View File
@@ -169,7 +169,8 @@ app/
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message`, `raw_packet`, and `contact` events.
- `on_message` and `on_raw` are scope-gated. `on_contact`, `on_telemetry`, and `on_health` are dispatched to all modules unconditionally (modules filter internally).
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch).
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch). Contact LPP telemetry is similarly recorded to `ContactTelemetryRepository` and dispatched to fanout.
- The telemetry collection loop in `radio_sync.py` is unified: it iterates over both `tracked_telemetry_repeaters` and `tracked_telemetry_contacts`, dispatching to `_collect_repeater_telemetry` (type 2) or `_collect_contact_telemetry` (others). The daily check ceiling uses the combined count.
- The 60-second radio stats sampling loop in `radio_stats.py` dispatches an enriched health snapshot (radio identity + full stats) to all fanout modules after each sample.
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
@@ -227,6 +228,8 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info`
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
- `POST /contacts/{public_key}/room/login`
- `POST /contacts/{public_key}/room/status`
- `POST /contacts/{public_key}/room/lpp-telemetry`
@@ -267,6 +270,8 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /settings/blocked-names/toggle`
- `POST /settings/tracked-telemetry/toggle`
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
- `POST /settings/tracked-telemetry-contacts/toggle` — toggle tracked LPP telemetry for any contact (max 8)
- `GET /settings/tracked-telemetry-contacts/schedule` — contact telemetry scheduling (shared ceiling with repeaters)
- `POST /settings/muted-channels/toggle`
### Fanout
@@ -320,6 +325,7 @@ Main tables:
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
- `contact_name_history` (tracks name changes over time)
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
- `contact_telemetry_history` (time-series LPP telemetry snapshots for tracked contacts; same schema as repeater table)
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
- `push_subscriptions` (Web Push browser subscriptions with delivery metadata; UNIQUE on endpoint)
- `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing)
@@ -343,7 +349,7 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel`
- `telemetry_interval_hours`
+47 -5
View File
@@ -154,6 +154,18 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
return payload
def _contact_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Build the flat HA state payload for a contact LPP telemetry snapshot.
Unlike repeaters, contacts only have LPP sensor data — no battery_volts,
noise_floor_dbm, packets_received, etc.
"""
payload: dict[str, Any] = {}
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
payload[key] = sensor.get("value")
return payload
def _lpp_discovery_configs(
prefix: str,
pub_key: str,
@@ -576,12 +588,30 @@ class MqttHaModule(FanoutModule):
)
)
# Tracked contacts — resolve names from DB best-effort
# Tracked contacts — resolve names and LPP sensors from DB best-effort
for pub_key in self._tracked_contacts:
cname = await self._resolve_contact_name(pub_key)
configs.append(
_contact_tracker_discovery_config(self._prefix, pub_key, cname, self._radio_key)
)
# LPP sensor entities for contacts with telemetry history
latest_ct = await self._resolve_latest_contact_telemetry(pub_key)
latest_ct_data = latest_ct.get("data", {}) if latest_ct else {}
ct_lpp_sensors = latest_ct_data.get("lpp_sensors", [])
if ct_lpp_sensors:
ct_nid = _node_id(pub_key)
ct_device = _device_payload(pub_key, cname, "Node", via_device_key=self._radio_key)
ct_state_topic = f"{self._prefix}/{ct_nid}/telemetry"
configs.extend(
_lpp_discovery_configs(
self._prefix, pub_key, ct_device, ct_lpp_sensors, ct_state_topic
)
)
if latest_ct_data:
ct_payload = _contact_telemetry_payload(latest_ct_data)
cached_repeater_states.append(
(f"{self._prefix}/{_node_id(pub_key)}/telemetry", ct_payload)
)
# Message event entity (namespaced to this radio)
configs.append(_message_event_discovery_config(self._prefix, self._radio_key, radio_name))
@@ -644,6 +674,17 @@ class MqttHaModule(FanoutModule):
pass
return None
@staticmethod
async def _resolve_latest_contact_telemetry(pub_key: str) -> dict | None:
"""Return the most recent contact telemetry row, or None."""
try:
from app.repository.contact_telemetry import ContactTelemetryRepository
return await ContactTelemetryRepository.get_latest(pub_key)
except Exception:
pass
return None
def _seed_radio_identity_from_runtime(self) -> None:
"""Best-effort bootstrap from the currently connected radio session."""
try:
@@ -749,13 +790,14 @@ class MqttHaModule(FanoutModule):
return
pub_key = data.get("public_key", "")
if pub_key not in self._tracked_repeaters:
if pub_key not in self._tracked_repeaters and pub_key not in self._tracked_contacts:
return
nid = _node_id(pub_key)
# Publish the full telemetry dict — HA sensors use value_template
# to extract individual fields
payload = _repeater_telemetry_payload(data)
is_repeater = pub_key in self._tracked_repeaters
payload = (
_repeater_telemetry_payload(data) if is_repeater else _contact_telemetry_payload(data)
)
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
rediscover = False
for _, key, _ in _assign_lpp_keys(lpp_sensors):
@@ -0,0 +1,40 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Create contact_telemetry_history table and tracked_telemetry_contacts setting."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_telemetry_history" not in tables:
await conn.execute(
"""
CREATE TABLE contact_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_contact_telemetry_pk_ts
ON contact_telemetry_history(public_key, timestamp)
"""
)
if "app_settings" in tables:
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "tracked_telemetry_contacts" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_contacts TEXT DEFAULT '[]'"
)
await conn.commit()
+21 -5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
@@ -42,7 +44,7 @@ class ContactUpsert(BaseModel):
first_seen: int | None = None
@classmethod
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
def from_contact(cls, contact: Contact, **changes) -> ContactUpsert:
return cls.model_validate(
{
**contact.model_dump(exclude={"last_read_at"}),
@@ -53,7 +55,7 @@ class ContactUpsert(BaseModel):
@classmethod
def from_radio_dict(
cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert":
) -> ContactUpsert:
"""Convert radio contact data to the contact-row write shape."""
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"),
@@ -543,7 +545,7 @@ class RepeaterStatusResponse(BaseModel):
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
telemetry_history: list["TelemetryHistoryEntry"] = Field(
telemetry_history: list[TelemetryHistoryEntry] = Field(
default_factory=list, description="Recent telemetry history snapshots"
)
@@ -598,6 +600,16 @@ class RepeaterLppTelemetryResponse(BaseModel):
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
class ContactTelemetryResponse(BaseModel):
"""On-demand CayenneLPP telemetry snapshot from any contact."""
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
fetched_at: int = Field(description="Unix timestamp when this telemetry was fetched")
telemetry_history: list[TelemetryHistoryEntry] = Field(
default_factory=list, description="Recent telemetry history entries"
)
class NeighborInfo(BaseModel):
"""Information about a neighbor seen by a repeater."""
@@ -849,18 +861,22 @@ class AppSettings(BaseModel):
default_factory=list,
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
)
tracked_telemetry_contacts: list[str] = Field(
default_factory=list,
description="Public keys of contacts opted into periodic LPP telemetry collection (max 8)",
)
telemetry_interval_hours: int = Field(
default=8,
description=(
"User-preferred telemetry collection interval in hours. The backend "
"clamps this up to the shortest legal interval given the number of "
"tracked repeaters so daily checks stay under a 24/day ceiling."
"tracked repeaters and contacts so daily checks stay under a 24/day ceiling."
),
)
telemetry_routed_hourly: bool = Field(
default=False,
description=(
"When enabled, tracked repeaters with a direct or routed (non-flood) "
"When enabled, tracked repeaters/contacts with a direct or routed (non-flood) "
"path are polled every hour instead of on the normal scheduled interval."
),
)
+143 -23
View File
@@ -31,6 +31,7 @@ from app.repository import (
ContactRepository,
RepeaterTelemetryRepository,
)
from app.repository.contact_telemetry import ContactTelemetryRepository
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
@@ -1890,24 +1891,111 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
return False
async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
"""Collect one telemetry sample from tracked repeaters.
async def _collect_contact_telemetry(mc: MeshCore, contact: Contact) -> bool:
"""Fetch LPP telemetry from a non-repeater contact and record it.
When *routed_only* is True, only repeaters whose effective route is
Unlike repeaters, companions/rooms/sensors only respond to
req_telemetry_sync (LPP), not req_status_sync (repeater status struct).
All sensor values including multi-value (GPS, accel) are stored.
Returns True on success, False on failure (logged, not raised).
"""
try:
await mc.commands.add_contact(contact.to_radio_dict())
lpp_raw = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
except Exception as e:
logger.debug(
"Contact telemetry collect: radio command failed for %s: %s",
contact.public_key[:12],
e,
)
return False
if lpp_raw is None:
logger.debug("Contact telemetry collect: no response from %s", contact.public_key[:12])
return False
lpp_sensors = []
for entry in lpp_raw:
lpp_sensors.append(
{
"channel": entry.get("channel", 0),
"type_name": str(entry.get("type", "unknown")),
"value": entry.get("value", 0),
}
)
data: dict = {}
if lpp_sensors:
data["lpp_sensors"] = lpp_sensors
try:
timestamp = int(time.time())
await ContactTelemetryRepository.record(
public_key=contact.public_key,
timestamp=timestamp,
data=data,
)
logger.info(
"Contact telemetry collect: recorded snapshot for %s (%s)",
contact.name or contact.public_key[:12],
contact.public_key[:12],
)
# Dispatch to fanout modules
from app.fanout.manager import fanout_manager
asyncio.create_task(
fanout_manager.broadcast_telemetry(
{
"public_key": contact.public_key,
"name": contact.name or contact.public_key[:12],
"timestamp": timestamp,
**data,
}
)
)
return True
except Exception as e:
logger.warning(
"Contact telemetry collect: failed to record for %s: %s",
contact.public_key[:12],
e,
)
return False
async def _run_telemetry_cycle(
*,
routed_only: bool = False,
collect_repeaters: bool = True,
collect_contacts: bool = True,
) -> None:
"""Collect one telemetry sample from tracked repeaters and/or contacts.
When *routed_only* is True, only targets whose effective route is
``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected.
This is used by the hourly routed-path fast-poll feature.
*collect_repeaters* and *collect_contacts* allow the scheduler to
selectively skip one list when its interval hasn't elapsed yet.
"""
if not radio_manager.is_connected:
logger.debug("Telemetry collect: radio not connected, skipping cycle")
return
app_settings = await AppSettingsRepository.get()
tracked = app_settings.tracked_telemetry_repeaters
if not tracked:
tracked_repeaters = app_settings.tracked_telemetry_repeaters if collect_repeaters else []
tracked_contacts = app_settings.tracked_telemetry_contacts if collect_contacts else []
if not tracked_repeaters and not tracked_contacts:
return
candidates: list[tuple[str, Contact]] = []
for pub_key in tracked:
# Build repeater candidates
candidates: list[tuple[str, Contact, bool]] = [] # (key, contact, is_repeater)
for pub_key in tracked_repeaters:
contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2:
logger.debug(
@@ -1917,29 +2005,46 @@ async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
continue
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
continue
candidates.append((pub_key, contact))
candidates.append((pub_key, contact, True))
# Build contact (non-repeater) candidates
for pub_key in tracked_contacts:
contact = await ContactRepository.get_by_key(pub_key)
if not contact:
logger.debug(
"Telemetry collect: skipping contact %s (not found)",
pub_key[:12],
)
continue
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
continue
candidates.append((pub_key, contact, False))
if not candidates:
if routed_only:
logger.debug("Telemetry collect: no routed repeaters to poll this hour")
logger.debug("Telemetry collect: no routed targets to poll this hour")
return
label = "routed" if routed_only else "full"
logger.info(
"Telemetry collect: starting %s cycle for %d repeater(s)",
"Telemetry collect: starting %s cycle for %d target(s)",
label,
len(candidates),
)
collected = 0
for _pub_key, contact in candidates:
for _pub_key, contact, is_repeater in candidates:
try:
async with radio_manager.radio_operation(
"telemetry_collect",
blocking=False,
suspend_auto_fetch=True,
) as mc:
if await _collect_repeater_telemetry(mc, contact):
if is_repeater:
success = await _collect_repeater_telemetry(mc, contact)
else:
success = await _collect_contact_telemetry(mc, contact)
if success:
collected += 1
except RadioOperationBusyError:
logger.debug(
@@ -1975,20 +2080,35 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
telemetry).
"""
app_settings = await AppSettingsRepository.get()
tracked_count = len(app_settings.tracked_telemetry_repeaters)
if tracked_count == 0:
return
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count)
if effective_hours <= 0:
n_repeaters = len(app_settings.tracked_telemetry_repeaters)
n_contacts = len(app_settings.tracked_telemetry_contacts)
if n_repeaters == 0 and n_contacts == 0:
return
is_normal_cycle = now.hour % effective_hours == 0
pref = app_settings.telemetry_interval_hours
routed_hourly = app_settings.telemetry_routed_hourly
if is_normal_cycle:
# Normal scheduled boundary: collect ALL tracked repeaters.
await _run_telemetry_cycle()
elif app_settings.telemetry_routed_hourly:
# Hourly routed-path fast-poll: only repeaters with a non-flood route.
# Each list has its own 24/day ceiling. Check eligibility independently
# so 8 repeaters on an 8h interval don't drag 1 contact to 8h too.
repeaters_due = False
contacts_due = False
if n_repeaters > 0:
eff_rep = clamp_telemetry_interval(pref, n_repeaters)
if now.hour % eff_rep == 0:
repeaters_due = True
if n_contacts > 0:
eff_ct = clamp_telemetry_interval(pref, n_contacts)
if now.hour % eff_ct == 0:
contacts_due = True
if repeaters_due or contacts_due:
await _run_telemetry_cycle(
collect_repeaters=repeaters_due,
collect_contacts=contacts_due,
)
elif routed_hourly:
await _run_telemetry_cycle(routed_only=True)
+100
View File
@@ -0,0 +1,100 @@
import json
import logging
import time
from app.database import db
logger = logging.getLogger(__name__)
# Maximum age for telemetry history entries (30 days)
_MAX_AGE_SECONDS = 30 * 86400
# Maximum entries to keep per contact (sanity cap)
_MAX_ENTRIES_PER_CONTACT = 1000
class ContactTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
data: dict,
) -> None:
"""Insert a telemetry history row and prune stale entries."""
cutoff = int(time.time()) - _MAX_AGE_SECONDS
async with db.tx() as conn:
async with conn.execute(
"""
INSERT INTO contact_telemetry_history
(public_key, timestamp, data)
VALUES (?, ?, ?)
""",
(public_key, timestamp, json.dumps(data)),
):
pass
# Prune entries older than 30 days
async with conn.execute(
"DELETE FROM contact_telemetry_history WHERE public_key = ? AND timestamp < ?",
(public_key, cutoff),
):
pass
# Cap at _MAX_ENTRIES_PER_CONTACT (keep newest)
async with conn.execute(
"""
DELETE FROM contact_telemetry_history
WHERE public_key = ? AND id NOT IN (
SELECT id FROM contact_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT ?
)
""",
(public_key, public_key, _MAX_ENTRIES_PER_CONTACT),
):
pass
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a contact since a given timestamp, ordered ASC."""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT timestamp, data
FROM contact_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
) as cursor:
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
for row in rows
]
@staticmethod
async def get_latest(public_key: str) -> dict | None:
"""Return the most recent telemetry row for a contact, or None."""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT timestamp, data
FROM contact_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT 1
""",
(public_key,),
) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return {
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
+19 -1
View File
@@ -41,7 +41,8 @@ class AppSettingsRepository:
last_message_times,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters, auto_resend_channel,
tracked_telemetry_repeaters, tracked_telemetry_contacts,
auto_resend_channel,
telemetry_interval_hours, telemetry_routed_hourly
FROM app_settings WHERE id = 1
"""
@@ -97,6 +98,15 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = []
# Parse tracked_telemetry_contacts JSON
tracked_telemetry_contacts: list[str] = []
try:
raw_tracked_contacts = row["tracked_telemetry_contacts"]
if raw_tracked_contacts:
tracked_telemetry_contacts = json.loads(raw_tracked_contacts)
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_contacts = []
# Parse auto_resend_channel boolean
try:
auto_resend_channel = bool(row["auto_resend_channel"])
@@ -130,6 +140,7 @@ class AppSettingsRepository:
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
tracked_telemetry_contacts=tracked_telemetry_contacts,
auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly,
@@ -149,6 +160,7 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None,
tracked_telemetry_contacts: list[str] | None = None,
auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None,
@@ -201,6 +213,10 @@ class AppSettingsRepository:
updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(tracked_telemetry_repeaters))
if tracked_telemetry_contacts is not None:
updates.append("tracked_telemetry_contacts = ?")
params.append(json.dumps(tracked_telemetry_contacts))
if auto_resend_channel is not None:
updates.append("auto_resend_channel = ?")
params.append(1 if auto_resend_channel else 0)
@@ -239,6 +255,7 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None,
tracked_telemetry_contacts: list[str] | None = None,
auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None,
@@ -257,6 +274,7 @@ class AppSettingsRepository:
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
tracked_telemetry_contacts=tracked_telemetry_contacts,
auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly,
+85
View File
@@ -14,11 +14,14 @@ from app.models import (
ContactAdvertPathSummary,
ContactAnalytics,
ContactRoutingOverrideRequest,
ContactTelemetryResponse,
ContactUpsert,
CreateContactRequest,
LppSensor,
NearestRepeater,
PathDiscoveryResponse,
PathDiscoveryRoute,
TelemetryHistoryEntry,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
@@ -613,3 +616,85 @@ async def set_contact_routing_override(
await _broadcast_contact_update(updated_contact)
return {"status": "ok", "public_key": contact.public_key}
# ---------------------------------------------------------------------------
# On-demand contact telemetry (CayenneLPP)
# ---------------------------------------------------------------------------
@router.post("/{public_key}/telemetry", response_model=ContactTelemetryResponse)
async def request_contact_telemetry(public_key: str) -> ContactTelemetryResponse:
"""Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout).
Persists the result in contact_telemetry_history and returns the latest
sensor readings along with recent telemetry history.
"""
from app.repository.contact_telemetry import ContactTelemetryRepository
radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key)
async with radio_manager.radio_operation(
"contact_telemetry", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
telemetry = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from contact")
sensors: list[LppSensor] = []
for entry in telemetry:
channel = entry.get("channel", 0)
type_name = str(entry.get("type", "unknown"))
value = entry.get("value", 0)
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
fetched_at = int(time.time())
# Persist snapshot
data = {"lpp_sensors": [s.model_dump() for s in sensors]}
await ContactTelemetryRepository.record(
public_key=contact.public_key,
timestamp=fetched_at,
data=data,
)
# Dispatch to fanout modules (e.g. HA MQTT)
from app.fanout.manager import fanout_manager
asyncio.create_task(
fanout_manager.broadcast_telemetry(
{
"public_key": contact.public_key,
"name": contact.name or contact.public_key[:12],
"timestamp": fetched_at,
**data,
}
)
)
# Fetch recent history (30 days)
since = fetched_at - 30 * 86400
rows = await ContactTelemetryRepository.get_history(contact.public_key, since)
history = [TelemetryHistoryEntry(**row) for row in rows]
return ContactTelemetryResponse(
sensors=sensors,
fetched_at=fetched_at,
telemetry_history=history,
)
@router.get("/{public_key}/telemetry-history", response_model=list[TelemetryHistoryEntry])
async def get_contact_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Get stored telemetry history for a contact (read-only, no radio access)."""
from app.repository.contact_telemetry import ContactTelemetryRepository
contact = await _resolve_contact_or_404(public_key)
since = int(time.time()) - 30 * 86400
rows = await ContactTelemetryRepository.get_history(contact.public_key, since)
return [TelemetryHistoryEntry(**row) for row in rows]
+24
View File
@@ -101,6 +101,18 @@ class RadioConfigResponse(BaseModel):
default=False,
description="Whether the radio sends an extra direct ACK transmission",
)
telemetry_mode_base: int = Field(
default=0,
description="Base telemetry sharing mode (0=deny, 1=per-contact, 2=allow-all)",
)
telemetry_mode_loc: int = Field(
default=0,
description="Location telemetry sharing mode (0=deny, 1=per-contact, 2=allow-all)",
)
telemetry_mode_env: int = Field(
default=0,
description="Environment sensor sharing mode (0=deny, 1=per-contact, 2=allow-all)",
)
class RadioConfigUpdate(BaseModel):
@@ -123,6 +135,15 @@ class RadioConfigUpdate(BaseModel):
default=None,
description="Whether the radio sends an extra direct ACK transmission",
)
telemetry_mode_base: int | None = Field(
default=None, ge=0, le=2, description="Base telemetry sharing mode"
)
telemetry_mode_loc: int | None = Field(
default=None, ge=0, le=2, description="Location telemetry sharing mode"
)
telemetry_mode_env: int | None = Field(
default=None, ge=0, le=2, description="Environment sensor sharing mode"
)
class PrivateKeyUpdate(BaseModel):
@@ -360,6 +381,9 @@ async def get_radio_config() -> RadioConfigResponse:
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source,
multi_acks_enabled=bool(info.get("multi_acks", 0)),
telemetry_mode_base=info.get("telemetry_mode_base", 0),
telemetry_mode_loc=info.get("telemetry_mode_loc", 0),
telemetry_mode_env=info.get("telemetry_mode_env", 0),
)
+98
View File
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
MAX_TRACKED_TELEMETRY_REPEATERS = 8
MAX_TRACKED_TELEMETRY_CONTACTS = 8
class AppSettingsUpdate(BaseModel):
@@ -404,6 +405,7 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
The UI uses this to render the interval dropdown (legal options),
surface saved-vs-effective when they differ, and show the next-run-at
timestamp so users know when the next cycle will fire.
"""
app_settings = await AppSettingsRepository.get()
return _build_schedule(
@@ -411,3 +413,99 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
app_settings.telemetry_interval_hours,
app_settings.telemetry_routed_hourly,
)
# ---------------------------------------------------------------------------
# Tracked contact telemetry (non-repeater LPP telemetry collection)
# ---------------------------------------------------------------------------
class TrackedTelemetryContactsResponse(BaseModel):
tracked_telemetry_contacts: list[str] = Field(
description="Current list of tracked contact public keys"
)
names: dict[str, str] = Field(
description="Map of public key to display name for tracked contacts"
)
schedule: TelemetrySchedule = Field(description="Current scheduling state")
@router.post("/tracked-telemetry-contacts/toggle", response_model=TrackedTelemetryContactsResponse)
async def toggle_tracked_telemetry_contact(
request: TrackedTelemetryRequest,
) -> TrackedTelemetryContactsResponse:
"""Toggle periodic LPP telemetry collection for any contact.
Max 8 contacts may be tracked. The daily check ceiling is shared with
tracked repeaters.
"""
key = request.public_key.lower()
settings = await AppSettingsRepository.get()
current = settings.tracked_telemetry_contacts
async def _resolve_names(keys: list[str]) -> dict[str, str]:
names: dict[str, str] = {}
for k in keys:
contact = await ContactRepository.get_by_key(k)
names[k] = contact.name if contact and contact.name else k[:12]
return names
if key in current:
# Remove
new_list = [k for k in current if k != key]
logger.info("Removing contact %s from tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
return TrackedTelemetryContactsResponse(
tracked_telemetry_contacts=new_list,
names=await _resolve_names(new_list),
schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
)
# Validate contact exists and is not a repeater (repeaters use tracked_telemetry_repeaters)
contact = await ContactRepository.get_by_key(key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
if contact.type == CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail="Repeaters use the dedicated repeater telemetry tracking list",
)
if len(current) >= MAX_TRACKED_TELEMETRY_CONTACTS:
names = await _resolve_names(current)
raise HTTPException(
status_code=409,
detail={
"message": f"Limit of {MAX_TRACKED_TELEMETRY_CONTACTS} tracked contacts reached",
"tracked_telemetry_contacts": current,
"names": names,
},
)
new_list = current + [key]
logger.info("Adding contact %s to tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
return TrackedTelemetryContactsResponse(
tracked_telemetry_contacts=new_list,
names=await _resolve_names(new_list),
schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
)
@router.get("/tracked-telemetry-contacts/schedule", response_model=TelemetrySchedule)
async def get_contact_telemetry_schedule() -> TelemetrySchedule:
"""Return the current telemetry scheduling derivation for contacts."""
app_settings = await AppSettingsRepository.get()
return _build_schedule(
len(app_settings.tracked_telemetry_contacts),
app_settings.telemetry_interval_hours,
app_settings.telemetry_routed_hourly,
)
+24
View File
@@ -51,6 +51,30 @@ async def apply_radio_config_update(
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
if update.telemetry_mode_base is not None:
logger.info("Setting telemetry_mode_base to %d", update.telemetry_mode_base)
result = await mc.commands.set_telemetry_mode_base(update.telemetry_mode_base)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set telemetry mode (base): {result.payload}"
)
if update.telemetry_mode_loc is not None:
logger.info("Setting telemetry_mode_loc to %d", update.telemetry_mode_loc)
result = await mc.commands.set_telemetry_mode_loc(update.telemetry_mode_loc)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set telemetry mode (location): {result.payload}"
)
if update.telemetry_mode_env is not None:
logger.info("Setting telemetry_mode_env to %d", update.telemetry_mode_env)
result = await mc.commands.set_telemetry_mode_env(update.telemetry_mode_env)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set telemetry mode (environment): {result.payload}"
)
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
+5 -3
View File
@@ -141,7 +141,8 @@ frontend/src/
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists
│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ ├── ThemeSelector.tsx # Color theme picker
@@ -323,7 +324,7 @@ Supported routes:
- `#contact/{publicKey}`
- `#contact/{publicKey}/{label}`
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
Where `{section}` is one of `radio`, `local`, `radio-app`, `database`, `fanout`, `statistics`, or `about`.
Legacy name-based channel/contact hashes are still accepted for compatibility.
@@ -361,7 +362,7 @@ Distance/validation helpers used by path + map UI.
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel`
- `telemetry_interval_hours`
@@ -382,6 +383,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops
- GPS location (clickable → map)
- On-demand LPP telemetry: "Request" button fetches `POST /contacts/{key}/telemetry`, displays sensor readings via `LppSensorRow`, optional GPS mini-map (Leaflet), and history chart (Recharts). Opt-in tracking toggle uses `POST /settings/tracked-telemetry-contacts/toggle`.
- Favorite toggle
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
+5
View File
@@ -166,6 +166,7 @@ export function App() {
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
} = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback
@@ -715,6 +716,8 @@ export function App() {
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
};
const crackerProps = {
packets: rawPackets,
@@ -748,6 +751,8 @@ export function App() {
onToggleBlockedName: handleBlockName,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
};
const channelInfoPaneProps = {
channelKey: infoPaneChannelKey,
+19
View File
@@ -8,6 +8,7 @@ import type {
Contact,
ContactAnalytics,
ContactAdvertPathSummary,
ContactTelemetryResponse,
FanoutConfig,
HealthStatus,
MaintenanceResult,
@@ -35,6 +36,7 @@ import type {
RepeaterStatusResponse,
TelemetryHistoryEntry,
TelemetrySchedule,
TrackedTelemetryContactsResponse,
TrackedTelemetryResponse,
StatisticsResponse,
TraceResponse,
@@ -337,6 +339,16 @@ export const api = {
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
// Tracked contact telemetry
toggleTrackedTelemetryContact: (publicKey: string) =>
fetchJson<TrackedTelemetryContactsResponse>('/settings/tracked-telemetry-contacts/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
getContactTelemetrySchedule: () =>
fetchJson<TelemetrySchedule>('/settings/tracked-telemetry-contacts/schedule'),
// Favorites
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
@@ -432,6 +444,13 @@ export const api = {
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
// Contact telemetry (universal, any contact type)
requestContactTelemetry: (publicKey: string) =>
fetchJson<ContactTelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
}),
contactTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
+368 -2
View File
@@ -1,6 +1,8 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Activity, Ban, ChevronDown, ChevronRight, Search, Star } from 'lucide-react';
import {
AreaChart,
Area,
LineChart,
Line,
XAxis,
@@ -10,6 +12,8 @@ import {
ResponsiveContainer,
Legend,
} from 'recharts';
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { api, isAbortError } from '../api';
import { formatTime } from '../utils/messageParser';
import {
@@ -31,6 +35,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
@@ -41,7 +46,10 @@ import type {
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
LppSensor,
RadioConfig,
TelemetryHistoryEntry,
TelemetryLppSensor,
} from '../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
@@ -73,6 +81,8 @@ interface ContactInfoPaneProps {
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
}
export function ContactInfoPane({
@@ -89,6 +99,8 @@ export function ContactInfoPane({
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
trackedTelemetryContacts = [],
onToggleTrackedTelemetryContact,
}: ContactInfoPaneProps) {
const { distanceUnit } = useDistanceUnit();
const isNameOnly = contactKey?.startsWith('name:') ?? false;
@@ -96,6 +108,8 @@ export function ContactInfoPane({
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
// Get live contact data from contacts array (real-time via WS)
const liveContact =
@@ -133,6 +147,41 @@ export function ContactInfoPane({
};
}, [contactKey, isNameOnly, nameOnlyValue]);
// Load telemetry history when pane opens for a contact
useEffect(() => {
if (!contactKey || isNameOnly) {
setTelemetryHistory([]);
return;
}
let cancelled = false;
api
.contactTelemetryHistory(contactKey)
.then((data) => {
if (!cancelled) setTelemetryHistory(data);
})
.catch(() => {
if (!cancelled) setTelemetryHistory([]);
});
return () => {
cancelled = true;
};
}, [contactKey, isNameOnly]);
const handleFetchTelemetry = useCallback(async () => {
if (!contactKey || isNameOnly) return;
setTelemetryLoading(true);
try {
const result = await api.requestContactTelemetry(contactKey);
setTelemetryHistory(result.telemetry_history);
} catch (err) {
if (!isAbortError(err)) {
toast.error(err instanceof Error ? err.message : 'Failed to fetch telemetry');
}
} finally {
setTelemetryLoading(false);
}
}, [contactKey, isNameOnly]);
// Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null;
@@ -371,6 +420,16 @@ export function ContactInfoPane({
</div>
)}
{/* Contact Telemetry */}
<ContactTelemetrySection
contact={contact}
loading={telemetryLoading}
onFetch={handleFetchTelemetry}
telemetryHistory={telemetryHistory}
isTracked={trackedTelemetryContacts.includes(contact.public_key)}
onToggleTracked={onToggleTrackedTelemetryContact}
/>
{/* Favorite toggle */}
<div className="px-5 py-3 border-b border-border">
<button
@@ -909,3 +968,310 @@ function InfoItem({ label, value }: { label: string; value: ReactNode }) {
</div>
);
}
// Stable color rotation for dynamic LPP sensors in the history chart
const LPP_CHART_COLORS = ['#22c55e', '#8b5cf6', '#0ea5e9', '#ef4444', '#f59e0b', '#ec4899'];
function ContactTelemetrySection({
contact,
loading,
onFetch,
telemetryHistory,
isTracked,
onToggleTracked,
}: {
contact: Contact;
loading: boolean;
onFetch: () => void;
telemetryHistory: TelemetryHistoryEntry[];
isTracked: boolean;
onToggleTracked?: (publicKey: string) => Promise<void>;
}) {
const { distanceUnit } = useDistanceUnit();
const [expanded, setExpanded] = useState(true);
const [mapExpanded, setMapExpanded] = useState(false);
const [chartExpanded, setChartExpanded] = useState(false);
const [toggling, setToggling] = useState(false);
// Latest telemetry snapshot from history
const latestEntry =
telemetryHistory.length > 0 ? telemetryHistory[telemetryHistory.length - 1] : null;
const sensors: LppSensor[] = useMemo(() => {
if (!latestEntry?.data?.lpp_sensors) return [];
return latestEntry.data.lpp_sensors.map((s: TelemetryLppSensor) => ({
channel: s.channel,
type_name: s.type_name,
value: s.value,
}));
}, [latestEntry]);
const fetchedAt = latestEntry?.timestamp ?? null;
// Extract GPS from sensors
const gpsSensor = sensors.find(
(s) => s.type_name === 'gps' && typeof s.value === 'object' && s.value !== null
);
const gpsValue = gpsSensor?.value as Record<string, number> | undefined;
const hasGps =
gpsValue != null &&
typeof gpsValue.latitude === 'number' &&
typeof gpsValue.longitude === 'number';
// Non-GPS sensors for display
const displaySensors = sensors.filter((s) => s.type_name !== 'gps');
// Build disambiguated labels
const labels = useMemo(() => {
const counts = new Map<string, number>();
return displaySensors.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) + (n > 1 ? ` (${n})` : '');
});
}, [displaySensors]);
// Discover unique LPP sensor series from history for charting
const sensorSeries = useMemo(() => {
const seen = new Map<string, { type_name: string; channel: number }>();
for (const entry of telemetryHistory) {
for (const s of entry.data?.lpp_sensors ?? []) {
if (typeof s.value !== 'number') continue;
const key = `${s.type_name}_ch${s.channel}`;
if (!seen.has(key)) seen.set(key, { type_name: s.type_name, channel: s.channel });
}
}
return Array.from(seen.entries()).map(([key, info], i) => ({
key,
label: formatLppLabel(info.type_name),
color: LPP_CHART_COLORS[i % LPP_CHART_COLORS.length],
...info,
}));
}, [telemetryHistory]);
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
const activeMetric = selectedMetric ?? (sensorSeries.length > 0 ? sensorSeries[0].key : null);
// Build chart data for selected metric
const chartData = useMemo(() => {
if (!activeMetric) return [];
const series = sensorSeries.find((s) => s.key === activeMetric);
if (!series) return [];
return telemetryHistory
.filter((e) => e.data?.lpp_sensors)
.map((e) => {
const sensor = (e.data.lpp_sensors ?? []).find(
(s: TelemetryLppSensor) =>
s.type_name === series.type_name && s.channel === series.channel
);
return {
time: e.timestamp,
value: sensor && typeof sensor.value === 'number' ? sensor.value : null,
};
})
.filter((d) => d.value !== null);
}, [telemetryHistory, activeMetric, sensorSeries]);
const activeSeries = sensorSeries.find((s) => s.key === activeMetric);
return (
<div className="px-5 py-3 border-b border-border">
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Telemetry
</button>
<button
type="button"
onClick={onFetch}
disabled={loading}
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-accent disabled:opacity-50 transition-colors flex items-center gap-1"
>
<Activity className="h-3 w-3" />
{loading ? 'Fetching...' : 'Request'}
</button>
</div>
{expanded && (
<div className="mt-2">
{sensors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
{fetchedAt ? 'No sensor data in last response' : 'Not yet fetched'}
</p>
) : (
<>
<div className="space-y-0.5">
{displaySensors.map((sensor, i) => (
<LppSensorRow
key={`${sensor.type_name}-${sensor.channel}-${i}`}
sensor={sensor}
unitPref={distanceUnit}
label={labels[i]}
/>
))}
</div>
{hasGps && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setMapExpanded(!mapExpanded)}
>
{mapExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
GPS: {gpsValue!.latitude.toFixed(5)}, {gpsValue!.longitude.toFixed(5)}
</button>
{mapExpanded && (
<div className="mt-1 h-48 rounded border border-border overflow-hidden">
<MapContainer
center={[gpsValue!.latitude, gpsValue!.longitude]}
zoom={13}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<CircleMarker
center={[gpsValue!.latitude, gpsValue!.longitude]}
radius={7}
pathOptions={{
color: '#1d4ed8',
fillColor: '#3b82f6',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<span className="text-sm">
{contact.name ?? contact.public_key.slice(0, 12)}
</span>
</Popup>
</CircleMarker>
</MapContainer>
</div>
)}
</div>
)}
{fetchedAt && (
<p className="text-[0.6875rem] text-muted-foreground mt-1.5">
Fetched {formatTime(fetchedAt)}
</p>
)}
</>
)}
{/* History chart */}
{telemetryHistory.length > 1 && sensorSeries.length > 0 && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setChartExpanded(!chartExpanded)}
>
{chartExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
History ({telemetryHistory.length} samples)
</button>
{chartExpanded && (
<div className="mt-1">
<div className="flex flex-wrap gap-1 mb-2">
{sensorSeries.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSelectedMetric(s.key)}
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded transition-colors ${
activeMetric === s.key
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{s.label}
</button>
))}
</div>
{chartData.length > 1 && activeSeries && (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="time"
tickFormatter={(t: number) => {
const d = new Date(t * 1000);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`;
}}
fontSize={9}
tick={{ fill: 'var(--muted-foreground)' }}
/>
<YAxis fontSize={9} tick={{ fill: 'var(--muted-foreground)' }} width={40} />
<RechartsTooltip
labelFormatter={(t) => new Date(Number(t) * 1000).toLocaleString()}
contentStyle={{
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
fontSize: '0.75rem',
}}
/>
<Area
type="monotone"
dataKey="value"
name={activeSeries.label}
stroke={activeSeries.color}
fill={activeSeries.color}
fillOpacity={0.15}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
)}
</div>
)}
{/* Tracking toggle */}
{onToggleTracked && (
<div className="mt-2 pt-2 border-t border-border/50">
<button
type="button"
disabled={toggling}
onClick={async () => {
setToggling(true);
try {
await onToggleTracked(contact.public_key);
} finally {
setToggling(false);
}
}}
className={`text-xs px-2 py-1 rounded border transition-colors w-full ${
isTracked
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
: 'border-green-600/50 text-green-600 hover:bg-green-600/10'
} disabled:opacity-50`}
>
{toggling
? 'Updating...'
: isTracked
? 'Stop Tracking Telemetry'
: 'Track Telemetry on Interval'}
</button>
</div>
)}
</div>
)}
</div>
);
}
+36 -8
View File
@@ -20,6 +20,7 @@ import {
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsRadioAppSection } from './settings/SettingsRadioAppSection';
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
@@ -54,6 +55,8 @@ interface SettingsModalBaseProps {
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -92,6 +95,8 @@ export function SettingsModal(props: SettingsModalProps) {
onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
trackedTelemetryContacts,
onToggleTrackedTelemetryContact,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -106,6 +111,7 @@ export function SettingsModal(props: SettingsModalProps) {
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
radio: false,
local: false,
'radio-app': false,
fanout: false,
database: false,
statistics: false,
@@ -239,6 +245,36 @@ export function SettingsModal(props: SettingsModalProps) {
</section>
)}
{shouldRenderSection('radio-app') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('radio-app')}
{isSectionVisible('radio-app') &&
(appSettings ? (
<SettingsRadioAppSection
appSettings={appSettings}
onSaveAppSettings={onSaveAppSettings}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
trackedTelemetryContacts={trackedTelemetryContacts}
onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact}
className={sectionContentClass}
/>
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Loading app settings...
</div>
</div>
))}
</section>
)}
{shouldRenderSection('database') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('database')}
@@ -249,14 +285,6 @@ export function SettingsModal(props: SettingsModalProps) {
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
className={sectionContentClass}
/>
) : (
@@ -247,10 +247,10 @@ export function TelemetryHistoryPane({
), or when the repeater is opted into interval telemetry polling, in which case the
repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
<a
href="#settings/database"
href="#settings/radio-app"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Settings &rarr; Database &amp; Messaging
Settings &rarr; Radio-App Management
</a>
, where you can also see which repeaters are currently opted in. A maximum of{' '}
{MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
@@ -6,117 +6,32 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
TelemetryHistoryEntry,
TelemetrySchedule,
} from '../../types';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
appSettings,
health,
onSaveAppSettings,
onHealthRefresh,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onHealthRefresh: () => Promise<void>;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
className?: string;
}) {
const { distanceUnit } = useDistanceUnit();
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
// Serialization chain for every auto-persisted control on this page.
// Without this, rapid successive toggles (or mixed dropdown + checkbox
// interactions) can dispatch overlapping PATCHes that land out of order
// on HTTP/2 — a stale write then wins, reverting the user's last click.
// Each call awaits the previous one before sending its request, so the
// server sees updates in the order the user made them.
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
setIntervalDraft(appSettings.telemetry_interval_hours);
}, [appSettings]);
// Re-fetch the scheduler derivation whenever the tracked list changes or
// the stored preference changes. Cheap: single GET, no radio lock.
useEffect(() => {
let cancelled = false;
api
.getTelemetrySchedule()
.then((s) => {
if (!cancelled) setSchedule(s);
})
.catch(() => {
// Non-critical: dropdown falls back to the unfiltered menu.
});
return () => {
cancelled = true;
};
}, [
trackedTelemetryRepeaters.length,
appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly,
]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryRepeaters.map((key) =>
api.repeaterTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters]);
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
@@ -163,12 +78,6 @@ export function SettingsDatabaseSection({
}
};
/**
* Apply an AppSettings PATCH after any already-queued saves finish, and
* revert local state if the save fails. Every auto-persist control on
* this page routes through here so the user-visible order of clicks is
* the order the backend sees, regardless of network reordering.
*/
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
const chained = saveChainRef.current.then(async () => {
try {
@@ -295,330 +204,6 @@ export function SettingsDatabaseSection({
contact sends an advertisement. This may cause brief delays on large packet backlogs.
</p>
</div>
<Separator />
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
</p>
{/* Interval picker. Legal options depend on current tracked count;
we list only those. If the saved preference is no longer legal,
the effective interval is shown below so the user knows what the
scheduler is actually using. */}
<div className="space-y-1.5">
<Label htmlFor="telemetry-interval" className="text-sm">
Collection interval
</Label>
<div className="flex items-center gap-2">
<select
id="telemetry-interval"
value={intervalDraft}
onChange={(e) => {
const nextValue = Number(e.target.value);
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
const prevValue = intervalDraft;
setIntervalDraft(nextValue);
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
setIntervalDraft(prevValue)
);
}}
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
<option key={hrs} value={hrs}>
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
</option>
))}
</select>
</div>
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
<p className="text-xs text-warning">
Saved preference is {schedule.preferred_hours} hour
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
{schedule.tracked_count === 1 ? '' : 's'}{' '}
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
restored if you drop back to a supported count.
</p>
)}
</div>
{/* Routed hourly toggle */}
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={appSettings.telemetry_routed_hourly}
onChange={() => {
const next = !appSettings.telemetry_routed_hourly;
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
}}
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
/>
<div>
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
every hour instead of on the scheduled interval above. Flood-only repeaters still
follow the normal schedule.
</p>
</div>
</label>
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
{formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
{schedule?.next_routed_run_at != null && (
<p className="text-xs text-muted-foreground">
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
</p>
)}
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
// A forced-flood override (path_len < 0) still reports source
// "override", but the actual route is flood. Check the real path.
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
<span>{d.battery_volts?.toFixed(2)}V</span>
<span>noise {d.noise_floor_dbm} dBm</span>
<span>
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
</span>
<span>
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
</span>
{d.lpp_sensors?.map((s) => {
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
{/* Block discovery of new node types */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked
? prev.filter((t) => t !== typeCode)
: [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
{/* Blocked contacts list */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Bulk delete */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,555 @@
import { useState, useEffect, useRef } from 'react';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
TelemetryHistoryEntry,
TelemetrySchedule,
} from '../../types';
export function SettingsRadioAppSection({
appSettings,
onSaveAppSettings,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
trackedTelemetryContacts = [],
onToggleTrackedTelemetryContact,
className,
}: {
appSettings: AppSettings;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
className?: string;
}) {
const { distanceUnit } = useDistanceUnit();
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
const [latestContactTelemetry, setLatestContactTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const contactTelemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
setIntervalDraft(appSettings.telemetry_interval_hours);
}, [appSettings]);
useEffect(() => {
let cancelled = false;
api
.getTelemetrySchedule()
.then((s) => {
if (!cancelled) setSchedule(s);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [
trackedTelemetryRepeaters.length,
trackedTelemetryContacts.length,
appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly,
]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryRepeaters.map((key) =>
api.repeaterTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters]);
useEffect(() => {
if (trackedTelemetryContacts.length === 0 || contactTelemetryFetchedRef.current) return;
contactTelemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryContacts.map((key) =>
api.contactTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestContactTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryContacts]);
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
const chained = saveChainRef.current.then(async () => {
try {
await onSaveAppSettings(update);
} catch (err) {
console.error('Failed to save radio-app settings:', err);
revert();
toast.error('Failed to save setting', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
});
saveChainRef.current = chained;
return chained;
};
return (
<div className={className}>
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
</p>
<div className="space-y-1.5">
<Label htmlFor="telemetry-interval" className="text-sm">
Collection interval
</Label>
<div className="flex items-center gap-2">
<select
id="telemetry-interval"
value={intervalDraft}
onChange={(e) => {
const nextValue = Number(e.target.value);
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
const prevValue = intervalDraft;
setIntervalDraft(nextValue);
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
setIntervalDraft(prevValue)
);
}}
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
<option key={hrs} value={hrs}>
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
</option>
))}
</select>
</div>
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
<p className="text-xs text-warning">
Saved preference is {schedule.preferred_hours} hour
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
{schedule.tracked_count === 1 ? '' : 's'}{' '}
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
restored if you drop back to a supported count.
</p>
)}
</div>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={appSettings.telemetry_routed_hourly}
onChange={() => {
const next = !appSettings.telemetry_routed_hourly;
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
}}
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
/>
<div>
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
every hour instead of on the scheduled interval above. Flood-only repeaters still
follow the normal schedule.
</p>
</div>
</label>
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
{formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
{schedule?.next_routed_run_at != null && (
<p className="text-xs text-muted-foreground">
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
</p>
)}
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
<span>{d.battery_volts?.toFixed(2)}V</span>
<span>noise {d.noise_floor_dbm} dBm</span>
<span>
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
</span>
<span>
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
</span>
{d.lpp_sensors?.map((s) => {
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Tracked Contact Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Contact Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP
telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily
check ceiling is shared with tracked repeaters — adding contacts may clamp the interval
upward.
</p>
{trackedTelemetryContacts.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No contacts are being tracked. Enable tracking from a contact&apos;s info pane.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryContacts.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestContactTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetryContact && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetryContact(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
{d.lpp_sensors?.map((s) => {
if (typeof s.value !== 'number') return null;
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked
? prev.filter((t) => t !== typeCode)
: [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
</div>
);
}
@@ -183,6 +183,9 @@ export function SettingsRadioSection({
const [pathHashMode, setPathHashMode] = useState('0');
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
const [telemetryModeBase, setTelemetryModeBase] = useState(0);
const [telemetryModeLoc, setTelemetryModeLoc] = useState(0);
const [telemetryModeEnv, setTelemetryModeEnv] = useState(0);
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
@@ -218,6 +221,9 @@ export function SettingsRadioSection({
setPathHashMode(String(config.path_hash_mode));
setAdvertLocationSource(config.advert_location_source ?? 'current');
setMultiAcksEnabled(config.multi_acks_enabled ?? false);
setTelemetryModeBase(config.telemetry_mode_base ?? 0);
setTelemetryModeLoc(config.telemetry_mode_loc ?? 0);
setTelemetryModeEnv(config.telemetry_mode_env ?? 0);
}, [config]);
useEffect(() => {
@@ -313,6 +319,15 @@ export function SettingsRadioSection({
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
? { multi_acks_enabled: multiAcksEnabled }
: {}),
...(telemetryModeBase !== (config.telemetry_mode_base ?? 0)
? { telemetry_mode_base: telemetryModeBase }
: {}),
...(telemetryModeLoc !== (config.telemetry_mode_loc ?? 0)
? { telemetry_mode_loc: telemetryModeLoc }
: {}),
...(telemetryModeEnv !== (config.telemetry_mode_env ?? 0)
? { telemetry_mode_env: telemetryModeEnv }
: {}),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -468,6 +483,9 @@ export function SettingsRadioSection({
path_hash_mode: config.path_hash_mode,
advert_location_source: config.advert_location_source ?? 'current',
multi_acks_enabled: config.multi_acks_enabled ?? false,
telemetry_mode_base: config.telemetry_mode_base ?? 0,
telemetry_mode_loc: config.telemetry_mode_loc ?? 0,
telemetry_mode_env: config.telemetry_mode_env ?? 0,
});
const downloadJson = (profile: object, suffix: string) => {
@@ -539,6 +557,10 @@ export function SettingsRadioSection({
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);
if (typeof data.telemetry_mode_base === 'number')
setTelemetryModeBase(data.telemetry_mode_base);
if (typeof data.telemetry_mode_loc === 'number') setTelemetryModeLoc(data.telemetry_mode_loc);
if (typeof data.telemetry_mode_env === 'number') setTelemetryModeEnv(data.telemetry_mode_env);
};
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
@@ -554,6 +576,12 @@ export function SettingsRadioSection({
update.advert_location_source = data.advert_location_source;
if (typeof data.multi_acks_enabled === 'boolean')
update.multi_acks_enabled = data.multi_acks_enabled;
if (typeof data.telemetry_mode_base === 'number')
update.telemetry_mode_base = data.telemetry_mode_base as number;
if (typeof data.telemetry_mode_loc === 'number')
update.telemetry_mode_loc = data.telemetry_mode_loc as number;
if (typeof data.telemetry_mode_env === 'number')
update.telemetry_mode_env = data.telemetry_mode_env as number;
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
update.path_hash_mode = data.path_hash_mode as number;
return update;
@@ -954,6 +982,66 @@ export function SettingsRadioSection({
</div>
</div>
<Separator />
{/* ── Telemetry Sharing ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Telemetry Sharing</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Controls what this radio shares when other nodes request its telemetry. &ldquo;Deny&rdquo;
blocks all requests, &ldquo;Per-Contact&rdquo; uses per-contact permission flags on the
radio, and &ldquo;Allow All&rdquo; shares with any requester.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="telemetry-mode-base" className="text-sm">
Battery &amp; Base
</Label>
<select
id="telemetry-mode-base"
value={telemetryModeBase}
onChange={(e) => setTelemetryModeBase(Number(e.target.value))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value={0}>Deny</option>
<option value={1}>Per-Contact</option>
<option value={2}>Allow All</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telemetry-mode-loc" className="text-sm">
Location
</Label>
<select
id="telemetry-mode-loc"
value={telemetryModeLoc}
onChange={(e) => setTelemetryModeLoc(Number(e.target.value))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value={0}>Deny</option>
<option value={1}>Per-Contact</option>
<option value={2}>Allow All</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telemetry-mode-env" className="text-sm">
Environment Sensors
</Label>
<select
id="telemetry-mode-env"
value={telemetryModeEnv}
onChange={(e) => setTelemetryModeEnv(Number(e.target.value))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value={0}>Deny</option>
<option value={1}>Per-Contact</option>
<option value={2}>Allow All</option>
</select>
</div>
</div>
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
@@ -5,16 +5,25 @@ import {
MonitorCog,
RadioTower,
Share2,
SlidersHorizontal,
type LucideIcon,
} from 'lucide-react';
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
export type SettingsSection =
| 'radio'
| 'local'
| 'radio-app'
| 'database'
| 'fanout'
| 'statistics'
| 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'local',
'database',
'fanout',
'radio-app',
'database',
'statistics',
'about',
];
@@ -22,7 +31,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: 'Radio',
local: 'Local Configuration',
database: 'Database & Messaging',
'radio-app': 'Radio-App Management',
database: 'Database',
fanout: 'MQTT & Automation',
statistics: 'Statistics',
about: 'About',
@@ -31,6 +41,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = {
radio: RadioTower,
local: MonitorCog,
'radio-app': SlidersHorizontal,
database: Database,
fanout: Share2,
statistics: BarChart3,
+34
View File
@@ -113,6 +113,39 @@ export function useAppSettings() {
}
}, []);
const handleToggleTrackedTelemetryContact = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_contacts ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_contacts: optimistic };
});
try {
const result = await api.toggleTrackedTelemetryContact(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_contacts: result.tracked_telemetry_contacts } : prev
);
} catch (err) {
console.error('Failed to toggle tracked contact telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked contact telemetry');
}
}
}, []);
// Legacy favorites migration: if pre-server-side favorites exist in
// localStorage, toggle each one via the existing API and clear the key.
useEffect(() => {
@@ -153,5 +186,6 @@ export function useAppSettings() {
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
};
}
+3 -2
View File
@@ -149,11 +149,12 @@ vi.mock('../components/SettingsModal', () => ({
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
'radio-app': '🗄️ Radio-App Management',
database: '🗄️ Database',
bot: '🤖 Bot',
},
}));
+3 -2
View File
@@ -92,11 +92,12 @@ vi.mock('../components/SettingsModal', () => ({
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: 'Radio',
local: 'Local Configuration',
database: 'Database & Messaging',
'radio-app': 'Radio-App Management',
database: 'Database',
bot: 'Bot',
},
}));
+13 -1
View File
@@ -4,14 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactAnalytics } from '../types';
const { getContactAnalytics } = vi.hoisted(() => ({
const { getContactAnalytics, contactTelemetryHistory } = vi.hoisted(() => ({
getContactAnalytics: vi.fn(),
contactTelemetryHistory: vi.fn(),
}));
vi.mock('../api', () => ({
api: {
getContactAnalytics,
contactTelemetryHistory,
},
isAbortError: () => false,
}));
vi.mock('../components/ui/sheet', () => ({
@@ -26,6 +29,13 @@ vi.mock('../components/ContactAvatar', () => ({
ContactAvatar: () => <div data-testid="contact-avatar" />,
}));
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
vi.mock('../components/ui/sonner', () => ({
toast: {
error: vi.fn(),
@@ -99,6 +109,8 @@ const baseProps = {
describe('ContactInfoPane', () => {
beforeEach(() => {
getContactAnalytics.mockReset();
contactTelemetryHistory.mockReset();
contactTelemetryHistory.mockResolvedValue([]);
baseProps.onSearchMessagesByKey = vi.fn();
baseProps.onSearchMessagesByName = vi.fn();
});
+2
View File
@@ -109,6 +109,7 @@ beforeEach(() => {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
@@ -1049,6 +1050,7 @@ describe('SettingsFanoutSection', () => {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: ['cc'.repeat(32)],
tracked_telemetry_contacts: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
+6 -5
View File
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
@@ -177,7 +178,7 @@ function setMatchMedia(matches: boolean) {
}
function openRadioSection() {
const radioToggle = screen.getByRole('button', { name: /Radio/i });
const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
fireEvent.click(radioToggle);
}
@@ -250,7 +251,7 @@ describe('SettingsModal', () => {
it('shows radio-unavailable message when config is null', () => {
renderModal({ config: null });
const radioToggle = screen.getByRole('button', { name: /Radio/i });
const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
expect(radioToggle).not.toBeDisabled();
fireEvent.click(radioToggle);
@@ -499,7 +500,7 @@ describe('SettingsModal', () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
desktopSection: 'radio-app',
onSaveAppSettings,
});
@@ -806,7 +807,7 @@ describe('SettingsModal', () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
desktopSection: 'radio-app',
onSaveAppSettings,
});
@@ -831,7 +832,7 @@ describe('SettingsModal', () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
desktopSection: 'radio-app',
appSettings: {
...baseSettings,
tracked_telemetry_repeaters: [directKey],
+19
View File
@@ -17,6 +17,9 @@ export interface RadioConfig {
path_hash_mode_supported: boolean;
advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean;
telemetry_mode_base?: number;
telemetry_mode_loc?: number;
telemetry_mode_env?: number;
}
export interface RadioConfigUpdate {
@@ -28,6 +31,9 @@ export interface RadioConfigUpdate {
path_hash_mode?: number;
advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean;
telemetry_mode_base?: number;
telemetry_mode_loc?: number;
telemetry_mode_env?: number;
}
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
@@ -359,6 +365,7 @@ export interface AppSettings {
blocked_names: string[];
discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
tracked_telemetry_contacts: string[];
auto_resend_channel: boolean;
telemetry_interval_hours: number;
telemetry_routed_hourly: boolean;
@@ -492,6 +499,18 @@ export interface RepeaterLppTelemetryResponse {
sensors: LppSensor[];
}
export interface ContactTelemetryResponse {
sensors: LppSensor[];
fetched_at: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface TrackedTelemetryContactsResponse {
tracked_telemetry_contacts: string[];
names: Record<string, string>;
schedule: TelemetrySchedule;
}
export type PaneName =
| 'status'
| 'nodeInfo'
+1
View File
@@ -16,6 +16,7 @@ interface ParsedHashConversation {
const SETTINGS_SECTIONS: SettingsSection[] = [
'radio',
'local',
'radio-app',
'fanout',
'database',
'statistics',
+2
View File
@@ -30,6 +30,7 @@ async def test_db():
"""Create an in-memory test database with schema + migrations."""
from app.repository import (
channels,
contact_telemetry,
contacts,
messages,
raw_packets,
@@ -49,6 +50,7 @@ async def test_db():
settings,
fanout_repo,
repeater_telemetry,
contact_telemetry,
]
originals = [(mod, mod.db) for mod in submodules]
+86
View File
@@ -675,3 +675,89 @@ class TestRoutingOverride:
assert response.status_code == 400
assert "same width" in response.json()["detail"].lower()
class TestContactTelemetry:
"""Tests for on-demand contact telemetry endpoint."""
@pytest.mark.asyncio
async def test_telemetry_happy_path(self, test_db, client):
"""Successful telemetry request returns sensors and persists history."""
await _insert_contact(KEY_A, name="Alice")
mock_mc = MagicMock()
mock_mc.commands.add_contact = AsyncMock(return_value=_radio_result())
mock_mc.commands.req_telemetry_sync = AsyncMock(
return_value=[
{"channel": 1, "type": "voltage", "value": 3.7},
{"channel": 1, "type": "temperature", "value": 22.5},
]
)
with (
patch("app.routers.contacts.radio_manager") as mock_rm,
patch("app.websocket.broadcast_event"),
):
mock_rm.is_connected = True
mock_rm.require_connected = MagicMock()
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
assert response.status_code == 200
data = response.json()
assert len(data["sensors"]) == 2
assert data["sensors"][0]["type_name"] == "voltage"
assert data["sensors"][0]["value"] == 3.7
assert data["fetched_at"] > 0
assert len(data["telemetry_history"]) >= 1
@pytest.mark.asyncio
async def test_telemetry_timeout_returns_504(self, test_db, client):
"""No response from contact returns 504."""
await _insert_contact(KEY_A)
mock_mc = MagicMock()
mock_mc.commands.add_contact = AsyncMock(return_value=_radio_result())
mock_mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
with (
patch("app.routers.contacts.radio_manager") as mock_rm,
):
mock_rm.is_connected = True
mock_rm.require_connected = MagicMock()
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
assert response.status_code == 504
@pytest.mark.asyncio
async def test_telemetry_history_endpoint(self, test_db, client):
"""History endpoint returns stored telemetry snapshots."""
import time
from app.repository.contact_telemetry import ContactTelemetryRepository
await _insert_contact(KEY_A)
now = int(time.time())
await ContactTelemetryRepository.record(
KEY_A, now, {"lpp_sensors": [{"channel": 1, "type_name": "voltage", "value": 3.6}]}
)
response = await client.get(f"/api/contacts/{KEY_A}/telemetry-history")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["data"]["lpp_sensors"][0]["value"] == 3.6
@pytest.mark.asyncio
async def test_telemetry_contact_not_found(self, test_db, client):
"""Telemetry for non-existent contact returns 404."""
with patch("app.routers.contacts.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.require_connected = MagicMock()
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
assert response.status_code == 404
+1 -1
View File
@@ -2,4 +2,4 @@
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
# ``applied == LATEST - starting_version`` so only this constant needs to
# change, not every individual assertion.
LATEST_SCHEMA_VERSION = 61
LATEST_SCHEMA_VERSION = 62
+8 -8
View File
@@ -2504,7 +2504,7 @@ class TestTelemetryCollectSchedulerDecision:
)
ran = False
async def fake_cycle():
async def fake_cycle(**_kwargs):
nonlocal ran
ran = True
@@ -2560,7 +2560,7 @@ class TestTelemetryCollectSchedulerDecision:
)
ran = False
async def fake_cycle():
async def fake_cycle(**_kwargs):
nonlocal ran
ran = True
@@ -2609,7 +2609,7 @@ class TestTelemetryCollectSchedulerDecision:
settings = AppSettings(tracked_telemetry_repeaters=[], telemetry_interval_hours=8)
ran = False
async def fake_cycle():
async def fake_cycle(**_kwargs):
nonlocal ran
ran = True
@@ -2670,7 +2670,7 @@ class TestTelemetryCollectSchedulerDecision:
)
ran = False
async def fake_cycle():
async def fake_cycle(**_kwargs):
nonlocal ran
ran = True
@@ -2733,7 +2733,7 @@ class TestTelemetryCollectSchedulerDecision:
)
ran = False
async def fake_cycle():
async def fake_cycle(**_kwargs):
nonlocal ran
ran = True
@@ -2794,7 +2794,7 @@ class TestRoutedHourlySchedulerDecision:
)
calls = []
async def fake_cycle(*, routed_only=False):
async def fake_cycle(*, routed_only=False, **_kwargs):
calls.append({"routed_only": routed_only})
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
@@ -2828,7 +2828,7 @@ class TestRoutedHourlySchedulerDecision:
)
calls = []
async def fake_cycle(*, routed_only=False):
async def fake_cycle(*, routed_only=False, **_kwargs):
calls.append({"routed_only": routed_only})
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
@@ -2862,7 +2862,7 @@ class TestRoutedHourlySchedulerDecision:
)
calls = []
async def fake_cycle(*, routed_only=False):
async def fake_cycle(*, routed_only=False, **_kwargs):
calls.append({"routed_only": routed_only})
now = real_datetime.datetime(2026, 4, 16, 16, 0, 0, tzinfo=real_datetime.UTC)