Initial tracke telemetry for contacts

This commit is contained in:
Jack Kingsman
2026-04-27 12:10:49 -07:00
parent 72f3d95acf
commit 0bd0c062f2
22 changed files with 1125 additions and 31 deletions
+5 -1
View File
@@ -350,6 +350,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals | | POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info | | 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) | | 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/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/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | | 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/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater | | 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 | | 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 | | POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
| GET | `/api/fanout` | List all fanout configs | | GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config | | 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_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). | | `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. Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+8 -2
View File
@@ -169,7 +169,8 @@ app/
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`. - 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. - `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). - `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. - 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. - 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. - 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/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info` - `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) - `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/login`
- `POST /contacts/{public_key}/room/status` - `POST /contacts/{public_key}/room/status`
- `POST /contacts/{public_key}/room/lpp-telemetry` - `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/blocked-names/toggle`
- `POST /settings/tracked-telemetry/toggle` - `POST /settings/tracked-telemetry/toggle`
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp - `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` - `POST /settings/muted-channels/toggle`
### Fanout ### Fanout
@@ -320,6 +325,7 @@ Main tables:
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count) - `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
- `contact_name_history` (tracks name changes over time) - `contact_name_history` (tracks name changes over time)
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters) - `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) - `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
- `push_subscriptions` (Web Push browser subscriptions with delivery metadata; UNIQUE on endpoint) - `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) - `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` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types` - `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters` - `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel` - `auto_resend_channel`
- `telemetry_interval_hours` - `telemetry_interval_hours`
@@ -0,0 +1,40 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Create contact_telemetry_history table and tracked_telemetry_contacts setting."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_telemetry_history" not in tables:
await conn.execute(
"""
CREATE TABLE contact_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_contact_telemetry_pk_ts
ON contact_telemetry_history(public_key, timestamp)
"""
)
if "app_settings" in tables:
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "tracked_telemetry_contacts" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_contacts TEXT DEFAULT '[]'"
)
await conn.commit()
+21 -5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -42,7 +44,7 @@ class ContactUpsert(BaseModel):
first_seen: int | None = None first_seen: int | None = None
@classmethod @classmethod
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert": def from_contact(cls, contact: Contact, **changes) -> ContactUpsert:
return cls.model_validate( return cls.model_validate(
{ {
**contact.model_dump(exclude={"last_read_at"}), **contact.model_dump(exclude={"last_read_at"}),
@@ -53,7 +55,7 @@ class ContactUpsert(BaseModel):
@classmethod @classmethod
def from_radio_dict( def from_radio_dict(
cls, public_key: str, radio_data: dict, on_radio: bool = False cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert": ) -> ContactUpsert:
"""Convert radio contact data to the contact-row write shape.""" """Convert radio contact data to the contact-row write shape."""
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route( direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"), radio_data.get("out_path"),
@@ -543,7 +545,7 @@ class RepeaterStatusResponse(BaseModel):
direct_dups: int = Field(description="Duplicate direct packets") direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count") full_events: int = Field(description="Full event queue count")
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors") 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" 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") 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): class NeighborInfo(BaseModel):
"""Information about a neighbor seen by a repeater.""" """Information about a neighbor seen by a repeater."""
@@ -849,18 +861,22 @@ class AppSettings(BaseModel):
default_factory=list, default_factory=list,
description="Public keys of repeaters opted into periodic telemetry collection (max 8)", 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( telemetry_interval_hours: int = Field(
default=8, default=8,
description=( description=(
"User-preferred telemetry collection interval in hours. The backend " "User-preferred telemetry collection interval in hours. The backend "
"clamps this up to the shortest legal interval given the number of " "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( telemetry_routed_hourly: bool = Field(
default=False, default=False,
description=( 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." "path are polled every hour instead of on the normal scheduled interval."
), ),
) )
+114 -15
View File
@@ -31,6 +31,7 @@ from app.repository import (
ContactRepository, ContactRepository,
RepeaterTelemetryRepository, RepeaterTelemetryRepository,
) )
from app.repository.contact_telemetry import ContactTelemetryRepository
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact, promote_prefix_contacts_for_contact,
reconcile_contact_messages, reconcile_contact_messages,
@@ -1890,10 +1891,87 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
return False return False
async def _run_telemetry_cycle(*, routed_only: bool = False) -> None: async def _collect_contact_telemetry(mc: MeshCore, contact: Contact) -> bool:
"""Collect one telemetry sample from tracked repeaters. """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. ``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected.
This is used by the hourly routed-path fast-poll feature. 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 return
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
tracked = app_settings.tracked_telemetry_repeaters tracked_repeaters = app_settings.tracked_telemetry_repeaters
if not tracked: tracked_contacts = app_settings.tracked_telemetry_contacts
if not tracked_repeaters and not tracked_contacts:
return return
candidates: list[tuple[str, Contact]] = [] # Build repeater candidates
for pub_key in tracked: candidates: list[tuple[str, Contact, bool]] = [] # (key, contact, is_repeater)
for pub_key in tracked_repeaters:
contact = await ContactRepository.get_by_key(pub_key) contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2: if not contact or contact.type != 2:
logger.debug( logger.debug(
@@ -1917,29 +1997,46 @@ async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
continue continue
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0): if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
continue 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 not candidates:
if routed_only: 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 return
label = "routed" if routed_only else "full" label = "routed" if routed_only else "full"
logger.info( logger.info(
"Telemetry collect: starting %s cycle for %d repeater(s)", "Telemetry collect: starting %s cycle for %d target(s)",
label, label,
len(candidates), len(candidates),
) )
collected = 0 collected = 0
for _pub_key, contact in candidates: for _pub_key, contact, is_repeater in candidates:
try: try:
async with radio_manager.radio_operation( async with radio_manager.radio_operation(
"telemetry_collect", "telemetry_collect",
blocking=False, blocking=False,
suspend_auto_fetch=True, suspend_auto_fetch=True,
) as mc: ) 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 collected += 1
except RadioOperationBusyError: except RadioOperationBusyError:
logger.debug( logger.debug(
@@ -1975,7 +2072,9 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
telemetry). telemetry).
""" """
app_settings = await AppSettingsRepository.get() 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: if tracked_count == 0:
return return
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count) 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 is_normal_cycle = now.hour % effective_hours == 0
if is_normal_cycle: if is_normal_cycle:
# Normal scheduled boundary: collect ALL tracked repeaters. # Normal scheduled boundary: collect ALL tracked targets.
await _run_telemetry_cycle() await _run_telemetry_cycle()
elif app_settings.telemetry_routed_hourly: 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) await _run_telemetry_cycle(routed_only=True)
+100
View File
@@ -0,0 +1,100 @@
import json
import logging
import time
from app.database import db
logger = logging.getLogger(__name__)
# Maximum age for telemetry history entries (30 days)
_MAX_AGE_SECONDS = 30 * 86400
# Maximum entries to keep per contact (sanity cap)
_MAX_ENTRIES_PER_CONTACT = 1000
class ContactTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
data: dict,
) -> None:
"""Insert a telemetry history row and prune stale entries."""
cutoff = int(time.time()) - _MAX_AGE_SECONDS
async with db.tx() as conn:
async with conn.execute(
"""
INSERT INTO contact_telemetry_history
(public_key, timestamp, data)
VALUES (?, ?, ?)
""",
(public_key, timestamp, json.dumps(data)),
):
pass
# Prune entries older than 30 days
async with conn.execute(
"DELETE FROM contact_telemetry_history WHERE public_key = ? AND timestamp < ?",
(public_key, cutoff),
):
pass
# Cap at _MAX_ENTRIES_PER_CONTACT (keep newest)
async with conn.execute(
"""
DELETE FROM contact_telemetry_history
WHERE public_key = ? AND id NOT IN (
SELECT id FROM contact_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT ?
)
""",
(public_key, public_key, _MAX_ENTRIES_PER_CONTACT),
):
pass
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a contact since a given timestamp, ordered ASC."""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT timestamp, data
FROM contact_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
) as cursor:
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
for row in rows
]
@staticmethod
async def get_latest(public_key: str) -> dict | None:
"""Return the most recent telemetry row for a contact, or None."""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT timestamp, data
FROM contact_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT 1
""",
(public_key,),
) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return {
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
+19 -1
View File
@@ -41,7 +41,8 @@ class AppSettingsRepository:
last_message_times, last_message_times,
advert_interval, last_advert_time, flood_scope, advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types, 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 telemetry_interval_hours, telemetry_routed_hourly
FROM app_settings WHERE id = 1 FROM app_settings WHERE id = 1
""" """
@@ -97,6 +98,15 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError, KeyError): except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = [] 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 # Parse auto_resend_channel boolean
try: try:
auto_resend_channel = bool(row["auto_resend_channel"]) auto_resend_channel = bool(row["auto_resend_channel"])
@@ -130,6 +140,7 @@ class AppSettingsRepository:
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types, discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters, tracked_telemetry_repeaters=tracked_telemetry_repeaters,
tracked_telemetry_contacts=tracked_telemetry_contacts,
auto_resend_channel=auto_resend_channel, auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours, telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly, telemetry_routed_hourly=telemetry_routed_hourly,
@@ -149,6 +160,7 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None, discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None, tracked_telemetry_repeaters: list[str] | None = None,
tracked_telemetry_contacts: list[str] | None = None,
auto_resend_channel: bool | None = None, auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None, telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None, telemetry_routed_hourly: bool | None = None,
@@ -201,6 +213,10 @@ class AppSettingsRepository:
updates.append("tracked_telemetry_repeaters = ?") updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(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: if auto_resend_channel is not None:
updates.append("auto_resend_channel = ?") updates.append("auto_resend_channel = ?")
params.append(1 if auto_resend_channel else 0) params.append(1 if auto_resend_channel else 0)
@@ -239,6 +255,7 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None, discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None, tracked_telemetry_repeaters: list[str] | None = None,
tracked_telemetry_contacts: list[str] | None = None,
auto_resend_channel: bool | None = None, auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None, telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None, telemetry_routed_hourly: bool | None = None,
@@ -257,6 +274,7 @@ class AppSettingsRepository:
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types, discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters, tracked_telemetry_repeaters=tracked_telemetry_repeaters,
tracked_telemetry_contacts=tracked_telemetry_contacts,
auto_resend_channel=auto_resend_channel, auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours, telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly, telemetry_routed_hourly=telemetry_routed_hourly,
+71
View File
@@ -14,11 +14,14 @@ from app.models import (
ContactAdvertPathSummary, ContactAdvertPathSummary,
ContactAnalytics, ContactAnalytics,
ContactRoutingOverrideRequest, ContactRoutingOverrideRequest,
ContactTelemetryResponse,
ContactUpsert, ContactUpsert,
CreateContactRequest, CreateContactRequest,
LppSensor,
NearestRepeater, NearestRepeater,
PathDiscoveryResponse, PathDiscoveryResponse,
PathDiscoveryRoute, PathDiscoveryRoute,
TelemetryHistoryEntry,
TraceResponse, TraceResponse,
) )
from app.packet_processor import start_historical_dm_decryption 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) await _broadcast_contact_update(updated_contact)
return {"status": "ok", "public_key": contact.public_key} 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]
+113 -3
View File
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
MAX_TRACKED_TELEMETRY_REPEATERS = 8 MAX_TRACKED_TELEMETRY_REPEATERS = 8
MAX_TRACKED_TELEMETRY_CONTACTS = 8
class AppSettingsUpdate(BaseModel): 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] names[k] = contact.name if contact and contact.name else k[:12]
return names return names
n_contacts = len(settings.tracked_telemetry_contacts)
if key in current: if key in current:
# Remove # Remove
new_list = [k for k in current if k != key] 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, tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list), names=await _resolve_names(new_list),
schedule=_build_schedule( schedule=_build_schedule(
len(new_list), len(new_list) + n_contacts,
settings.telemetry_interval_hours, settings.telemetry_interval_hours,
settings.telemetry_routed_hourly, settings.telemetry_routed_hourly,
), ),
@@ -390,7 +393,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
tracked_telemetry_repeaters=new_list, tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list), names=await _resolve_names(new_list),
schedule=_build_schedule( schedule=_build_schedule(
len(new_list), len(new_list) + n_contacts,
settings.telemetry_interval_hours, settings.telemetry_interval_hours,
settings.telemetry_routed_hourly, 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), The UI uses this to render the interval dropdown (legal options),
surface saved-vs-effective when they differ, and show the next-run-at surface saved-vs-effective when they differ, and show the next-run-at
timestamp so users know when the next cycle will fire. 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() app_settings = await AppSettingsRepository.get()
combined_count = len(app_settings.tracked_telemetry_repeaters) + len(
app_settings.tracked_telemetry_contacts
)
return _build_schedule( 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_interval_hours,
app_settings.telemetry_routed_hourly, app_settings.telemetry_routed_hourly,
) )
+2 -1
View File
@@ -361,7 +361,7 @@ Distance/validation helpers used by path + map UI.
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types` - `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters` - `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel` - `auto_resend_channel`
- `telemetry_interval_hours` - `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 - Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops - Info grid: last seen, first heard, last contacted, distance, hops
- GPS location (clickable → map) - 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 - Favorite toggle
- Name history ("Also Known As") — shown only when the contact has used multiple names - Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count - Message stats: DM count, channel message count
+3
View File
@@ -166,6 +166,7 @@ export function App() {
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry, handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
} = useAppSettings(); } = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback // Keep user's name in ref for mention detection in WebSocket callback
@@ -715,6 +716,8 @@ export function App() {
}, },
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [], trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry, onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
}; };
const crackerProps = { const crackerProps = {
packets: rawPackets, packets: rawPackets,
+19
View File
@@ -8,6 +8,7 @@ import type {
Contact, Contact,
ContactAnalytics, ContactAnalytics,
ContactAdvertPathSummary, ContactAdvertPathSummary,
ContactTelemetryResponse,
FanoutConfig, FanoutConfig,
HealthStatus, HealthStatus,
MaintenanceResult, MaintenanceResult,
@@ -35,6 +36,7 @@ import type {
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry, TelemetryHistoryEntry,
TelemetrySchedule, TelemetrySchedule,
TrackedTelemetryContactsResponse,
TrackedTelemetryResponse, TrackedTelemetryResponse,
StatisticsResponse, StatisticsResponse,
TraceResponse, TraceResponse,
@@ -337,6 +339,16 @@ export const api = {
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'), getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
// Tracked contact telemetry
toggleTrackedTelemetryContact: (publicKey: string) =>
fetchJson<TrackedTelemetryContactsResponse>('/settings/tracked-telemetry-contacts/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
getContactTelemetrySchedule: () =>
fetchJson<TelemetrySchedule>('/settings/tracked-telemetry-contacts/schedule'),
// Favorites // Favorites
toggleFavorite: (type: 'channel' | 'contact', id: string) => toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', { fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
@@ -432,6 +444,13 @@ export const api = {
}), }),
repeaterTelemetryHistory: (publicKey: string) => repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`), fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
// Contact telemetry (universal, any contact type)
requestContactTelemetry: (publicKey: string) =>
fetchJson<ContactTelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
}),
contactTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/telemetry-history`),
roomLogin: (publicKey: string, password: string) => roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, { fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST', method: 'POST',
+328 -2
View File
@@ -1,6 +1,8 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react'; import { Activity, Ban, ChevronDown, ChevronRight, Search, Star } from 'lucide-react';
import { import {
AreaChart,
Area,
LineChart, LineChart,
Line, Line,
XAxis, XAxis,
@@ -10,6 +12,8 @@ import {
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
} from 'recharts'; } from 'recharts';
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { api, isAbortError } from '../api'; import { api, isAbortError } from '../api';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
import { import {
@@ -31,6 +35,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash'; import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext'; import { useDistanceUnit } from '../contexts/DistanceUnitContext';
@@ -41,7 +46,10 @@ import type {
ContactAnalytics, ContactAnalytics,
ContactAnalyticsHourlyBucket, ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket, ContactAnalyticsWeeklyBucket,
LppSensor,
RadioConfig, RadioConfig,
TelemetryHistoryEntry,
TelemetryLppSensor,
} from '../types'; } from '../types';
const CONTACT_TYPE_LABELS: Record<number, string> = { const CONTACT_TYPE_LABELS: Record<number, string> = {
@@ -96,6 +104,8 @@ export function ContactInfoPane({
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null); const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
// Get live contact data from contacts array (real-time via WS) // Get live contact data from contacts array (real-time via WS)
const liveContact = const liveContact =
@@ -133,6 +143,41 @@ export function ContactInfoPane({
}; };
}, [contactKey, isNameOnly, nameOnlyValue]); }, [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 // Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null; const contact = liveContact ?? analytics?.contact ?? null;
@@ -371,6 +416,14 @@ export function ContactInfoPane({
</div> </div>
)} )}
{/* Contact Telemetry */}
<ContactTelemetrySection
contact={contact}
loading={telemetryLoading}
onFetch={handleFetchTelemetry}
telemetryHistory={telemetryHistory}
/>
{/* Favorite toggle */} {/* Favorite toggle */}
<div className="px-5 py-3 border-b border-border"> <div className="px-5 py-3 border-b border-border">
<button <button
@@ -909,3 +962,276 @@ function InfoItem({ label, value }: { label: string; value: ReactNode }) {
</div> </div>
); );
} }
// Stable color rotation for dynamic LPP sensors in the history chart
const LPP_CHART_COLORS = ['#22c55e', '#8b5cf6', '#0ea5e9', '#ef4444', '#f59e0b', '#ec4899'];
function ContactTelemetrySection({
contact,
loading,
onFetch,
telemetryHistory,
}: {
contact: Contact;
loading: boolean;
onFetch: () => void;
telemetryHistory: TelemetryHistoryEntry[];
}) {
const { distanceUnit } = useDistanceUnit();
const [expanded, setExpanded] = useState(true);
const [mapExpanded, setMapExpanded] = useState(false);
const [chartExpanded, setChartExpanded] = useState(false);
// Latest telemetry snapshot from history
const latestEntry =
telemetryHistory.length > 0 ? telemetryHistory[telemetryHistory.length - 1] : null;
const sensors: LppSensor[] = useMemo(() => {
if (!latestEntry?.data?.lpp_sensors) return [];
return latestEntry.data.lpp_sensors.map((s: TelemetryLppSensor) => ({
channel: s.channel,
type_name: s.type_name,
value: s.value,
}));
}, [latestEntry]);
const fetchedAt = latestEntry?.timestamp ?? null;
// Extract GPS from sensors
const gpsSensor = sensors.find(
(s) => s.type_name === 'gps' && typeof s.value === 'object' && s.value !== null
);
const gpsValue = gpsSensor?.value as Record<string, number> | undefined;
const hasGps =
gpsValue != null &&
typeof gpsValue.latitude === 'number' &&
typeof gpsValue.longitude === 'number';
// Non-GPS sensors for display
const displaySensors = sensors.filter((s) => s.type_name !== 'gps');
// Build disambiguated labels
const labels = useMemo(() => {
const counts = new Map<string, number>();
return displaySensors.map((s) => {
const base = `${s.type_name}_${s.channel}`;
const n = (counts.get(base) ?? 0) + 1;
counts.set(base, n);
return formatLppLabel(s.type_name) + (n > 1 ? ` (${n})` : '');
});
}, [displaySensors]);
// Discover unique LPP sensor series from history for charting
const sensorSeries = useMemo(() => {
const seen = new Map<string, { type_name: string; channel: number }>();
for (const entry of telemetryHistory) {
for (const s of entry.data?.lpp_sensors ?? []) {
if (typeof s.value !== 'number') continue;
const key = `${s.type_name}_ch${s.channel}`;
if (!seen.has(key)) seen.set(key, { type_name: s.type_name, channel: s.channel });
}
}
return Array.from(seen.entries()).map(([key, info], i) => ({
key,
label: formatLppLabel(info.type_name),
color: LPP_CHART_COLORS[i % LPP_CHART_COLORS.length],
...info,
}));
}, [telemetryHistory]);
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
const activeMetric = selectedMetric ?? (sensorSeries.length > 0 ? sensorSeries[0].key : null);
// Build chart data for selected metric
const chartData = useMemo(() => {
if (!activeMetric) return [];
const series = sensorSeries.find((s) => s.key === activeMetric);
if (!series) return [];
return telemetryHistory
.filter((e) => e.data?.lpp_sensors)
.map((e) => {
const sensor = (e.data.lpp_sensors ?? []).find(
(s: TelemetryLppSensor) =>
s.type_name === series.type_name && s.channel === series.channel
);
return {
time: e.timestamp,
value: sensor && typeof sensor.value === 'number' ? sensor.value : null,
};
})
.filter((d) => d.value !== null);
}, [telemetryHistory, activeMetric, sensorSeries]);
const activeSeries = sensorSeries.find((s) => s.key === activeMetric);
return (
<div className="px-5 py-3 border-b border-border">
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Telemetry
</button>
<button
type="button"
onClick={onFetch}
disabled={loading}
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-accent disabled:opacity-50 transition-colors flex items-center gap-1"
>
<Activity className="h-3 w-3" />
{loading ? 'Fetching...' : 'Request'}
</button>
</div>
{expanded && (
<div className="mt-2">
{sensors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
{fetchedAt ? 'No sensor data in last response' : 'Not yet fetched'}
</p>
) : (
<>
<div className="space-y-0.5">
{displaySensors.map((sensor, i) => (
<LppSensorRow
key={`${sensor.type_name}-${sensor.channel}-${i}`}
sensor={sensor}
unitPref={distanceUnit}
label={labels[i]}
/>
))}
</div>
{hasGps && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setMapExpanded(!mapExpanded)}
>
{mapExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
GPS: {gpsValue!.latitude.toFixed(5)}, {gpsValue!.longitude.toFixed(5)}
</button>
{mapExpanded && (
<div className="mt-1 h-48 rounded border border-border overflow-hidden">
<MapContainer
center={[gpsValue!.latitude, gpsValue!.longitude]}
zoom={13}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<CircleMarker
center={[gpsValue!.latitude, gpsValue!.longitude]}
radius={7}
pathOptions={{
color: '#1d4ed8',
fillColor: '#3b82f6',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<span className="text-sm">
{contact.name ?? contact.public_key.slice(0, 12)}
</span>
</Popup>
</CircleMarker>
</MapContainer>
</div>
)}
</div>
)}
{fetchedAt && (
<p className="text-[0.6875rem] text-muted-foreground mt-1.5">
Fetched {formatTime(fetchedAt)}
</p>
)}
</>
)}
{/* History chart */}
{telemetryHistory.length > 1 && sensorSeries.length > 0 && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setChartExpanded(!chartExpanded)}
>
{chartExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
History ({telemetryHistory.length} samples)
</button>
{chartExpanded && (
<div className="mt-1">
<div className="flex flex-wrap gap-1 mb-2">
{sensorSeries.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSelectedMetric(s.key)}
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded transition-colors ${
activeMetric === s.key
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{s.label}
</button>
))}
</div>
{chartData.length > 1 && activeSeries && (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="time"
tickFormatter={(t: number) => {
const d = new Date(t * 1000);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`;
}}
fontSize={9}
tick={{ fill: 'var(--muted-foreground)' }}
/>
<YAxis fontSize={9} tick={{ fill: 'var(--muted-foreground)' }} width={40} />
<RechartsTooltip
labelFormatter={(t) => new Date(Number(t) * 1000).toLocaleString()}
contentStyle={{
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
fontSize: '0.75rem',
}}
/>
<Area
type="monotone"
dataKey="value"
name={activeSeries.label}
stroke={activeSeries.color}
fill={activeSeries.color}
fillOpacity={0.15}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -54,6 +54,8 @@ interface SettingsModalBaseProps {
onBulkDeleteContacts?: (deletedKeys: string[]) => void; onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[]; trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>; onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
} }
export type SettingsModalProps = SettingsModalBaseProps & export type SettingsModalProps = SettingsModalBaseProps &
@@ -92,6 +94,8 @@ export function SettingsModal(props: SettingsModalProps) {
onBulkDeleteContacts, onBulkDeleteContacts,
trackedTelemetryRepeaters, trackedTelemetryRepeaters,
onToggleTrackedTelemetry, onToggleTrackedTelemetry,
trackedTelemetryContacts,
onToggleTrackedTelemetryContact,
} = props; } = props;
const externalSidebarNav = props.externalSidebarNav === true; const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -257,6 +261,8 @@ export function SettingsModal(props: SettingsModalProps) {
onBulkDeleteContacts={onBulkDeleteContacts} onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters} trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry} onToggleTrackedTelemetry={onToggleTrackedTelemetry}
trackedTelemetryContacts={trackedTelemetryContacts}
onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact}
className={sectionContentClass} className={sectionContentClass}
/> />
) : ( ) : (
@@ -31,6 +31,8 @@ export function SettingsDatabaseSection({
onBulkDeleteContacts, onBulkDeleteContacts,
trackedTelemetryRepeaters = [], trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry, onToggleTrackedTelemetry,
trackedTelemetryContacts = [],
onToggleTrackedTelemetryContact,
className, className,
}: { }: {
appSettings: AppSettings; appSettings: AppSettings;
@@ -45,6 +47,8 @@ export function SettingsDatabaseSection({
onBulkDeleteContacts?: (deletedKeys: string[]) => void; onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[]; trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>; onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
className?: string; className?: string;
}) { }) {
const { distanceUnit } = useDistanceUnit(); const { distanceUnit } = useDistanceUnit();
@@ -60,6 +64,11 @@ export function SettingsDatabaseSection({
>({}); >({});
const telemetryFetchedRef = useRef(false); const telemetryFetchedRef = useRef(false);
const [latestContactTelemetry, setLatestContactTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const contactTelemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null); const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours); const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
@@ -94,6 +103,7 @@ export function SettingsDatabaseSection({
}; };
}, [ }, [
trackedTelemetryRepeaters.length, trackedTelemetryRepeaters.length,
trackedTelemetryContacts.length,
appSettings.telemetry_interval_hours, appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly, appSettings.telemetry_routed_hourly,
]); ]);
@@ -117,6 +127,25 @@ export function SettingsDatabaseSection({
}; };
}, [trackedTelemetryRepeaters]); }, [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 handleCleanup = async () => {
const days = parseInt(retentionDays, 10); const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) { if (isNaN(days) || days < 1) {
@@ -480,6 +509,102 @@ export function SettingsDatabaseSection({
<Separator /> <Separator />
{/* ── Tracked Contact Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Contact Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP
telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily
check ceiling is shared with tracked repeaters adding contacts may clamp the interval
upward.
</p>
{trackedTelemetryContacts.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No contacts are being tracked. Enable tracking from a contact&apos;s info pane.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryContacts.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestContactTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetryContact && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetryContact(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
{d.lpp_sensors?.map((s) => {
if (typeof s.value !== 'number') return null;
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Contact Management ── */} {/* ── Contact Management ── */}
<div className="space-y-5"> <div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3> <h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
+34
View File
@@ -113,6 +113,39 @@ export function useAppSettings() {
} }
}, []); }, []);
const handleToggleTrackedTelemetryContact = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_contacts ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_contacts: optimistic };
});
try {
const result = await api.toggleTrackedTelemetryContact(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_contacts: result.tracked_telemetry_contacts } : prev
);
} catch (err) {
console.error('Failed to toggle tracked contact telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked contact telemetry');
}
}
}, []);
// Legacy favorites migration: if pre-server-side favorites exist in // Legacy favorites migration: if pre-server-side favorites exist in
// localStorage, toggle each one via the existing API and clear the key. // localStorage, toggle each one via the existing API and clear the key.
useEffect(() => { useEffect(() => {
@@ -153,5 +186,6 @@ export function useAppSettings() {
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry, handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
}; };
} }
+13 -1
View File
@@ -4,14 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ContactInfoPane } from '../components/ContactInfoPane'; import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactAnalytics } from '../types'; import type { Contact, ContactAnalytics } from '../types';
const { getContactAnalytics } = vi.hoisted(() => ({ const { getContactAnalytics, contactTelemetryHistory } = vi.hoisted(() => ({
getContactAnalytics: vi.fn(), getContactAnalytics: vi.fn(),
contactTelemetryHistory: vi.fn(),
})); }));
vi.mock('../api', () => ({ vi.mock('../api', () => ({
api: { api: {
getContactAnalytics, getContactAnalytics,
contactTelemetryHistory,
}, },
isAbortError: () => false,
})); }));
vi.mock('../components/ui/sheet', () => ({ vi.mock('../components/ui/sheet', () => ({
@@ -26,6 +29,13 @@ vi.mock('../components/ContactAvatar', () => ({
ContactAvatar: () => <div data-testid="contact-avatar" />, ContactAvatar: () => <div data-testid="contact-avatar" />,
})); }));
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
vi.mock('../components/ui/sonner', () => ({ vi.mock('../components/ui/sonner', () => ({
toast: { toast: {
error: vi.fn(), error: vi.fn(),
@@ -99,6 +109,8 @@ const baseProps = {
describe('ContactInfoPane', () => { describe('ContactInfoPane', () => {
beforeEach(() => { beforeEach(() => {
getContactAnalytics.mockReset(); getContactAnalytics.mockReset();
contactTelemetryHistory.mockReset();
contactTelemetryHistory.mockResolvedValue([]);
baseProps.onSearchMessagesByKey = vi.fn(); baseProps.onSearchMessagesByKey = vi.fn();
baseProps.onSearchMessagesByName = vi.fn(); baseProps.onSearchMessagesByName = vi.fn();
}); });
+2
View File
@@ -109,6 +109,7 @@ beforeEach(() => {
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: [], tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false, auto_resend_channel: false,
telemetry_interval_hours: 8, telemetry_interval_hours: 8,
telemetry_routed_hourly: false, telemetry_routed_hourly: false,
@@ -1049,6 +1050,7 @@ describe('SettingsFanoutSection', () => {
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: ['cc'.repeat(32)], tracked_telemetry_repeaters: ['cc'.repeat(32)],
tracked_telemetry_contacts: [],
auto_resend_channel: false, auto_resend_channel: false,
telemetry_interval_hours: 8, telemetry_interval_hours: 8,
telemetry_routed_hourly: false, telemetry_routed_hourly: false,
+1
View File
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: [], tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false, auto_resend_channel: false,
telemetry_interval_hours: 8, telemetry_interval_hours: 8,
telemetry_routed_hourly: false, telemetry_routed_hourly: false,
+13
View File
@@ -359,6 +359,7 @@ export interface AppSettings {
blocked_names: string[]; blocked_names: string[];
discovery_blocked_types: number[]; discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[]; tracked_telemetry_repeaters: string[];
tracked_telemetry_contacts: string[];
auto_resend_channel: boolean; auto_resend_channel: boolean;
telemetry_interval_hours: number; telemetry_interval_hours: number;
telemetry_routed_hourly: boolean; telemetry_routed_hourly: boolean;
@@ -492,6 +493,18 @@ export interface RepeaterLppTelemetryResponse {
sensors: LppSensor[]; sensors: LppSensor[];
} }
export interface ContactTelemetryResponse {
sensors: LppSensor[];
fetched_at: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface TrackedTelemetryContactsResponse {
tracked_telemetry_contacts: string[];
names: Record<string, string>;
schedule: TelemetrySchedule;
}
export type PaneName = export type PaneName =
| 'status' | 'status'
| 'nodeInfo' | 'nodeInfo'
+2
View File
@@ -30,6 +30,7 @@ async def test_db():
"""Create an in-memory test database with schema + migrations.""" """Create an in-memory test database with schema + migrations."""
from app.repository import ( from app.repository import (
channels, channels,
contact_telemetry,
contacts, contacts,
messages, messages,
raw_packets, raw_packets,
@@ -49,6 +50,7 @@ async def test_db():
settings, settings,
fanout_repo, fanout_repo,
repeater_telemetry, repeater_telemetry,
contact_telemetry,
] ]
originals = [(mod, mod.db) for mod in submodules] originals = [(mod, mod.db) for mod in submodules]
+86
View File
@@ -675,3 +675,89 @@ class TestRoutingOverride:
assert response.status_code == 400 assert response.status_code == 400
assert "same width" in response.json()["detail"].lower() 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