From 0bd0c062f25c64e14dc2dc22e4be24f765d84e97 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 12:10:49 -0700 Subject: [PATCH 1/7] Initial tracke telemetry for contacts --- AGENTS.md | 6 +- app/AGENTS.md | 10 +- .../_062_contact_telemetry_history.py | 40 +++ app/models.py | 26 +- app/radio_sync.py | 129 ++++++- app/repository/contact_telemetry.py | 100 ++++++ app/repository/settings.py | 20 +- app/routers/contacts.py | 71 ++++ app/routers/settings.py | 116 +++++- frontend/AGENTS.md | 3 +- frontend/src/App.tsx | 3 + frontend/src/api.ts | 19 + frontend/src/components/ContactInfoPane.tsx | 330 +++++++++++++++++- frontend/src/components/SettingsModal.tsx | 6 + .../settings/SettingsDatabaseSection.tsx | 125 +++++++ frontend/src/hooks/useAppSettings.ts | 34 ++ frontend/src/test/contactInfoPane.test.tsx | 14 +- frontend/src/test/fanoutSection.test.tsx | 2 + frontend/src/test/settingsModal.test.tsx | 1 + frontend/src/types.ts | 13 + tests/conftest.py | 2 + tests/test_contacts_router.py | 86 +++++ 22 files changed, 1125 insertions(+), 31 deletions(-) create mode 100644 app/migrations/_062_contact_telemetry_history.py create mode 100644 app/repository/contact_telemetry.py diff --git a/AGENTS.md b/AGENTS.md index 0c39f32..b668bd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/AGENTS.md b/app/AGENTS.md index 31b6d62..c1961b4 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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` diff --git a/app/migrations/_062_contact_telemetry_history.py b/app/migrations/_062_contact_telemetry_history.py new file mode 100644 index 0000000..ce218f4 --- /dev/null +++ b/app/migrations/_062_contact_telemetry_history.py @@ -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() diff --git a/app/models.py b/app/models.py index 232c113..2c1636d 100644 --- a/app/models.py +++ b/app/models.py @@ -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." ), ) diff --git a/app/radio_sync.py b/app/radio_sync.py index 4c217ab..52fc18e 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -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,10 +1891,87 @@ 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) -> None: + """Collect one telemetry sample from tracked repeaters and 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. """ @@ -1902,12 +1980,14 @@ async def _run_telemetry_cycle(*, routed_only: bool = False) -> None: return app_settings = await AppSettingsRepository.get() - tracked = app_settings.tracked_telemetry_repeaters - if not tracked: + tracked_repeaters = app_settings.tracked_telemetry_repeaters + tracked_contacts = app_settings.tracked_telemetry_contacts + 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 +1997,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,7 +2072,9 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None: telemetry). """ app_settings = await AppSettingsRepository.get() - tracked_count = len(app_settings.tracked_telemetry_repeaters) + tracked_count = len(app_settings.tracked_telemetry_repeaters) + len( + app_settings.tracked_telemetry_contacts + ) if tracked_count == 0: return effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count) @@ -1985,10 +2084,10 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None: is_normal_cycle = now.hour % effective_hours == 0 if is_normal_cycle: - # Normal scheduled boundary: collect ALL tracked repeaters. + # Normal scheduled boundary: collect ALL tracked targets. await _run_telemetry_cycle() elif app_settings.telemetry_routed_hourly: - # Hourly routed-path fast-poll: only repeaters with a non-flood route. + # Hourly routed-path fast-poll: only targets with a non-flood route. await _run_telemetry_cycle(routed_only=True) diff --git a/app/repository/contact_telemetry.py b/app/repository/contact_telemetry.py new file mode 100644 index 0000000..7588d1e --- /dev/null +++ b/app/repository/contact_telemetry.py @@ -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"]), + } diff --git a/app/repository/settings.py b/app/repository/settings.py index 0670714..f5b6a28 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -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, diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 6a94502..2f944ad 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -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,71 @@ 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, + ) + + # 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] diff --git a/app/routers/settings.py b/app/routers/settings.py index da18a50..6456104 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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): @@ -350,6 +351,8 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT names[k] = contact.name if contact and contact.name else k[:12] return names + n_contacts = len(settings.tracked_telemetry_contacts) + if key in current: # Remove new_list = [k for k in current if k != key] @@ -359,7 +362,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT tracked_telemetry_repeaters=new_list, names=await _resolve_names(new_list), schedule=_build_schedule( - len(new_list), + len(new_list) + n_contacts, settings.telemetry_interval_hours, settings.telemetry_routed_hourly, ), @@ -390,7 +393,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT tracked_telemetry_repeaters=new_list, names=await _resolve_names(new_list), schedule=_build_schedule( - len(new_list), + len(new_list) + n_contacts, settings.telemetry_interval_hours, settings.telemetry_routed_hourly, ), @@ -404,10 +407,117 @@ 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. + + The tracked count includes both repeaters and contacts for ceiling + enforcement. """ app_settings = await AppSettingsRepository.get() + combined_count = len(app_settings.tracked_telemetry_repeaters) + len( + app_settings.tracked_telemetry_contacts + ) return _build_schedule( - len(app_settings.tracked_telemetry_repeaters), + combined_count, + 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 + + def combined_count(lst: list[str]) -> int: + return len(settings.tracked_telemetry_repeaters) + len(lst) + + 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( + combined_count(new_list), + settings.telemetry_interval_hours, + settings.telemetry_routed_hourly, + ), + ) + + # Validate contact exists + contact = await ContactRepository.get_by_key(key) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + + 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( + combined_count(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. + + Uses the combined tracked count (repeaters + contacts) for ceiling + enforcement since they share one collection loop. + """ + app_settings = await AppSettingsRepository.get() + combined_count = len(app_settings.tracked_telemetry_repeaters) + len( + app_settings.tracked_telemetry_contacts + ) + return _build_schedule( + combined_count, app_settings.telemetry_interval_hours, app_settings.telemetry_routed_hourly, ) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 038f59f..c00c174 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -361,7 +361,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 +382,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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2265ade..341a794 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 25b7143..24c7125 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/settings/tracked-telemetry/schedule'), + // Tracked contact telemetry + toggleTrackedTelemetryContact: (publicKey: string) => + fetchJson('/settings/tracked-telemetry-contacts/toggle', { + method: 'POST', + body: JSON.stringify({ public_key: publicKey }), + }), + + getContactTelemetrySchedule: () => + fetchJson('/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(`/contacts/${publicKey}/repeater/telemetry-history`), + // Contact telemetry (universal, any contact type) + requestContactTelemetry: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/telemetry`, { + method: 'POST', + }), + contactTelemetryHistory: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/telemetry-history`), roomLogin: (publicKey: string, password: string) => fetchJson(`/contacts/${publicKey}/room/login`, { method: 'POST', diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 020239f..dc39063 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -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 = { @@ -96,6 +104,8 @@ export function ContactInfoPane({ const [analytics, setAnalytics] = useState(null); const [loading, setLoading] = useState(false); + const [telemetryLoading, setTelemetryLoading] = useState(false); + const [telemetryHistory, setTelemetryHistory] = useState([]); // Get live contact data from contacts array (real-time via WS) const liveContact = @@ -133,6 +143,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 +416,14 @@ export function ContactInfoPane({ )} + {/* Contact Telemetry */} + + {/* Favorite toggle */}
+ +
+ + {expanded && ( +
+ {sensors.length === 0 ? ( +

+ {fetchedAt ? 'No sensor data in last response' : 'Not yet fetched'} +

+ ) : ( + <> +
+ {displaySensors.map((sensor, i) => ( + + ))} +
+ + {hasGps && ( +
+ + {mapExpanded && ( +
+ + + + + + {contact.name ?? contact.public_key.slice(0, 12)} + + + + +
+ )} +
+ )} + + {fetchedAt && ( +

+ Fetched {formatTime(fetchedAt)} +

+ )} + + )} + + {/* History chart */} + {telemetryHistory.length > 1 && sensorSeries.length > 0 && ( +
+ + {chartExpanded && ( +
+
+ {sensorSeries.map((s) => ( + + ))} +
+ {chartData.length > 1 && activeSeries && ( + + + + { + 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)' }} + /> + + new Date(Number(t) * 1000).toLocaleString()} + contentStyle={{ + backgroundColor: 'var(--popover)', + border: '1px solid var(--border)', + fontSize: '0.75rem', + }} + /> + + + + )} +
+ )} +
+ )} +
+ )} + + ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e72c18e..b4fd03c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -54,6 +54,8 @@ interface SettingsModalBaseProps { onBulkDeleteContacts?: (deletedKeys: string[]) => void; trackedTelemetryRepeaters?: string[]; onToggleTrackedTelemetry?: (publicKey: string) => Promise; + trackedTelemetryContacts?: string[]; + onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; } export type SettingsModalProps = SettingsModalBaseProps & @@ -92,6 +94,8 @@ export function SettingsModal(props: SettingsModalProps) { onBulkDeleteContacts, trackedTelemetryRepeaters, onToggleTrackedTelemetry, + trackedTelemetryContacts, + onToggleTrackedTelemetryContact, } = props; const externalSidebarNav = props.externalSidebarNav === true; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; @@ -257,6 +261,8 @@ export function SettingsModal(props: SettingsModalProps) { onBulkDeleteContacts={onBulkDeleteContacts} trackedTelemetryRepeaters={trackedTelemetryRepeaters} onToggleTrackedTelemetry={onToggleTrackedTelemetry} + trackedTelemetryContacts={trackedTelemetryContacts} + onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact} className={sectionContentClass} /> ) : ( diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 32308a8..7d91b95 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -31,6 +31,8 @@ export function SettingsDatabaseSection({ onBulkDeleteContacts, trackedTelemetryRepeaters = [], onToggleTrackedTelemetry, + trackedTelemetryContacts = [], + onToggleTrackedTelemetryContact, className, }: { appSettings: AppSettings; @@ -45,6 +47,8 @@ export function SettingsDatabaseSection({ onBulkDeleteContacts?: (deletedKeys: string[]) => void; trackedTelemetryRepeaters?: string[]; onToggleTrackedTelemetry?: (publicKey: string) => Promise; + trackedTelemetryContacts?: string[]; + onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; className?: string; }) { const { distanceUnit } = useDistanceUnit(); @@ -60,6 +64,11 @@ export function SettingsDatabaseSection({ >({}); const telemetryFetchedRef = useRef(false); + const [latestContactTelemetry, setLatestContactTelemetry] = useState< + Record + >({}); + const contactTelemetryFetchedRef = useRef(false); + const [schedule, setSchedule] = useState(null); const [intervalDraft, setIntervalDraft] = useState(appSettings.telemetry_interval_hours); @@ -94,6 +103,7 @@ export function SettingsDatabaseSection({ }; }, [ trackedTelemetryRepeaters.length, + trackedTelemetryContacts.length, appSettings.telemetry_interval_hours, appSettings.telemetry_routed_hourly, ]); @@ -117,6 +127,25 @@ export function SettingsDatabaseSection({ }; }, [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 handleCleanup = async () => { const days = parseInt(retentionDays, 10); if (isNaN(days) || days < 1) { @@ -480,6 +509,102 @@ export function SettingsDatabaseSection({ + {/* ── Tracked Contact Telemetry ── */} +
+

Tracked Contact Telemetry

+

+ 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. +

+ + {trackedTelemetryContacts.length === 0 ? ( +

+ No contacts are being tracked. Enable tracking from a contact's info pane. +

+ ) : ( +
+ {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 ( +
+
+
+ {displayName} +
+ + {key.slice(0, 12)} + + + {routeLabel} + +
+
+ {onToggleTrackedTelemetryContact && ( + + )} +
+ {d ? ( +
+ {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 ( + + {label} {val} + {display.unit ? ` ${display.unit}` : ''} + + ); + })} + checked {formatTime(snap.timestamp)} +
+ ) : snap === null ? ( +
+ No telemetry recorded yet +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + {/* ── Contact Management ── */}

Contact Management

diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 9a86184..6f4e7c7 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -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, }; } diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index 2e8c397..be53e12 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -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: () =>
, })); +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(); }); diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index 51773b3..3c26658 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -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, diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index c8c03e4..23c5b10 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1472b5b..a0111a2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -359,6 +359,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 +493,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; + schedule: TelemetrySchedule; +} + export type PaneName = | 'status' | 'nodeInfo' diff --git a/tests/conftest.py b/tests/conftest.py index 794b893..cc95bdd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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] diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index ff27787..206ba84 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -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 From 7f1bb92e92d697144fd393fbd8e55611fa9ae94e Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 12:44:52 -0700 Subject: [PATCH 2/7] Add telemetry config to radio settings --- app/routers/radio.py | 24 +++++ app/services/radio_commands.py | 24 +++++ .../settings/SettingsRadioSection.tsx | 88 +++++++++++++++++++ frontend/src/types.ts | 6 ++ 4 files changed, 142 insertions(+) diff --git a/app/routers/radio.py b/app/routers/radio.py index 02fb4b2..e3b2c69 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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), ) diff --git a/app/services/radio_commands.py b/app/services/radio_commands.py index 9718a37..8f7a1c5 100644 --- a/app/services/radio_commands.py +++ b/app/services/radio_commands.py @@ -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) diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 1278a6a..25d64e4 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -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): 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({
+ + + {/* ── Telemetry Sharing ── */} +
+

Telemetry Sharing

+

+ Controls what this radio shares when other nodes request its telemetry. “Deny” + blocks all requests, “Per-Contact” uses per-contact permission flags on the + radio, and “Allow All” shares with any requester. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {error && (
{error} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a0111a2..db8e9f4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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'; From 84aa352be30e26c825ccb32ec65b2a2b2341faf8 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 13:14:43 -0700 Subject: [PATCH 3/7] Split up setting to be a bit neater --- frontend/AGENTS.md | 5 +- frontend/src/App.tsx | 2 + frontend/src/components/ContactInfoPane.tsx | 40 ++ frontend/src/components/SettingsModal.tsx | 34 +- .../repeater/RepeaterTelemetryHistoryPane.tsx | 4 +- .../settings/SettingsDatabaseSection.tsx | 542 +---------------- .../settings/SettingsRadioAppSection.tsx | 555 ++++++++++++++++++ .../components/settings/settingsConstants.ts | 17 +- frontend/src/test/appFavorites.test.tsx | 5 +- frontend/src/test/appStartupHash.test.tsx | 5 +- frontend/src/test/settingsModal.test.tsx | 10 +- frontend/src/utils/urlHash.ts | 1 + 12 files changed, 657 insertions(+), 563 deletions(-) create mode 100644 frontend/src/components/settings/SettingsRadioAppSection.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index c00c174..f05a8c4 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 341a794..2ae1a42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -751,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, diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index dc39063..5609b16 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -81,6 +81,8 @@ interface ContactInfoPaneProps { blockedNames?: string[]; onToggleBlockedKey?: (key: string) => void; onToggleBlockedName?: (name: string) => void; + trackedTelemetryContacts?: string[]; + onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; } export function ContactInfoPane({ @@ -97,6 +99,8 @@ export function ContactInfoPane({ blockedNames = [], onToggleBlockedKey, onToggleBlockedName, + trackedTelemetryContacts = [], + onToggleTrackedTelemetryContact, }: ContactInfoPaneProps) { const { distanceUnit } = useDistanceUnit(); const isNameOnly = contactKey?.startsWith('name:') ?? false; @@ -422,6 +426,8 @@ export function ContactInfoPane({ loading={telemetryLoading} onFetch={handleFetchTelemetry} telemetryHistory={telemetryHistory} + isTracked={trackedTelemetryContacts.includes(contact.public_key)} + onToggleTracked={onToggleTrackedTelemetryContact} /> {/* Favorite toggle */} @@ -971,16 +977,21 @@ function ContactTelemetrySection({ loading, onFetch, telemetryHistory, + isTracked, + onToggleTracked, }: { contact: Contact; loading: boolean; onFetch: () => void; telemetryHistory: TelemetryHistoryEntry[]; + isTracked: boolean; + onToggleTracked?: (publicKey: string) => Promise; }) { 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 = @@ -1230,6 +1241,35 @@ function ContactTelemetrySection({ )}
)} + + {/* Tracking toggle */} + {onToggleTracked && ( +
+ +
+ )} )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b4fd03c..4999401 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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'; @@ -110,6 +111,7 @@ export function SettingsModal(props: SettingsModalProps) { const [expandedSections, setExpandedSections] = useState>({ radio: false, local: false, + 'radio-app': false, fanout: false, database: false, statistics: false, @@ -243,16 +245,14 @@ export function SettingsModal(props: SettingsModalProps) { )} - {shouldRenderSection('database') && ( + {shouldRenderSection('radio-app') && (
- {renderSectionHeader('database')} - {isSectionVisible('database') && + {renderSectionHeader('radio-app')} + {isSectionVisible('radio-app') && (appSettings ? ( - )} + {shouldRenderSection('database') && ( +
+ {renderSectionHeader('database')} + {isSectionVisible('database') && + (appSettings ? ( + + ) : ( +
+
+ Loading app settings... +
+
+ ))} +
+ )} + {shouldRenderSection('fanout') && (
{renderSectionHeader('fanout')} diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index 16876eb..dd02aec 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -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{' '} - Settings → Database & Messaging + Settings → Radio-App Management , 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 diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 7d91b95..72553b3 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -6,146 +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, - trackedTelemetryContacts = [], - onToggleTrackedTelemetryContact, className, }: { appSettings: AppSettings; health: HealthStatus | null; onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onHealthRefresh: () => Promise; - blockedKeys?: string[]; - blockedNames?: string[]; - onToggleBlockedKey?: (key: string) => void; - onToggleBlockedName?: (name: string) => void; - contacts?: Contact[]; - onBulkDeleteContacts?: (deletedKeys: string[]) => void; - trackedTelemetryRepeaters?: string[]; - onToggleTrackedTelemetry?: (publicKey: string) => Promise; - trackedTelemetryContacts?: string[]; - onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; 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([]); - const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); - const [latestTelemetry, setLatestTelemetry] = useState< - Record - >({}); - const telemetryFetchedRef = useRef(false); - - const [latestContactTelemetry, setLatestContactTelemetry] = useState< - Record - >({}); - const contactTelemetryFetchedRef = useRef(false); - - const [schedule, setSchedule] = useState(null); - const [intervalDraft, setIntervalDraft] = useState(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.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, - 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 handleCleanup = async () => { const days = parseInt(retentionDays, 10); if (isNaN(days) || days < 1) { @@ -192,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 => { const chained = saveChainRef.current.then(async () => { try { @@ -324,426 +204,6 @@ export function SettingsDatabaseSection({ contact sends an advertisement. This may cause brief delays on large packet backlogs.

- - - - {/* ── Tracked Repeater Telemetry ── */} -
-

Tracked Repeater Telemetry

-

- 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). -

- - {/* 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. */} -
- -
- -
- {schedule && schedule.effective_hours !== schedule.preferred_hours && ( -

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

- )} -
- - {/* Routed hourly toggle */} - - - {schedule?.next_run_at != null && ( -

- {schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '} - {formatTime(schedule.next_run_at)} (UTC top of hour). -

- )} - {schedule?.next_routed_run_at != null && ( -

- Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour). -

- )} - - {trackedTelemetryRepeaters.length === 0 ? ( -

- No repeaters are being tracked. Enable tracking from a repeater's dashboard. -

- ) : ( -
- {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 ( -
-
-
- {displayName} -
- - {key.slice(0, 12)} - - - {routeLabel} - -
-
- {onToggleTrackedTelemetry && ( - - )} -
- {d ? ( -
- {d.battery_volts?.toFixed(2)}V - noise {d.noise_floor_dbm} dBm - - rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'} - - - tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'} - - {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 ( - - {label} {val} - {display.unit ? ` ${display.unit}` : ''} - - ); - })} - checked {formatTime(snap.timestamp)} -
- ) : snap === null ? ( -
- No telemetry recorded yet -
- ) : null} -
- ); - })} -
- )} -
- - - - {/* ── Tracked Contact Telemetry ── */} -
-

Tracked Contact Telemetry

-

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

- - {trackedTelemetryContacts.length === 0 ? ( -

- No contacts are being tracked. Enable tracking from a contact's info pane. -

- ) : ( -
- {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 ( -
-
-
- {displayName} -
- - {key.slice(0, 12)} - - - {routeLabel} - -
-
- {onToggleTrackedTelemetryContact && ( - - )} -
- {d ? ( -
- {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 ( - - {label} {val} - {display.unit ? ` ${display.unit}` : ''} - - ); - })} - checked {formatTime(snap.timestamp)} -
- ) : snap === null ? ( -
- No telemetry recorded yet -
- ) : null} -
- ); - })} -
- )} -
- - - - {/* ── Contact Management ── */} -
-

Contact Management

- - {/* Block discovery of new node types */} -
-

Block Discovery of New Node Types

-

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

-
- {( - [ - [1, 'Block clients'], - [2, 'Block repeaters'], - [3, 'Block room servers'], - [4, 'Block sensors'], - ] as const - ).map(([typeCode, label]) => { - const checked = discoveryBlockedTypes.includes(typeCode); - return ( - - ); - })} -
- {discoveryBlockedTypes.length > 0 && ( -

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

- )} -
- - {/* Blocked contacts list */} -
-

Blocked Contacts

-

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

- - {blockedKeys.length === 0 && blockedNames.length === 0 ? ( -

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

- ) : ( -
- {blockedKeys.length > 0 && ( -
- Blocked Keys -
- {blockedKeys.map((key) => ( -
- {key} - {onToggleBlockedKey && ( - - )} -
- ))} -
-
- )} - {blockedNames.length > 0 && ( -
- Blocked Names -
- {blockedNames.map((name) => ( -
- {name} - {onToggleBlockedName && ( - - )} -
- ))} -
-
- )} -
- )} -
- - {/* Bulk delete */} -
-

Bulk Delete Contacts

-

- Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted - nodes. Message history will be preserved. -

- - setBulkDeleteOpen(false)} - contacts={contacts} - onDeleted={(keys) => onBulkDeleteContacts?.(keys)} - /> -
-
); } diff --git a/frontend/src/components/settings/SettingsRadioAppSection.tsx b/frontend/src/components/settings/SettingsRadioAppSection.tsx new file mode 100644 index 0000000..8029e3d --- /dev/null +++ b/frontend/src/components/settings/SettingsRadioAppSection.tsx @@ -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; + blockedKeys?: string[]; + blockedNames?: string[]; + onToggleBlockedKey?: (key: string) => void; + onToggleBlockedName?: (name: string) => void; + contacts?: Contact[]; + onBulkDeleteContacts?: (deletedKeys: string[]) => void; + trackedTelemetryRepeaters?: string[]; + onToggleTrackedTelemetry?: (publicKey: string) => Promise; + trackedTelemetryContacts?: string[]; + onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; + className?: string; +}) { + const { distanceUnit } = useDistanceUnit(); + const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState([]); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + + const [latestTelemetry, setLatestTelemetry] = useState< + Record + >({}); + const telemetryFetchedRef = useRef(false); + + const [latestContactTelemetry, setLatestContactTelemetry] = useState< + Record + >({}); + const contactTelemetryFetchedRef = useRef(false); + + const [schedule, setSchedule] = useState(null); + const [intervalDraft, setIntervalDraft] = useState(appSettings.telemetry_interval_hours); + + const saveChainRef = useRef>(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 => { + 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 ( +
+ {/* ── Tracked Repeater Telemetry ── */} +
+

Tracked Repeater Telemetry

+

+ 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). +

+ +
+ +
+ +
+ {schedule && schedule.effective_hours !== schedule.preferred_hours && ( +

+ 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. +

+ )} +
+ + + + {schedule?.next_run_at != null && ( +

+ {schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '} + {formatTime(schedule.next_run_at)} (UTC top of hour). +

+ )} + {schedule?.next_routed_run_at != null && ( +

+ Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour). +

+ )} + + {trackedTelemetryRepeaters.length === 0 ? ( +

+ No repeaters are being tracked. Enable tracking from a repeater's dashboard. +

+ ) : ( +
+ {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 ( +
+
+
+ {displayName} +
+ + {key.slice(0, 12)} + + + {routeLabel} + +
+
+ {onToggleTrackedTelemetry && ( + + )} +
+ {d ? ( +
+ {d.battery_volts?.toFixed(2)}V + noise {d.noise_floor_dbm} dBm + + rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'} + + + tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'} + + {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 ( + + {label} {val} + {display.unit ? ` ${display.unit}` : ''} + + ); + })} + checked {formatTime(snap.timestamp)} +
+ ) : snap === null ? ( +
+ No telemetry recorded yet +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + + {/* ── Tracked Contact Telemetry ── */} +
+

Tracked Contact Telemetry

+

+ 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. +

+ + {trackedTelemetryContacts.length === 0 ? ( +

+ No contacts are being tracked. Enable tracking from a contact's info pane. +

+ ) : ( +
+ {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 ( +
+
+
+ {displayName} +
+ + {key.slice(0, 12)} + + + {routeLabel} + +
+
+ {onToggleTrackedTelemetryContact && ( + + )} +
+ {d ? ( +
+ {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 ( + + {label} {val} + {display.unit ? ` ${display.unit}` : ''} + + ); + })} + checked {formatTime(snap.timestamp)} +
+ ) : snap === null ? ( +
+ No telemetry recorded yet +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + + {/* ── Contact Management ── */} +
+

Contact Management

+ +
+

Block Discovery of New Node Types

+

+ 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. +

+
+ {( + [ + [1, 'Block clients'], + [2, 'Block repeaters'], + [3, 'Block room servers'], + [4, 'Block sensors'], + ] as const + ).map(([typeCode, label]) => { + const checked = discoveryBlockedTypes.includes(typeCode); + return ( + + ); + })} +
+ {discoveryBlockedTypes.length > 0 && ( +

+ 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. +

+ )} +
+ +
+

Blocked Contacts

+

+ 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. +

+ + {blockedKeys.length === 0 && blockedNames.length === 0 ? ( +

+ 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. +

+ ) : ( +
+ {blockedKeys.length > 0 && ( +
+ Blocked Keys +
+ {blockedKeys.map((key) => ( +
+ {key} + {onToggleBlockedKey && ( + + )} +
+ ))} +
+
+ )} + {blockedNames.length > 0 && ( +
+ Blocked Names +
+ {blockedNames.map((name) => ( +
+ {name} + {onToggleBlockedName && ( + + )} +
+ ))} +
+
+ )} +
+ )} +
+ +
+

Bulk Delete Contacts

+

+ Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted + nodes. Message history will be preserved. +

+ + setBulkDeleteOpen(false)} + contacts={contacts} + onDeleted={(keys) => onBulkDeleteContacts?.(keys)} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts index 64d4f2c..23f56f9 100644 --- a/frontend/src/components/settings/settingsConstants.ts +++ b/frontend/src/components/settings/settingsConstants.ts @@ -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 = { 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 = { export const SETTINGS_SECTION_ICONS: Record = { radio: RadioTower, local: MonitorCog, + 'radio-app': SlidersHorizontal, database: Database, fanout: Share2, statistics: BarChart3, diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 22dbeed..022f653 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -149,11 +149,12 @@ vi.mock('../components/SettingsModal', () => ({ SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
{desktopSection ?? 'none'}
), - 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', }, })); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 90013c3..c938b74 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -92,11 +92,12 @@ vi.mock('../components/SettingsModal', () => ({ SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
{desktopSection ?? 'none'}
), - 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', }, })); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 23c5b10..ec881c3 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -178,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); } @@ -251,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); @@ -500,7 +500,7 @@ describe('SettingsModal', () => { renderModal({ externalSidebarNav: true, - desktopSection: 'database', + desktopSection: 'radio-app', onSaveAppSettings, }); @@ -807,7 +807,7 @@ describe('SettingsModal', () => { renderModal({ externalSidebarNav: true, - desktopSection: 'database', + desktopSection: 'radio-app', onSaveAppSettings, }); @@ -832,7 +832,7 @@ describe('SettingsModal', () => { renderModal({ externalSidebarNav: true, - desktopSection: 'database', + desktopSection: 'radio-app', appSettings: { ...baseSettings, tracked_telemetry_repeaters: [directKey], diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index c2b6f8d..5f2563b 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -16,6 +16,7 @@ interface ParsedHashConversation { const SETTINGS_SECTIONS: SettingsSection[] = [ 'radio', 'local', + 'radio-app', 'fanout', 'database', 'statistics', From 2eb8ac15a81dd84adf7fa4387978cabcdaa52a63 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 13:51:17 -0700 Subject: [PATCH 4/7] Reject repeaters from contact telemetry opt-in --- app/routers/settings.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/routers/settings.py b/app/routers/settings.py index 6456104..d54e1b5 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -475,10 +475,15 @@ async def toggle_tracked_telemetry_contact( ), ) - # Validate contact exists + # 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) From a4fd1d3b37ba0988b5d05c6f72085cf77a62e9c1 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 16:10:13 -0700 Subject: [PATCH 5/7] Propagate to HA --- app/fanout/mqtt_ha.py | 35 +++++++++++++++++++++++++++++++++-- app/routers/contacts.py | 14 ++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index 093401d..eca696f 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -576,12 +576,32 @@ 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 = _repeater_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 +664,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,7 +780,7 @@ 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) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 2f944ad..6ff1afb 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -663,6 +663,20 @@ async def request_contact_telemetry(public_key: str) -> ContactTelemetryResponse 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) From 896267ff7e2ec5d13cb69076dd2798d2fe5b2d57 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 16:20:10 -0700 Subject: [PATCH 6/7] Fix migration bump --- app/fanout/mqtt_ha.py | 4 +--- tests/test_migrations/conftest.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index eca696f..49de98a 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -588,9 +588,7 @@ class MqttHaModule(FanoutModule): 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_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( diff --git a/tests/test_migrations/conftest.py b/tests/test_migrations/conftest.py index 259d6b5..c066e9a 100644 --- a/tests/test_migrations/conftest.py +++ b/tests/test_migrations/conftest.py @@ -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 From 2778e8bd4f854842b21d23bf5945564d6041e193 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 13 May 2026 17:47:24 -0700 Subject: [PATCH 7/7] Don't use ghost shape of telemetry, and fix ceiling behavior --- app/fanout/mqtt_ha.py | 21 ++++++++++++--- app/radio_sync.py | 55 +++++++++++++++++++++++++++------------- app/routers/settings.py | 31 +++++----------------- tests/test_radio_sync.py | 16 ++++++------ 4 files changed, 70 insertions(+), 53 deletions(-) diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index 49de98a..b8d1278 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -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, @@ -596,7 +608,7 @@ class MqttHaModule(FanoutModule): ) ) if latest_ct_data: - ct_payload = _repeater_telemetry_payload(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) ) @@ -782,9 +794,10 @@ class MqttHaModule(FanoutModule): 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): diff --git a/app/radio_sync.py b/app/radio_sync.py index 52fc18e..75f98de 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -1968,20 +1968,28 @@ async def _collect_contact_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 and contacts. +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_repeaters = app_settings.tracked_telemetry_repeaters - tracked_contacts = app_settings.tracked_telemetry_contacts + 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 @@ -2072,22 +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) + len( - app_settings.tracked_telemetry_contacts - ) - 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 targets. - await _run_telemetry_cycle() - elif app_settings.telemetry_routed_hourly: - # Hourly routed-path fast-poll: only targets 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) diff --git a/app/routers/settings.py b/app/routers/settings.py index d54e1b5..54dead1 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -351,8 +351,6 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT names[k] = contact.name if contact and contact.name else k[:12] return names - n_contacts = len(settings.tracked_telemetry_contacts) - if key in current: # Remove new_list = [k for k in current if k != key] @@ -362,7 +360,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT tracked_telemetry_repeaters=new_list, names=await _resolve_names(new_list), schedule=_build_schedule( - len(new_list) + n_contacts, + len(new_list), settings.telemetry_interval_hours, settings.telemetry_routed_hourly, ), @@ -393,7 +391,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT tracked_telemetry_repeaters=new_list, names=await _resolve_names(new_list), schedule=_build_schedule( - len(new_list) + n_contacts, + len(new_list), settings.telemetry_interval_hours, settings.telemetry_routed_hourly, ), @@ -408,15 +406,10 @@ async def get_telemetry_schedule() -> TelemetrySchedule: surface saved-vs-effective when they differ, and show the next-run-at timestamp so users know when the next cycle will fire. - The tracked count includes both repeaters and contacts for ceiling - enforcement. """ app_settings = await AppSettingsRepository.get() - combined_count = len(app_settings.tracked_telemetry_repeaters) + len( - app_settings.tracked_telemetry_contacts - ) return _build_schedule( - combined_count, + len(app_settings.tracked_telemetry_repeaters), app_settings.telemetry_interval_hours, app_settings.telemetry_routed_hourly, ) @@ -457,9 +450,6 @@ async def toggle_tracked_telemetry_contact( names[k] = contact.name if contact and contact.name else k[:12] return names - def combined_count(lst: list[str]) -> int: - return len(settings.tracked_telemetry_repeaters) + len(lst) - if key in current: # Remove new_list = [k for k in current if k != key] @@ -469,7 +459,7 @@ async def toggle_tracked_telemetry_contact( tracked_telemetry_contacts=new_list, names=await _resolve_names(new_list), schedule=_build_schedule( - combined_count(new_list), + len(new_list), settings.telemetry_interval_hours, settings.telemetry_routed_hourly, ), @@ -503,7 +493,7 @@ async def toggle_tracked_telemetry_contact( tracked_telemetry_contacts=new_list, names=await _resolve_names(new_list), schedule=_build_schedule( - combined_count(new_list), + len(new_list), settings.telemetry_interval_hours, settings.telemetry_routed_hourly, ), @@ -512,17 +502,10 @@ async def toggle_tracked_telemetry_contact( @router.get("/tracked-telemetry-contacts/schedule", response_model=TelemetrySchedule) async def get_contact_telemetry_schedule() -> TelemetrySchedule: - """Return the current telemetry scheduling derivation for contacts. - - Uses the combined tracked count (repeaters + contacts) for ceiling - enforcement since they share one collection loop. - """ + """Return the current telemetry scheduling derivation for contacts.""" app_settings = await AppSettingsRepository.get() - combined_count = len(app_settings.tracked_telemetry_repeaters) + len( - app_settings.tracked_telemetry_contacts - ) return _build_schedule( - combined_count, + len(app_settings.tracked_telemetry_contacts), app_settings.telemetry_interval_hours, app_settings.telemetry_routed_hourly, ) diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index d11a75c..f11dde4 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -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)