mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 18:53:03 +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/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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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='© <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'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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user