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 */}
+
+
+ {/* ── 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.
+
- 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. */}
-
- 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.
-
- 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.
-
- 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.
-
- 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.
-
- 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.
-
+ 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).
+
+ 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).
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+