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 2e58eb6..d4b243c 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"), @@ -541,7 +543,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" ) @@ -596,6 +598,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.""" @@ -847,18 +859,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 00d332e..c813953 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 cd80136..4bb4342 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -357,6 +357,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; @@ -490,6 +491,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 bb867c5..af31bb2 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