mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-13 21:06:04 +02:00
Initial tracke telemetry for contacts
This commit is contained in:
@@ -350,6 +350,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
|
||||
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
|
||||
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
@@ -380,6 +382,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
||||
| POST | `/api/settings/tracked-telemetry-contacts/toggle` | Toggle tracked LPP telemetry for any contact |
|
||||
| GET | `/api/settings/tracked-telemetry-contacts/schedule` | Contact telemetry scheduling derivation (shared ceiling with repeaters) |
|
||||
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
@@ -508,7 +512,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
|
||||
+8
-2
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -42,7 +44,7 @@ class ContactUpsert(BaseModel):
|
||||
first_seen: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
|
||||
def from_contact(cls, contact: Contact, **changes) -> ContactUpsert:
|
||||
return cls.model_validate(
|
||||
{
|
||||
**contact.model_dump(exclude={"last_read_at"}),
|
||||
@@ -53,7 +55,7 @@ class ContactUpsert(BaseModel):
|
||||
@classmethod
|
||||
def from_radio_dict(
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
) -> ContactUpsert:
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
@@ -541,7 +543,7 @@ class RepeaterStatusResponse(BaseModel):
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
telemetry_history: list[TelemetryHistoryEntry] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
@@ -596,6 +598,16 @@ class RepeaterLppTelemetryResponse(BaseModel):
|
||||
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
|
||||
|
||||
|
||||
class ContactTelemetryResponse(BaseModel):
|
||||
"""On-demand CayenneLPP telemetry snapshot from any contact."""
|
||||
|
||||
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
|
||||
fetched_at: int = Field(description="Unix timestamp when this telemetry was fetched")
|
||||
telemetry_history: list[TelemetryHistoryEntry] = Field(
|
||||
default_factory=list, description="Recent telemetry history entries"
|
||||
)
|
||||
|
||||
|
||||
class NeighborInfo(BaseModel):
|
||||
"""Information about a neighbor seen by a repeater."""
|
||||
|
||||
@@ -847,18 +859,22 @@ class AppSettings(BaseModel):
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
tracked_telemetry_contacts: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of contacts opted into periodic LPP telemetry collection (max 8)",
|
||||
)
|
||||
telemetry_interval_hours: int = Field(
|
||||
default=8,
|
||||
description=(
|
||||
"User-preferred telemetry collection interval in hours. The backend "
|
||||
"clamps this up to the shortest legal interval given the number of "
|
||||
"tracked repeaters so daily checks stay under a 24/day ceiling."
|
||||
"tracked repeaters and contacts so daily checks stay under a 24/day ceiling."
|
||||
),
|
||||
)
|
||||
telemetry_routed_hourly: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When enabled, tracked repeaters with a direct or routed (non-flood) "
|
||||
"When enabled, tracked repeaters/contacts with a direct or routed (non-flood) "
|
||||
"path are polled every hour instead of on the normal scheduled interval."
|
||||
),
|
||||
)
|
||||
|
||||
+114
-15
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
+113
-3
@@ -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,
|
||||
)
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Contact,
|
||||
ContactAnalytics,
|
||||
ContactAdvertPathSummary,
|
||||
ContactTelemetryResponse,
|
||||
FanoutConfig,
|
||||
HealthStatus,
|
||||
MaintenanceResult,
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetrySchedule,
|
||||
TrackedTelemetryContactsResponse,
|
||||
TrackedTelemetryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
@@ -337,6 +339,16 @@ export const api = {
|
||||
|
||||
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
|
||||
|
||||
// Tracked contact telemetry
|
||||
toggleTrackedTelemetryContact: (publicKey: string) =>
|
||||
fetchJson<TrackedTelemetryContactsResponse>('/settings/tracked-telemetry-contacts/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
}),
|
||||
|
||||
getContactTelemetrySchedule: () =>
|
||||
fetchJson<TelemetrySchedule>('/settings/tracked-telemetry-contacts/schedule'),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
|
||||
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
|
||||
@@ -432,6 +444,13 @@ export const api = {
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||
// Contact telemetry (universal, any contact type)
|
||||
requestContactTelemetry: (publicKey: string) =>
|
||||
fetchJson<ContactTelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
contactTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/telemetry-history`),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, Ban, ChevronDown, ChevronRight, Search, Star } from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
@@ -10,6 +12,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { api, isAbortError } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
@@ -31,6 +35,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
@@ -41,7 +46,10 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
LppSensor,
|
||||
RadioConfig,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetryLppSensor,
|
||||
} from '../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
@@ -96,6 +104,8 @@ export function ContactInfoPane({
|
||||
|
||||
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(false);
|
||||
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||
|
||||
// Get live contact data from contacts array (real-time via WS)
|
||||
const liveContact =
|
||||
@@ -133,6 +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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Telemetry */}
|
||||
<ContactTelemetrySection
|
||||
contact={contact}
|
||||
loading={telemetryLoading}
|
||||
onFetch={handleFetchTelemetry}
|
||||
telemetryHistory={telemetryHistory}
|
||||
/>
|
||||
|
||||
{/* Favorite toggle */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
@@ -909,3 +962,276 @@ function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stable color rotation for dynamic LPP sensors in the history chart
|
||||
const LPP_CHART_COLORS = ['#22c55e', '#8b5cf6', '#0ea5e9', '#ef4444', '#f59e0b', '#ec4899'];
|
||||
|
||||
function ContactTelemetrySection({
|
||||
contact,
|
||||
loading,
|
||||
onFetch,
|
||||
telemetryHistory,
|
||||
}: {
|
||||
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='© <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;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -92,6 +94,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts,
|
||||
onToggleTrackedTelemetryContact,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -257,6 +261,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
trackedTelemetryContacts={trackedTelemetryContacts}
|
||||
onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -31,6 +31,8 @@ export function SettingsDatabaseSection({
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts = [],
|
||||
onToggleTrackedTelemetryContact,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -45,6 +47,8 @@ export function SettingsDatabaseSection({
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
@@ -60,6 +64,11 @@ export function SettingsDatabaseSection({
|
||||
>({});
|
||||
const telemetryFetchedRef = useRef(false);
|
||||
|
||||
const [latestContactTelemetry, setLatestContactTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
const contactTelemetryFetchedRef = useRef(false);
|
||||
|
||||
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
|
||||
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
|
||||
|
||||
@@ -94,6 +103,7 @@ export function SettingsDatabaseSection({
|
||||
};
|
||||
}, [
|
||||
trackedTelemetryRepeaters.length,
|
||||
trackedTelemetryContacts.length,
|
||||
appSettings.telemetry_interval_hours,
|
||||
appSettings.telemetry_routed_hourly,
|
||||
]);
|
||||
@@ -117,6 +127,25 @@ export function SettingsDatabaseSection({
|
||||
};
|
||||
}, [trackedTelemetryRepeaters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryContacts.length === 0 || contactTelemetryFetchedRef.current) return;
|
||||
contactTelemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryContacts.map((key) =>
|
||||
api.contactTelemetryHistory(key).then(
|
||||
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
|
||||
() => [key, null] as const
|
||||
)
|
||||
);
|
||||
Promise.all(fetches).then((entries) => {
|
||||
if (cancelled) return;
|
||||
setLatestContactTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trackedTelemetryContacts]);
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const days = parseInt(retentionDays, 10);
|
||||
if (isNaN(days) || days < 1) {
|
||||
@@ -480,6 +509,102 @@ export function SettingsDatabaseSection({
|
||||
|
||||
<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's info pane.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryContacts.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
const displayName = contact?.name ?? key.slice(0, 12);
|
||||
const routeSource = contact?.effective_route_source ?? 'flood';
|
||||
const hasRealPath =
|
||||
contact?.effective_route != null && contact.effective_route.path_len >= 0;
|
||||
const routeLabel = !hasRealPath
|
||||
? 'flood'
|
||||
: routeSource === 'override'
|
||||
? 'routed'
|
||||
: routeSource === 'direct'
|
||||
? 'direct'
|
||||
: 'flood';
|
||||
const routeColor = hasRealPath
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground bg-muted';
|
||||
const snap = latestContactTelemetry[key];
|
||||
const d = snap?.data;
|
||||
return (
|
||||
<div key={key} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
|
||||
>
|
||||
{routeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{onToggleTrackedTelemetryContact && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetryContact(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{d ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
if (typeof s.value !== 'number') return null;
|
||||
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
|
||||
const val =
|
||||
typeof display.value === 'number'
|
||||
? display.value % 1 === 0
|
||||
? display.value
|
||||
: display.value.toFixed(1)
|
||||
: display.value;
|
||||
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
|
||||
return (
|
||||
<span key={`${s.type_name}-${s.channel}`}>
|
||||
{label} {val}
|
||||
{display.unit ? ` ${display.unit}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||
</div>
|
||||
) : snap === null ? (
|
||||
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
|
||||
No telemetry recorded yet
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Contact Management ── */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
|
||||
|
||||
@@ -113,6 +113,39 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTrackedTelemetryContact = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.tracked_telemetry_contacts ?? [];
|
||||
const wasTracked = current.includes(key);
|
||||
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
|
||||
return { ...prev, tracked_telemetry_contacts: optimistic };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.toggleTrackedTelemetryContact(publicKey);
|
||||
setAppSettings((prev) =>
|
||||
prev ? { ...prev, tracked_telemetry_contacts: result.tracked_telemetry_contacts } : prev
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle tracked contact telemetry:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const detail = (err as any)?.body?.detail;
|
||||
if (typeof detail === 'object' && detail?.message) {
|
||||
toast.error(detail.message);
|
||||
} else {
|
||||
toast.error('Failed to update tracked contact telemetry');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Legacy favorites migration: if pre-server-side favorites exist in
|
||||
// localStorage, toggle each one via the existing API and clear the key.
|
||||
useEffect(() => {
|
||||
@@ -153,5 +186,6 @@ export function useAppSettings() {
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { ContactInfoPane } from '../components/ContactInfoPane';
|
||||
import type { Contact, ContactAnalytics } from '../types';
|
||||
|
||||
const { getContactAnalytics } = vi.hoisted(() => ({
|
||||
const { getContactAnalytics, contactTelemetryHistory } = vi.hoisted(() => ({
|
||||
getContactAnalytics: vi.fn(),
|
||||
contactTelemetryHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getContactAnalytics,
|
||||
contactTelemetryHistory,
|
||||
},
|
||||
isAbortError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sheet', () => ({
|
||||
@@ -26,6 +29,13 @@ vi.mock('../components/ContactAvatar', () => ({
|
||||
ContactAvatar: () => <div data-testid="contact-avatar" />,
|
||||
}));
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: () => null,
|
||||
TileLayer: () => null,
|
||||
CircleMarker: () => null,
|
||||
Popup: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
@@ -99,6 +109,8 @@ const baseProps = {
|
||||
describe('ContactInfoPane', () => {
|
||||
beforeEach(() => {
|
||||
getContactAnalytics.mockReset();
|
||||
contactTelemetryHistory.mockReset();
|
||||
contactTelemetryHistory.mockResolvedValue([]);
|
||||
baseProps.onSearchMessagesByKey = vi.fn();
|
||||
baseProps.onSearchMessagesByName = vi.fn();
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ beforeEach(() => {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
@@ -1049,6 +1050,7 @@ describe('SettingsFanoutSection', () => {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: ['cc'.repeat(32)],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
|
||||
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
|
||||
@@ -357,6 +357,7 @@ export interface AppSettings {
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
tracked_telemetry_repeaters: string[];
|
||||
tracked_telemetry_contacts: string[];
|
||||
auto_resend_channel: boolean;
|
||||
telemetry_interval_hours: number;
|
||||
telemetry_routed_hourly: boolean;
|
||||
@@ -490,6 +491,18 @@ export interface RepeaterLppTelemetryResponse {
|
||||
sensors: LppSensor[];
|
||||
}
|
||||
|
||||
export interface ContactTelemetryResponse {
|
||||
sensors: LppSensor[];
|
||||
fetched_at: number;
|
||||
telemetry_history: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface TrackedTelemetryContactsResponse {
|
||||
tracked_telemetry_contacts: string[];
|
||||
names: Record<string, string>;
|
||||
schedule: TelemetrySchedule;
|
||||
}
|
||||
|
||||
export type PaneName =
|
||||
| 'status'
|
||||
| 'nodeInfo'
|
||||
|
||||
@@ -30,6 +30,7 @@ async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
from app.repository import (
|
||||
channels,
|
||||
contact_telemetry,
|
||||
contacts,
|
||||
messages,
|
||||
raw_packets,
|
||||
@@ -49,6 +50,7 @@ async def test_db():
|
||||
settings,
|
||||
fanout_repo,
|
||||
repeater_telemetry,
|
||||
contact_telemetry,
|
||||
]
|
||||
originals = [(mod, mod.db) for mod in submodules]
|
||||
|
||||
|
||||
@@ -675,3 +675,89 @@ class TestRoutingOverride:
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "same width" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestContactTelemetry:
|
||||
"""Tests for on-demand contact telemetry endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_happy_path(self, test_db, client):
|
||||
"""Successful telemetry request returns sensors and persists history."""
|
||||
await _insert_contact(KEY_A, name="Alice")
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=_radio_result())
|
||||
mock_mc.commands.req_telemetry_sync = AsyncMock(
|
||||
return_value=[
|
||||
{"channel": 1, "type": "voltage", "value": 3.7},
|
||||
{"channel": 1, "type": "temperature", "value": 22.5},
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event"),
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.require_connected = MagicMock()
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["sensors"]) == 2
|
||||
assert data["sensors"][0]["type_name"] == "voltage"
|
||||
assert data["sensors"][0]["value"] == 3.7
|
||||
assert data["fetched_at"] > 0
|
||||
assert len(data["telemetry_history"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_timeout_returns_504(self, test_db, client):
|
||||
"""No response from contact returns 504."""
|
||||
await _insert_contact(KEY_A)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=_radio_result())
|
||||
mock_mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.require_connected = MagicMock()
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
|
||||
|
||||
assert response.status_code == 504
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_history_endpoint(self, test_db, client):
|
||||
"""History endpoint returns stored telemetry snapshots."""
|
||||
import time
|
||||
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
|
||||
await _insert_contact(KEY_A)
|
||||
now = int(time.time())
|
||||
await ContactTelemetryRepository.record(
|
||||
KEY_A, now, {"lpp_sensors": [{"channel": 1, "type_name": "voltage", "value": 3.6}]}
|
||||
)
|
||||
|
||||
response = await client.get(f"/api/contacts/{KEY_A}/telemetry-history")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["data"]["lpp_sensors"][0]["value"] == 3.6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_contact_not_found(self, test_db, client):
|
||||
"""Telemetry for non-existent contact returns 404."""
|
||||
with patch("app.routers.contacts.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.require_connected = MagicMock()
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user