mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 04:16:05 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2de946318c | |||
| 26983667bd | |||
| 72efe214e9 | |||
| 8aac6a9771 | |||
| d019ab4ee1 | |||
| 53f701938b |
@@ -350,6 +350,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
|
||||
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
|
||||
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
@@ -380,6 +382,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
||||
| POST | `/api/settings/tracked-telemetry-contacts/toggle` | Toggle tracked LPP telemetry for any contact |
|
||||
| GET | `/api/settings/tracked-telemetry-contacts/schedule` | Contact telemetry scheduling derivation (shared ceiling with repeaters) |
|
||||
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
@@ -508,7 +512,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
|
||||
+8
-2
@@ -169,7 +169,8 @@ app/
|
||||
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message`, `raw_packet`, and `contact` events.
|
||||
- `on_message` and `on_raw` are scope-gated. `on_contact`, `on_telemetry`, and `on_health` are dispatched to all modules unconditionally (modules filter internally).
|
||||
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch).
|
||||
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch). Contact LPP telemetry is similarly recorded to `ContactTelemetryRepository` and dispatched to fanout.
|
||||
- The telemetry collection loop in `radio_sync.py` is unified: it iterates over both `tracked_telemetry_repeaters` and `tracked_telemetry_contacts`, dispatching to `_collect_repeater_telemetry` (type 2) or `_collect_contact_telemetry` (others). The daily check ceiling uses the combined count.
|
||||
- The 60-second radio stats sampling loop in `radio_stats.py` dispatches an enriched health snapshot (radio identity + full stats) to all fanout modules after each sample.
|
||||
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
|
||||
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
|
||||
@@ -227,6 +228,8 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
|
||||
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
|
||||
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
|
||||
- `POST /contacts/{public_key}/room/login`
|
||||
- `POST /contacts/{public_key}/room/status`
|
||||
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||
@@ -267,6 +270,8 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
||||
- `POST /settings/blocked-names/toggle`
|
||||
- `POST /settings/tracked-telemetry/toggle`
|
||||
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
||||
- `POST /settings/tracked-telemetry-contacts/toggle` — toggle tracked LPP telemetry for any contact (max 8)
|
||||
- `GET /settings/tracked-telemetry-contacts/schedule` — contact telemetry scheduling (shared ceiling with repeaters)
|
||||
- `POST /settings/muted-channels/toggle`
|
||||
|
||||
### Fanout
|
||||
@@ -320,6 +325,7 @@ Main tables:
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
|
||||
- `contact_telemetry_history` (time-series LPP telemetry snapshots for tracked contacts; same schema as repeater table)
|
||||
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
|
||||
- `push_subscriptions` (Web Push browser subscriptions with delivery metadata; UNIQUE on endpoint)
|
||||
- `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing)
|
||||
@@ -343,7 +349,7 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
- `tracked_telemetry_repeaters`
|
||||
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
|
||||
- `auto_resend_channel`
|
||||
- `telemetry_interval_hours`
|
||||
|
||||
|
||||
+31
-2
@@ -576,12 +576,30 @@ class MqttHaModule(FanoutModule):
|
||||
)
|
||||
)
|
||||
|
||||
# Tracked contacts — resolve names from DB best-effort
|
||||
# Tracked contacts — resolve names and LPP sensors from DB best-effort
|
||||
for pub_key in self._tracked_contacts:
|
||||
cname = await self._resolve_contact_name(pub_key)
|
||||
configs.append(
|
||||
_contact_tracker_discovery_config(self._prefix, pub_key, cname, self._radio_key)
|
||||
)
|
||||
# LPP sensor entities for contacts with telemetry history
|
||||
latest_ct = await self._resolve_latest_contact_telemetry(pub_key)
|
||||
latest_ct_data = latest_ct.get("data", {}) if latest_ct else {}
|
||||
ct_lpp_sensors = latest_ct_data.get("lpp_sensors", [])
|
||||
if ct_lpp_sensors:
|
||||
ct_nid = _node_id(pub_key)
|
||||
ct_device = _device_payload(pub_key, cname, "Node", via_device_key=self._radio_key)
|
||||
ct_state_topic = f"{self._prefix}/{ct_nid}/telemetry"
|
||||
configs.extend(
|
||||
_lpp_discovery_configs(
|
||||
self._prefix, pub_key, ct_device, ct_lpp_sensors, ct_state_topic
|
||||
)
|
||||
)
|
||||
if latest_ct_data:
|
||||
ct_payload = _repeater_telemetry_payload(latest_ct_data)
|
||||
cached_repeater_states.append(
|
||||
(f"{self._prefix}/{_node_id(pub_key)}/telemetry", ct_payload)
|
||||
)
|
||||
|
||||
# Message event entity (namespaced to this radio)
|
||||
configs.append(_message_event_discovery_config(self._prefix, self._radio_key, radio_name))
|
||||
@@ -644,6 +662,17 @@ class MqttHaModule(FanoutModule):
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_latest_contact_telemetry(pub_key: str) -> dict | None:
|
||||
"""Return the most recent contact telemetry row, or None."""
|
||||
try:
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
|
||||
return await ContactTelemetryRepository.get_latest(pub_key)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _seed_radio_identity_from_runtime(self) -> None:
|
||||
"""Best-effort bootstrap from the currently connected radio session."""
|
||||
try:
|
||||
@@ -749,7 +778,7 @@ class MqttHaModule(FanoutModule):
|
||||
return
|
||||
|
||||
pub_key = data.get("public_key", "")
|
||||
if pub_key not in self._tracked_repeaters:
|
||||
if pub_key not in self._tracked_repeaters and pub_key not in self._tracked_contacts:
|
||||
return
|
||||
|
||||
nid = _node_id(pub_key)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
"""Create contact_telemetry_history table and tracked_telemetry_contacts setting."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||
|
||||
if "contact_telemetry_history" not in tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact_telemetry_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_telemetry_pk_ts
|
||||
ON contact_telemetry_history(public_key, timestamp)
|
||||
"""
|
||||
)
|
||||
|
||||
if "app_settings" in tables:
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "tracked_telemetry_contacts" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_contacts TEXT DEFAULT '[]'"
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
+21
-5
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -42,7 +44,7 @@ class ContactUpsert(BaseModel):
|
||||
first_seen: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
|
||||
def from_contact(cls, contact: Contact, **changes) -> ContactUpsert:
|
||||
return cls.model_validate(
|
||||
{
|
||||
**contact.model_dump(exclude={"last_read_at"}),
|
||||
@@ -53,7 +55,7 @@ class ContactUpsert(BaseModel):
|
||||
@classmethod
|
||||
def from_radio_dict(
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
) -> ContactUpsert:
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
@@ -541,7 +543,7 @@ class RepeaterStatusResponse(BaseModel):
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
telemetry_history: list[TelemetryHistoryEntry] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
@@ -596,6 +598,16 @@ class RepeaterLppTelemetryResponse(BaseModel):
|
||||
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
|
||||
|
||||
|
||||
class ContactTelemetryResponse(BaseModel):
|
||||
"""On-demand CayenneLPP telemetry snapshot from any contact."""
|
||||
|
||||
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
|
||||
fetched_at: int = Field(description="Unix timestamp when this telemetry was fetched")
|
||||
telemetry_history: list[TelemetryHistoryEntry] = Field(
|
||||
default_factory=list, description="Recent telemetry history entries"
|
||||
)
|
||||
|
||||
|
||||
class NeighborInfo(BaseModel):
|
||||
"""Information about a neighbor seen by a repeater."""
|
||||
|
||||
@@ -847,18 +859,22 @@ class AppSettings(BaseModel):
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
tracked_telemetry_contacts: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of contacts opted into periodic LPP telemetry collection (max 8)",
|
||||
)
|
||||
telemetry_interval_hours: int = Field(
|
||||
default=8,
|
||||
description=(
|
||||
"User-preferred telemetry collection interval in hours. The backend "
|
||||
"clamps this up to the shortest legal interval given the number of "
|
||||
"tracked repeaters so daily checks stay under a 24/day ceiling."
|
||||
"tracked repeaters and contacts so daily checks stay under a 24/day ceiling."
|
||||
),
|
||||
)
|
||||
telemetry_routed_hourly: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When enabled, tracked repeaters with a direct or routed (non-flood) "
|
||||
"When enabled, tracked repeaters/contacts with a direct or routed (non-flood) "
|
||||
"path are polled every hour instead of on the normal scheduled interval."
|
||||
),
|
||||
)
|
||||
|
||||
+114
-15
@@ -31,6 +31,7 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
RepeaterTelemetryRepository,
|
||||
)
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
@@ -1890,10 +1891,87 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
|
||||
"""Collect one telemetry sample from tracked repeaters.
|
||||
async def _collect_contact_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"""Fetch LPP telemetry from a non-repeater contact and record it.
|
||||
|
||||
When *routed_only* is True, only repeaters whose effective route is
|
||||
Unlike repeaters, companions/rooms/sensors only respond to
|
||||
req_telemetry_sync (LPP), not req_status_sync (repeater status struct).
|
||||
All sensor values including multi-value (GPS, accel) are stored.
|
||||
|
||||
Returns True on success, False on failure (logged, not raised).
|
||||
"""
|
||||
try:
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
lpp_raw = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Contact telemetry collect: radio command failed for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
if lpp_raw is None:
|
||||
logger.debug("Contact telemetry collect: no response from %s", contact.public_key[:12])
|
||||
return False
|
||||
|
||||
lpp_sensors = []
|
||||
for entry in lpp_raw:
|
||||
lpp_sensors.append(
|
||||
{
|
||||
"channel": entry.get("channel", 0),
|
||||
"type_name": str(entry.get("type", "unknown")),
|
||||
"value": entry.get("value", 0),
|
||||
}
|
||||
)
|
||||
|
||||
data: dict = {}
|
||||
if lpp_sensors:
|
||||
data["lpp_sensors"] = lpp_sensors
|
||||
|
||||
try:
|
||||
timestamp = int(time.time())
|
||||
await ContactTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=timestamp,
|
||||
data=data,
|
||||
)
|
||||
logger.info(
|
||||
"Contact telemetry collect: recorded snapshot for %s (%s)",
|
||||
contact.name or contact.public_key[:12],
|
||||
contact.public_key[:12],
|
||||
)
|
||||
|
||||
# Dispatch to fanout modules
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
asyncio.create_task(
|
||||
fanout_manager.broadcast_telemetry(
|
||||
{
|
||||
"public_key": contact.public_key,
|
||||
"name": contact.name or contact.public_key[:12],
|
||||
"timestamp": timestamp,
|
||||
**data,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Contact telemetry collect: failed to record for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
|
||||
"""Collect one telemetry sample from tracked repeaters and contacts.
|
||||
|
||||
When *routed_only* is True, only targets whose effective route is
|
||||
``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected.
|
||||
This is used by the hourly routed-path fast-poll feature.
|
||||
"""
|
||||
@@ -1902,12 +1980,14 @@ async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
|
||||
return
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
tracked = app_settings.tracked_telemetry_repeaters
|
||||
if not tracked:
|
||||
tracked_repeaters = app_settings.tracked_telemetry_repeaters
|
||||
tracked_contacts = app_settings.tracked_telemetry_contacts
|
||||
if not tracked_repeaters and not tracked_contacts:
|
||||
return
|
||||
|
||||
candidates: list[tuple[str, Contact]] = []
|
||||
for pub_key in tracked:
|
||||
# Build repeater candidates
|
||||
candidates: list[tuple[str, Contact, bool]] = [] # (key, contact, is_repeater)
|
||||
for pub_key in tracked_repeaters:
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
if not contact or contact.type != 2:
|
||||
logger.debug(
|
||||
@@ -1917,29 +1997,46 @@ async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
|
||||
continue
|
||||
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
|
||||
continue
|
||||
candidates.append((pub_key, contact))
|
||||
candidates.append((pub_key, contact, True))
|
||||
|
||||
# Build contact (non-repeater) candidates
|
||||
for pub_key in tracked_contacts:
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
if not contact:
|
||||
logger.debug(
|
||||
"Telemetry collect: skipping contact %s (not found)",
|
||||
pub_key[:12],
|
||||
)
|
||||
continue
|
||||
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
|
||||
continue
|
||||
candidates.append((pub_key, contact, False))
|
||||
|
||||
if not candidates:
|
||||
if routed_only:
|
||||
logger.debug("Telemetry collect: no routed repeaters to poll this hour")
|
||||
logger.debug("Telemetry collect: no routed targets to poll this hour")
|
||||
return
|
||||
|
||||
label = "routed" if routed_only else "full"
|
||||
logger.info(
|
||||
"Telemetry collect: starting %s cycle for %d repeater(s)",
|
||||
"Telemetry collect: starting %s cycle for %d target(s)",
|
||||
label,
|
||||
len(candidates),
|
||||
)
|
||||
collected = 0
|
||||
|
||||
for _pub_key, contact in candidates:
|
||||
for _pub_key, contact, is_repeater in candidates:
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"telemetry_collect",
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
if await _collect_repeater_telemetry(mc, contact):
|
||||
if is_repeater:
|
||||
success = await _collect_repeater_telemetry(mc, contact)
|
||||
else:
|
||||
success = await _collect_contact_telemetry(mc, contact)
|
||||
if success:
|
||||
collected += 1
|
||||
except RadioOperationBusyError:
|
||||
logger.debug(
|
||||
@@ -1975,7 +2072,9 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
|
||||
telemetry).
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
tracked_count = len(app_settings.tracked_telemetry_repeaters)
|
||||
tracked_count = len(app_settings.tracked_telemetry_repeaters) + len(
|
||||
app_settings.tracked_telemetry_contacts
|
||||
)
|
||||
if tracked_count == 0:
|
||||
return
|
||||
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count)
|
||||
@@ -1985,10 +2084,10 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
|
||||
is_normal_cycle = now.hour % effective_hours == 0
|
||||
|
||||
if is_normal_cycle:
|
||||
# Normal scheduled boundary: collect ALL tracked repeaters.
|
||||
# Normal scheduled boundary: collect ALL tracked targets.
|
||||
await _run_telemetry_cycle()
|
||||
elif app_settings.telemetry_routed_hourly:
|
||||
# Hourly routed-path fast-poll: only repeaters with a non-flood route.
|
||||
# Hourly routed-path fast-poll: only targets with a non-flood route.
|
||||
await _run_telemetry_cycle(routed_only=True)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum age for telemetry history entries (30 days)
|
||||
_MAX_AGE_SECONDS = 30 * 86400
|
||||
|
||||
# Maximum entries to keep per contact (sanity cap)
|
||||
_MAX_ENTRIES_PER_CONTACT = 1000
|
||||
|
||||
|
||||
class ContactTelemetryRepository:
|
||||
@staticmethod
|
||||
async def record(
|
||||
public_key: str,
|
||||
timestamp: int,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Insert a telemetry history row and prune stale entries."""
|
||||
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||
async with db.tx() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_telemetry_history
|
||||
(public_key, timestamp, data)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(public_key, timestamp, json.dumps(data)),
|
||||
):
|
||||
pass
|
||||
|
||||
# Prune entries older than 30 days
|
||||
async with conn.execute(
|
||||
"DELETE FROM contact_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||
(public_key, cutoff),
|
||||
):
|
||||
pass
|
||||
|
||||
# Cap at _MAX_ENTRIES_PER_CONTACT (keep newest)
|
||||
async with conn.execute(
|
||||
"""
|
||||
DELETE FROM contact_telemetry_history
|
||||
WHERE public_key = ? AND id NOT IN (
|
||||
SELECT id FROM contact_telemetry_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(public_key, public_key, _MAX_ENTRIES_PER_CONTACT),
|
||||
):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||
"""Return telemetry rows for a contact since a given timestamp, ordered ASC."""
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT timestamp, data
|
||||
FROM contact_telemetry_history
|
||||
WHERE public_key = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
""",
|
||||
(public_key, since_timestamp),
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"timestamp": row["timestamp"],
|
||||
"data": json.loads(row["data"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_latest(public_key: str) -> dict | None:
|
||||
"""Return the most recent telemetry row for a contact, or None."""
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT timestamp, data
|
||||
FROM contact_telemetry_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(public_key,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"timestamp": row["timestamp"],
|
||||
"data": json.loads(row["data"]),
|
||||
}
|
||||
@@ -41,7 +41,8 @@ class AppSettingsRepository:
|
||||
last_message_times,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
tracked_telemetry_repeaters, auto_resend_channel,
|
||||
tracked_telemetry_repeaters, tracked_telemetry_contacts,
|
||||
auto_resend_channel,
|
||||
telemetry_interval_hours, telemetry_routed_hourly
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
@@ -97,6 +98,15 @@ class AppSettingsRepository:
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
tracked_telemetry_repeaters = []
|
||||
|
||||
# Parse tracked_telemetry_contacts JSON
|
||||
tracked_telemetry_contacts: list[str] = []
|
||||
try:
|
||||
raw_tracked_contacts = row["tracked_telemetry_contacts"]
|
||||
if raw_tracked_contacts:
|
||||
tracked_telemetry_contacts = json.loads(raw_tracked_contacts)
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
tracked_telemetry_contacts = []
|
||||
|
||||
# Parse auto_resend_channel boolean
|
||||
try:
|
||||
auto_resend_channel = bool(row["auto_resend_channel"])
|
||||
@@ -130,6 +140,7 @@ class AppSettingsRepository:
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
tracked_telemetry_contacts=tracked_telemetry_contacts,
|
||||
auto_resend_channel=auto_resend_channel,
|
||||
telemetry_interval_hours=telemetry_interval_hours,
|
||||
telemetry_routed_hourly=telemetry_routed_hourly,
|
||||
@@ -149,6 +160,7 @@ class AppSettingsRepository:
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
tracked_telemetry_contacts: list[str] | None = None,
|
||||
auto_resend_channel: bool | None = None,
|
||||
telemetry_interval_hours: int | None = None,
|
||||
telemetry_routed_hourly: bool | None = None,
|
||||
@@ -201,6 +213,10 @@ class AppSettingsRepository:
|
||||
updates.append("tracked_telemetry_repeaters = ?")
|
||||
params.append(json.dumps(tracked_telemetry_repeaters))
|
||||
|
||||
if tracked_telemetry_contacts is not None:
|
||||
updates.append("tracked_telemetry_contacts = ?")
|
||||
params.append(json.dumps(tracked_telemetry_contacts))
|
||||
|
||||
if auto_resend_channel is not None:
|
||||
updates.append("auto_resend_channel = ?")
|
||||
params.append(1 if auto_resend_channel else 0)
|
||||
@@ -239,6 +255,7 @@ class AppSettingsRepository:
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
tracked_telemetry_contacts: list[str] | None = None,
|
||||
auto_resend_channel: bool | None = None,
|
||||
telemetry_interval_hours: int | None = None,
|
||||
telemetry_routed_hourly: bool | None = None,
|
||||
@@ -257,6 +274,7 @@ class AppSettingsRepository:
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
tracked_telemetry_contacts=tracked_telemetry_contacts,
|
||||
auto_resend_channel=auto_resend_channel,
|
||||
telemetry_interval_hours=telemetry_interval_hours,
|
||||
telemetry_routed_hourly=telemetry_routed_hourly,
|
||||
|
||||
@@ -14,11 +14,14 @@ from app.models import (
|
||||
ContactAdvertPathSummary,
|
||||
ContactAnalytics,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactTelemetryResponse,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
LppSensor,
|
||||
NearestRepeater,
|
||||
PathDiscoveryResponse,
|
||||
PathDiscoveryRoute,
|
||||
TelemetryHistoryEntry,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
@@ -613,3 +616,85 @@ async def set_contact_routing_override(
|
||||
await _broadcast_contact_update(updated_contact)
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-demand contact telemetry (CayenneLPP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{public_key}/telemetry", response_model=ContactTelemetryResponse)
|
||||
async def request_contact_telemetry(public_key: str) -> ContactTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout).
|
||||
|
||||
Persists the result in contact_telemetry_history and returns the latest
|
||||
sensor readings along with recent telemetry history.
|
||||
"""
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"contact_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from contact")
|
||||
|
||||
sensors: list[LppSensor] = []
|
||||
for entry in telemetry:
|
||||
channel = entry.get("channel", 0)
|
||||
type_name = str(entry.get("type", "unknown"))
|
||||
value = entry.get("value", 0)
|
||||
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
|
||||
|
||||
fetched_at = int(time.time())
|
||||
|
||||
# Persist snapshot
|
||||
data = {"lpp_sensors": [s.model_dump() for s in sensors]}
|
||||
await ContactTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=fetched_at,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Dispatch to fanout modules (e.g. HA MQTT)
|
||||
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": fetched_at,
|
||||
**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]
|
||||
|
||||
@@ -101,6 +101,18 @@ class RadioConfigResponse(BaseModel):
|
||||
default=False,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
telemetry_mode_base: int = Field(
|
||||
default=0,
|
||||
description="Base telemetry sharing mode (0=deny, 1=per-contact, 2=allow-all)",
|
||||
)
|
||||
telemetry_mode_loc: int = Field(
|
||||
default=0,
|
||||
description="Location telemetry sharing mode (0=deny, 1=per-contact, 2=allow-all)",
|
||||
)
|
||||
telemetry_mode_env: int = Field(
|
||||
default=0,
|
||||
description="Environment sensor sharing mode (0=deny, 1=per-contact, 2=allow-all)",
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -123,6 +135,15 @@ class RadioConfigUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
telemetry_mode_base: int | None = Field(
|
||||
default=None, ge=0, le=2, description="Base telemetry sharing mode"
|
||||
)
|
||||
telemetry_mode_loc: int | None = Field(
|
||||
default=None, ge=0, le=2, description="Location telemetry sharing mode"
|
||||
)
|
||||
telemetry_mode_env: int | None = Field(
|
||||
default=None, ge=0, le=2, description="Environment sensor sharing mode"
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
@@ -360,6 +381,9 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
advert_location_source=advert_location_source,
|
||||
multi_acks_enabled=bool(info.get("multi_acks", 0)),
|
||||
telemetry_mode_base=info.get("telemetry_mode_base", 0),
|
||||
telemetry_mode_loc=info.get("telemetry_mode_loc", 0),
|
||||
telemetry_mode_env=info.get("telemetry_mode_env", 0),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+118
-3
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
MAX_TRACKED_TELEMETRY_CONTACTS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
@@ -350,6 +351,8 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
n_contacts = len(settings.tracked_telemetry_contacts)
|
||||
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
@@ -359,7 +362,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
len(new_list),
|
||||
len(new_list) + n_contacts,
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
@@ -390,7 +393,7 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
len(new_list),
|
||||
len(new_list) + n_contacts,
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
@@ -404,10 +407,122 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
|
||||
The UI uses this to render the interval dropdown (legal options),
|
||||
surface saved-vs-effective when they differ, and show the next-run-at
|
||||
timestamp so users know when the next cycle will fire.
|
||||
|
||||
The tracked count includes both repeaters and contacts for ceiling
|
||||
enforcement.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
combined_count = len(app_settings.tracked_telemetry_repeaters) + len(
|
||||
app_settings.tracked_telemetry_contacts
|
||||
)
|
||||
return _build_schedule(
|
||||
len(app_settings.tracked_telemetry_repeaters),
|
||||
combined_count,
|
||||
app_settings.telemetry_interval_hours,
|
||||
app_settings.telemetry_routed_hourly,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracked contact telemetry (non-repeater LPP telemetry collection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TrackedTelemetryContactsResponse(BaseModel):
|
||||
tracked_telemetry_contacts: list[str] = Field(
|
||||
description="Current list of tracked contact public keys"
|
||||
)
|
||||
names: dict[str, str] = Field(
|
||||
description="Map of public key to display name for tracked contacts"
|
||||
)
|
||||
schedule: TelemetrySchedule = Field(description="Current scheduling state")
|
||||
|
||||
|
||||
@router.post("/tracked-telemetry-contacts/toggle", response_model=TrackedTelemetryContactsResponse)
|
||||
async def toggle_tracked_telemetry_contact(
|
||||
request: TrackedTelemetryRequest,
|
||||
) -> TrackedTelemetryContactsResponse:
|
||||
"""Toggle periodic LPP telemetry collection for any contact.
|
||||
|
||||
Max 8 contacts may be tracked. The daily check ceiling is shared with
|
||||
tracked repeaters.
|
||||
"""
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_contacts
|
||||
|
||||
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for k in keys:
|
||||
contact = await ContactRepository.get_by_key(k)
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
def combined_count(lst: list[str]) -> int:
|
||||
return len(settings.tracked_telemetry_repeaters) + len(lst)
|
||||
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing contact %s from tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
|
||||
return TrackedTelemetryContactsResponse(
|
||||
tracked_telemetry_contacts=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
combined_count(new_list),
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
)
|
||||
|
||||
# Validate contact exists and is not a repeater (repeaters use tracked_telemetry_repeaters)
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Repeaters use the dedicated repeater telemetry tracking list",
|
||||
)
|
||||
|
||||
if len(current) >= MAX_TRACKED_TELEMETRY_CONTACTS:
|
||||
names = await _resolve_names(current)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Limit of {MAX_TRACKED_TELEMETRY_CONTACTS} tracked contacts reached",
|
||||
"tracked_telemetry_contacts": current,
|
||||
"names": names,
|
||||
},
|
||||
)
|
||||
|
||||
new_list = current + [key]
|
||||
logger.info("Adding contact %s to tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
|
||||
return TrackedTelemetryContactsResponse(
|
||||
tracked_telemetry_contacts=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(
|
||||
combined_count(new_list),
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tracked-telemetry-contacts/schedule", response_model=TelemetrySchedule)
|
||||
async def get_contact_telemetry_schedule() -> TelemetrySchedule:
|
||||
"""Return the current telemetry scheduling derivation for contacts.
|
||||
|
||||
Uses the combined tracked count (repeaters + contacts) for ceiling
|
||||
enforcement since they share one collection loop.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
combined_count = len(app_settings.tracked_telemetry_repeaters) + len(
|
||||
app_settings.tracked_telemetry_contacts
|
||||
)
|
||||
return _build_schedule(
|
||||
combined_count,
|
||||
app_settings.telemetry_interval_hours,
|
||||
app_settings.telemetry_routed_hourly,
|
||||
)
|
||||
|
||||
@@ -51,6 +51,30 @@ async def apply_radio_config_update(
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
|
||||
|
||||
if update.telemetry_mode_base is not None:
|
||||
logger.info("Setting telemetry_mode_base to %d", update.telemetry_mode_base)
|
||||
result = await mc.commands.set_telemetry_mode_base(update.telemetry_mode_base)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set telemetry mode (base): {result.payload}"
|
||||
)
|
||||
|
||||
if update.telemetry_mode_loc is not None:
|
||||
logger.info("Setting telemetry_mode_loc to %d", update.telemetry_mode_loc)
|
||||
result = await mc.commands.set_telemetry_mode_loc(update.telemetry_mode_loc)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set telemetry mode (location): {result.payload}"
|
||||
)
|
||||
|
||||
if update.telemetry_mode_env is not None:
|
||||
logger.info("Setting telemetry_mode_env to %d", update.telemetry_mode_env)
|
||||
result = await mc.commands.set_telemetry_mode_env(update.telemetry_mode_env)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set telemetry mode (environment): {result.payload}"
|
||||
)
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
+5
-3
@@ -141,7 +141,8 @@ frontend/src/
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists
|
||||
│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ ├── ThemeSelector.tsx # Color theme picker
|
||||
@@ -323,7 +324,7 @@ Supported routes:
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
|
||||
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||
Where `{section}` is one of `radio`, `local`, `radio-app`, `database`, `fanout`, `statistics`, or `about`.
|
||||
|
||||
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||
|
||||
@@ -361,7 +362,7 @@ Distance/validation helpers used by path + map UI.
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
- `tracked_telemetry_repeaters`
|
||||
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
|
||||
- `auto_resend_channel`
|
||||
- `telemetry_interval_hours`
|
||||
|
||||
@@ -382,6 +383,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
- Header: avatar, name, public key, type badge, on-radio badge
|
||||
- Info grid: last seen, first heard, last contacted, distance, hops
|
||||
- GPS location (clickable → map)
|
||||
- On-demand LPP telemetry: "Request" button fetches `POST /contacts/{key}/telemetry`, displays sensor readings via `LppSensorRow`, optional GPS mini-map (Leaflet), and history chart (Recharts). Opt-in tracking toggle uses `POST /settings/tracked-telemetry-contacts/toggle`.
|
||||
- Favorite toggle
|
||||
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
||||
- Message stats: DM count, channel message count
|
||||
|
||||
@@ -166,6 +166,7 @@ export function App() {
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
handleToggleTrackedTelemetryContact,
|
||||
} = useAppSettings();
|
||||
|
||||
// Keep user's name in ref for mention detection in WebSocket callback
|
||||
@@ -715,6 +716,8 @@ export function App() {
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
|
||||
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
@@ -748,6 +751,8 @@ export function App() {
|
||||
onToggleBlockedName: handleBlockName,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
|
||||
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
const channelInfoPaneProps = {
|
||||
channelKey: infoPaneChannelKey,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Contact,
|
||||
ContactAnalytics,
|
||||
ContactAdvertPathSummary,
|
||||
ContactTelemetryResponse,
|
||||
FanoutConfig,
|
||||
HealthStatus,
|
||||
MaintenanceResult,
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetrySchedule,
|
||||
TrackedTelemetryContactsResponse,
|
||||
TrackedTelemetryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
@@ -337,6 +339,16 @@ export const api = {
|
||||
|
||||
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
|
||||
|
||||
// Tracked contact telemetry
|
||||
toggleTrackedTelemetryContact: (publicKey: string) =>
|
||||
fetchJson<TrackedTelemetryContactsResponse>('/settings/tracked-telemetry-contacts/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
}),
|
||||
|
||||
getContactTelemetrySchedule: () =>
|
||||
fetchJson<TelemetrySchedule>('/settings/tracked-telemetry-contacts/schedule'),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
|
||||
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
|
||||
@@ -432,6 +444,13 @@ export const api = {
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||
// Contact telemetry (universal, any contact type)
|
||||
requestContactTelemetry: (publicKey: string) =>
|
||||
fetchJson<ContactTelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
contactTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/telemetry-history`),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, Ban, ChevronDown, ChevronRight, Search, Star } from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
@@ -10,6 +12,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { api, isAbortError } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
@@ -31,6 +35,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
@@ -41,7 +46,10 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
LppSensor,
|
||||
RadioConfig,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetryLppSensor,
|
||||
} from '../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
@@ -73,6 +81,8 @@ interface ContactInfoPaneProps {
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ContactInfoPane({
|
||||
@@ -89,6 +99,8 @@ export function ContactInfoPane({
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
trackedTelemetryContacts = [],
|
||||
onToggleTrackedTelemetryContact,
|
||||
}: ContactInfoPaneProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
@@ -96,6 +108,8 @@ export function ContactInfoPane({
|
||||
|
||||
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(false);
|
||||
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||
|
||||
// Get live contact data from contacts array (real-time via WS)
|
||||
const liveContact =
|
||||
@@ -133,6 +147,41 @@ export function ContactInfoPane({
|
||||
};
|
||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||
|
||||
// Load telemetry history when pane opens for a contact
|
||||
useEffect(() => {
|
||||
if (!contactKey || isNameOnly) {
|
||||
setTelemetryHistory([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.contactTelemetryHistory(contactKey)
|
||||
.then((data) => {
|
||||
if (!cancelled) setTelemetryHistory(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setTelemetryHistory([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contactKey, isNameOnly]);
|
||||
|
||||
const handleFetchTelemetry = useCallback(async () => {
|
||||
if (!contactKey || isNameOnly) return;
|
||||
setTelemetryLoading(true);
|
||||
try {
|
||||
const result = await api.requestContactTelemetry(contactKey);
|
||||
setTelemetryHistory(result.telemetry_history);
|
||||
} catch (err) {
|
||||
if (!isAbortError(err)) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to fetch telemetry');
|
||||
}
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
}
|
||||
}, [contactKey, isNameOnly]);
|
||||
|
||||
// Use live contact data where available, fall back to analytics snapshot
|
||||
const contact = liveContact ?? analytics?.contact ?? null;
|
||||
|
||||
@@ -371,6 +420,16 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Telemetry */}
|
||||
<ContactTelemetrySection
|
||||
contact={contact}
|
||||
loading={telemetryLoading}
|
||||
onFetch={handleFetchTelemetry}
|
||||
telemetryHistory={telemetryHistory}
|
||||
isTracked={trackedTelemetryContacts.includes(contact.public_key)}
|
||||
onToggleTracked={onToggleTrackedTelemetryContact}
|
||||
/>
|
||||
|
||||
{/* Favorite toggle */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
@@ -909,3 +968,310 @@ function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stable color rotation for dynamic LPP sensors in the history chart
|
||||
const LPP_CHART_COLORS = ['#22c55e', '#8b5cf6', '#0ea5e9', '#ef4444', '#f59e0b', '#ec4899'];
|
||||
|
||||
function ContactTelemetrySection({
|
||||
contact,
|
||||
loading,
|
||||
onFetch,
|
||||
telemetryHistory,
|
||||
isTracked,
|
||||
onToggleTracked,
|
||||
}: {
|
||||
contact: Contact;
|
||||
loading: boolean;
|
||||
onFetch: () => void;
|
||||
telemetryHistory: TelemetryHistoryEntry[];
|
||||
isTracked: boolean;
|
||||
onToggleTracked?: (publicKey: string) => Promise<void>;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [mapExpanded, setMapExpanded] = useState(false);
|
||||
const [chartExpanded, setChartExpanded] = useState(false);
|
||||
const [toggling, setToggling] = 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>
|
||||
)}
|
||||
|
||||
{/* Tracking toggle */}
|
||||
{onToggleTracked && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
disabled={toggling}
|
||||
onClick={async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await onToggleTracked(contact.public_key);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
}}
|
||||
className={`text-xs px-2 py-1 rounded border transition-colors w-full ${
|
||||
isTracked
|
||||
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
|
||||
: 'border-green-600/50 text-green-600 hover:bg-green-600/10'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{toggling
|
||||
? 'Updating...'
|
||||
: isTracked
|
||||
? 'Stop Tracking Telemetry'
|
||||
: 'Track Telemetry on Interval'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { SettingsRadioSection } from './settings/SettingsRadioSection';
|
||||
import { SettingsLocalSection } from './settings/SettingsLocalSection';
|
||||
import { SettingsRadioAppSection } from './settings/SettingsRadioAppSection';
|
||||
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
|
||||
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
|
||||
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
|
||||
@@ -54,6 +55,8 @@ interface SettingsModalBaseProps {
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -92,6 +95,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts,
|
||||
onToggleTrackedTelemetryContact,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -106,6 +111,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
|
||||
radio: false,
|
||||
local: false,
|
||||
'radio-app': false,
|
||||
fanout: false,
|
||||
database: false,
|
||||
statistics: false,
|
||||
@@ -239,6 +245,36 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('radio-app') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('radio-app')}
|
||||
{isSectionVisible('radio-app') &&
|
||||
(appSettings ? (
|
||||
<SettingsRadioAppSection
|
||||
appSettings={appSettings}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
trackedTelemetryContacts={trackedTelemetryContacts}
|
||||
onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
<div className={sectionContentClass}>
|
||||
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
Loading app settings...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('database') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
@@ -249,14 +285,6 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
health={health}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -247,10 +247,10 @@ export function TelemetryHistoryPane({
|
||||
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||
repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
|
||||
<a
|
||||
href="#settings/database"
|
||||
href="#settings/radio-app"
|
||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Settings → Database & Messaging
|
||||
Settings → Radio-App Management
|
||||
</a>
|
||||
, where you can also see which repeaters are currently opted in. A maximum of{' '}
|
||||
{MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
|
||||
|
||||
@@ -6,117 +6,32 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetrySchedule,
|
||||
} from '../../types';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
health,
|
||||
onSaveAppSettings,
|
||||
onHealthRefresh,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
health: HealthStatus | null;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
const [latestTelemetry, setLatestTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
const telemetryFetchedRef = useRef(false);
|
||||
|
||||
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
|
||||
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
|
||||
|
||||
// Serialization chain for every auto-persisted control on this page.
|
||||
// Without this, rapid successive toggles (or mixed dropdown + checkbox
|
||||
// interactions) can dispatch overlapping PATCHes that land out of order
|
||||
// on HTTP/2 — a stale write then wins, reverting the user's last click.
|
||||
// Each call awaits the previous one before sending its request, so the
|
||||
// server sees updates in the order the user made them.
|
||||
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
setIntervalDraft(appSettings.telemetry_interval_hours);
|
||||
}, [appSettings]);
|
||||
|
||||
// Re-fetch the scheduler derivation whenever the tracked list changes or
|
||||
// the stored preference changes. Cheap: single GET, no radio lock.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getTelemetrySchedule()
|
||||
.then((s) => {
|
||||
if (!cancelled) setSchedule(s);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-critical: dropdown falls back to the unfiltered menu.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
trackedTelemetryRepeaters.length,
|
||||
appSettings.telemetry_interval_hours,
|
||||
appSettings.telemetry_routed_hourly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||
telemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryRepeaters.map((key) =>
|
||||
api.repeaterTelemetryHistory(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;
|
||||
setLatestTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trackedTelemetryRepeaters]);
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const days = parseInt(retentionDays, 10);
|
||||
if (isNaN(days) || days < 1) {
|
||||
@@ -163,12 +78,6 @@ export function SettingsDatabaseSection({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply an AppSettings PATCH after any already-queued saves finish, and
|
||||
* revert local state if the save fails. Every auto-persist control on
|
||||
* this page routes through here so the user-visible order of clicks is
|
||||
* the order the backend sees, regardless of network reordering.
|
||||
*/
|
||||
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
|
||||
const chained = saveChainRef.current.then(async () => {
|
||||
try {
|
||||
@@ -295,330 +204,6 @@ export function SettingsDatabaseSection({
|
||||
contact sends an advertisement. This may cause brief delays on large packet backlogs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
|
||||
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
|
||||
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
|
||||
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
|
||||
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
|
||||
</p>
|
||||
|
||||
{/* Interval picker. Legal options depend on current tracked count;
|
||||
we list only those. If the saved preference is no longer legal,
|
||||
the effective interval is shown below so the user knows what the
|
||||
scheduler is actually using. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-interval" className="text-sm">
|
||||
Collection interval
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="telemetry-interval"
|
||||
value={intervalDraft}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
|
||||
const prevValue = intervalDraft;
|
||||
setIntervalDraft(nextValue);
|
||||
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
|
||||
setIntervalDraft(prevValue)
|
||||
);
|
||||
}}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
|
||||
<option key={hrs} value={hrs}>
|
||||
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
|
||||
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
|
||||
<p className="text-xs text-warning">
|
||||
Saved preference is {schedule.preferred_hours} hour
|
||||
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
|
||||
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
|
||||
{schedule.tracked_count === 1 ? '' : 's'}{' '}
|
||||
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
|
||||
restored if you drop back to a supported count.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routed hourly toggle */}
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appSettings.telemetry_routed_hourly}
|
||||
onChange={() => {
|
||||
const next = !appSettings.telemetry_routed_hourly;
|
||||
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
|
||||
every hour instead of on the scheduled interval above. Flood-only repeaters still
|
||||
follow the normal schedule.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
|
||||
{formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
{schedule?.next_routed_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryRepeaters.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';
|
||||
// A forced-flood override (path_len < 0) still reports source
|
||||
// "override", but the actual route is flood. Check the real path.
|
||||
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 = latestTelemetry[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>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(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">
|
||||
<span>{d.battery_volts?.toFixed(2)}V</span>
|
||||
<span>noise {d.noise_floor_dbm} dBm</span>
|
||||
<span>
|
||||
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
|
||||
const val =
|
||||
typeof display.value === 'number'
|
||||
? display.value % 1 === 0
|
||||
? display.value
|
||||
: display.value.toFixed(1)
|
||||
: display.value;
|
||||
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
|
||||
return (
|
||||
<span key={`${s.type_name}-${s.channel}`}>
|
||||
{label} {val}
|
||||
{display.unit ? ` ${display.unit}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||
</div>
|
||||
) : snap === null ? (
|
||||
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
|
||||
No telemetry recorded yet
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Contact Management ── */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
|
||||
|
||||
{/* Block discovery of new node types */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const prev = discoveryBlockedTypes;
|
||||
const next = checked
|
||||
? prev.filter((t) => t !== typeCode)
|
||||
: [...prev, typeCode];
|
||||
setDiscoveryBlockedTypes(next);
|
||||
void persistAppSettings({ discovery_blocked_types: next }, () =>
|
||||
setDiscoveryBlockedTypes(prev)
|
||||
);
|
||||
}}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocked contacts list */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk delete */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
TelemetryHistoryEntry,
|
||||
TelemetrySchedule,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsRadioAppSection({
|
||||
appSettings,
|
||||
onSaveAppSettings,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
trackedTelemetryContacts = [],
|
||||
onToggleTrackedTelemetryContact,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
trackedTelemetryContacts?: string[];
|
||||
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
const [latestTelemetry, setLatestTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
const telemetryFetchedRef = useRef(false);
|
||||
|
||||
const [latestContactTelemetry, setLatestContactTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
const contactTelemetryFetchedRef = useRef(false);
|
||||
|
||||
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
|
||||
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
|
||||
|
||||
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
useEffect(() => {
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
setIntervalDraft(appSettings.telemetry_interval_hours);
|
||||
}, [appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getTelemetrySchedule()
|
||||
.then((s) => {
|
||||
if (!cancelled) setSchedule(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
trackedTelemetryRepeaters.length,
|
||||
trackedTelemetryContacts.length,
|
||||
appSettings.telemetry_interval_hours,
|
||||
appSettings.telemetry_routed_hourly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||
telemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryRepeaters.map((key) =>
|
||||
api.repeaterTelemetryHistory(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;
|
||||
setLatestTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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 persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
|
||||
const chained = saveChainRef.current.then(async () => {
|
||||
try {
|
||||
await onSaveAppSettings(update);
|
||||
} catch (err) {
|
||||
console.error('Failed to save radio-app settings:', err);
|
||||
revert();
|
||||
toast.error('Failed to save setting', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
saveChainRef.current = chained;
|
||||
return chained;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
|
||||
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
|
||||
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
|
||||
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
|
||||
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-interval" className="text-sm">
|
||||
Collection interval
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="telemetry-interval"
|
||||
value={intervalDraft}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
|
||||
const prevValue = intervalDraft;
|
||||
setIntervalDraft(nextValue);
|
||||
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
|
||||
setIntervalDraft(prevValue)
|
||||
);
|
||||
}}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
|
||||
<option key={hrs} value={hrs}>
|
||||
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
|
||||
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
|
||||
<p className="text-xs text-warning">
|
||||
Saved preference is {schedule.preferred_hours} hour
|
||||
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
|
||||
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
|
||||
{schedule.tracked_count === 1 ? '' : 's'}{' '}
|
||||
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
|
||||
restored if you drop back to a supported count.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appSettings.telemetry_routed_hourly}
|
||||
onChange={() => {
|
||||
const next = !appSettings.telemetry_routed_hourly;
|
||||
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
|
||||
every hour instead of on the scheduled interval above. Flood-only repeaters still
|
||||
follow the normal schedule.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
|
||||
{formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
{schedule?.next_routed_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryRepeaters.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 = latestTelemetry[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>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(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">
|
||||
<span>{d.battery_volts?.toFixed(2)}V</span>
|
||||
<span>noise {d.noise_floor_dbm} dBm</span>
|
||||
<span>
|
||||
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
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 />
|
||||
|
||||
{/* ── Tracked Contact Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold tracking-tight">Tracked Contact Telemetry</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP
|
||||
telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily
|
||||
check ceiling is shared with tracked repeaters — adding contacts may clamp the interval
|
||||
upward.
|
||||
</p>
|
||||
|
||||
{trackedTelemetryContacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No contacts are being tracked. Enable tracking from a contact's info pane.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryContacts.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
const displayName = contact?.name ?? key.slice(0, 12);
|
||||
const routeSource = contact?.effective_route_source ?? 'flood';
|
||||
const hasRealPath =
|
||||
contact?.effective_route != null && contact.effective_route.path_len >= 0;
|
||||
const routeLabel = !hasRealPath
|
||||
? 'flood'
|
||||
: routeSource === 'override'
|
||||
? 'routed'
|
||||
: routeSource === 'direct'
|
||||
? 'direct'
|
||||
: 'flood';
|
||||
const routeColor = hasRealPath
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground bg-muted';
|
||||
const snap = latestContactTelemetry[key];
|
||||
const d = snap?.data;
|
||||
return (
|
||||
<div key={key} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
|
||||
>
|
||||
{routeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{onToggleTrackedTelemetryContact && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetryContact(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{d ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
if (typeof s.value !== 'number') return null;
|
||||
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
|
||||
const val =
|
||||
typeof display.value === 'number'
|
||||
? display.value % 1 === 0
|
||||
? display.value
|
||||
: display.value.toFixed(1)
|
||||
: display.value;
|
||||
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
|
||||
return (
|
||||
<span key={`${s.type_name}-${s.channel}`}>
|
||||
{label} {val}
|
||||
{display.unit ? ` ${display.unit}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||
</div>
|
||||
) : snap === null ? (
|
||||
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
|
||||
No telemetry recorded yet
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Contact Management ── */}
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const prev = discoveryBlockedTypes;
|
||||
const next = checked
|
||||
? prev.filter((t) => t !== typeCode)
|
||||
: [...prev, typeCode];
|
||||
setDiscoveryBlockedTypes(next);
|
||||
void persistAppSettings({ discovery_blocked_types: next }, () =>
|
||||
setDiscoveryBlockedTypes(prev)
|
||||
);
|
||||
}}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,9 @@ export function SettingsRadioSection({
|
||||
const [pathHashMode, setPathHashMode] = useState('0');
|
||||
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
|
||||
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
|
||||
const [telemetryModeBase, setTelemetryModeBase] = useState(0);
|
||||
const [telemetryModeLoc, setTelemetryModeLoc] = useState(0);
|
||||
const [telemetryModeEnv, setTelemetryModeEnv] = useState(0);
|
||||
const [gettingLocation, setGettingLocation] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
@@ -218,6 +221,9 @@ export function SettingsRadioSection({
|
||||
setPathHashMode(String(config.path_hash_mode));
|
||||
setAdvertLocationSource(config.advert_location_source ?? 'current');
|
||||
setMultiAcksEnabled(config.multi_acks_enabled ?? false);
|
||||
setTelemetryModeBase(config.telemetry_mode_base ?? 0);
|
||||
setTelemetryModeLoc(config.telemetry_mode_loc ?? 0);
|
||||
setTelemetryModeEnv(config.telemetry_mode_env ?? 0);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -313,6 +319,15 @@ export function SettingsRadioSection({
|
||||
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
|
||||
? { multi_acks_enabled: multiAcksEnabled }
|
||||
: {}),
|
||||
...(telemetryModeBase !== (config.telemetry_mode_base ?? 0)
|
||||
? { telemetry_mode_base: telemetryModeBase }
|
||||
: {}),
|
||||
...(telemetryModeLoc !== (config.telemetry_mode_loc ?? 0)
|
||||
? { telemetry_mode_loc: telemetryModeLoc }
|
||||
: {}),
|
||||
...(telemetryModeEnv !== (config.telemetry_mode_env ?? 0)
|
||||
? { telemetry_mode_env: telemetryModeEnv }
|
||||
: {}),
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
@@ -468,6 +483,9 @@ export function SettingsRadioSection({
|
||||
path_hash_mode: config.path_hash_mode,
|
||||
advert_location_source: config.advert_location_source ?? 'current',
|
||||
multi_acks_enabled: config.multi_acks_enabled ?? false,
|
||||
telemetry_mode_base: config.telemetry_mode_base ?? 0,
|
||||
telemetry_mode_loc: config.telemetry_mode_loc ?? 0,
|
||||
telemetry_mode_env: config.telemetry_mode_env ?? 0,
|
||||
});
|
||||
|
||||
const downloadJson = (profile: object, suffix: string) => {
|
||||
@@ -539,6 +557,10 @@ export function SettingsRadioSection({
|
||||
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||
setAdvertLocationSource(data.advert_location_source);
|
||||
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
|
||||
if (typeof data.telemetry_mode_base === 'number')
|
||||
setTelemetryModeBase(data.telemetry_mode_base);
|
||||
if (typeof data.telemetry_mode_loc === 'number') setTelemetryModeLoc(data.telemetry_mode_loc);
|
||||
if (typeof data.telemetry_mode_env === 'number') setTelemetryModeEnv(data.telemetry_mode_env);
|
||||
};
|
||||
|
||||
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
|
||||
@@ -554,6 +576,12 @@ export function SettingsRadioSection({
|
||||
update.advert_location_source = data.advert_location_source;
|
||||
if (typeof data.multi_acks_enabled === 'boolean')
|
||||
update.multi_acks_enabled = data.multi_acks_enabled;
|
||||
if (typeof data.telemetry_mode_base === 'number')
|
||||
update.telemetry_mode_base = data.telemetry_mode_base as number;
|
||||
if (typeof data.telemetry_mode_loc === 'number')
|
||||
update.telemetry_mode_loc = data.telemetry_mode_loc as number;
|
||||
if (typeof data.telemetry_mode_env === 'number')
|
||||
update.telemetry_mode_env = data.telemetry_mode_env as number;
|
||||
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
|
||||
update.path_hash_mode = data.path_hash_mode as number;
|
||||
return update;
|
||||
@@ -954,6 +982,66 @@ export function SettingsRadioSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Telemetry Sharing ── */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold tracking-tight">Telemetry Sharing</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Controls what this radio shares when other nodes request its telemetry. “Deny”
|
||||
blocks all requests, “Per-Contact” uses per-contact permission flags on the
|
||||
radio, and “Allow All” shares with any requester.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-mode-base" className="text-sm">
|
||||
Battery & Base
|
||||
</Label>
|
||||
<select
|
||||
id="telemetry-mode-base"
|
||||
value={telemetryModeBase}
|
||||
onChange={(e) => setTelemetryModeBase(Number(e.target.value))}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value={0}>Deny</option>
|
||||
<option value={1}>Per-Contact</option>
|
||||
<option value={2}>Allow All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-mode-loc" className="text-sm">
|
||||
Location
|
||||
</Label>
|
||||
<select
|
||||
id="telemetry-mode-loc"
|
||||
value={telemetryModeLoc}
|
||||
onChange={(e) => setTelemetryModeLoc(Number(e.target.value))}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value={0}>Deny</option>
|
||||
<option value={1}>Per-Contact</option>
|
||||
<option value={2}>Allow All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telemetry-mode-env" className="text-sm">
|
||||
Environment Sensors
|
||||
</Label>
|
||||
<select
|
||||
id="telemetry-mode-env"
|
||||
value={telemetryModeEnv}
|
||||
onChange={(e) => setTelemetryModeEnv(Number(e.target.value))}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value={0}>Deny</option>
|
||||
<option value={1}>Per-Contact</option>
|
||||
<option value={2}>Allow All</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -5,16 +5,25 @@ import {
|
||||
MonitorCog,
|
||||
RadioTower,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
|
||||
export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'local'
|
||||
| 'radio-app'
|
||||
| 'database'
|
||||
| 'fanout'
|
||||
| 'statistics'
|
||||
| 'about';
|
||||
|
||||
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'database',
|
||||
'fanout',
|
||||
'radio-app',
|
||||
'database',
|
||||
'statistics',
|
||||
'about',
|
||||
];
|
||||
@@ -22,7 +31,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: 'Radio',
|
||||
local: 'Local Configuration',
|
||||
database: 'Database & Messaging',
|
||||
'radio-app': 'Radio-App Management',
|
||||
database: 'Database',
|
||||
fanout: 'MQTT & Automation',
|
||||
statistics: 'Statistics',
|
||||
about: 'About',
|
||||
@@ -31,6 +41,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = {
|
||||
radio: RadioTower,
|
||||
local: MonitorCog,
|
||||
'radio-app': SlidersHorizontal,
|
||||
database: Database,
|
||||
fanout: Share2,
|
||||
statistics: BarChart3,
|
||||
|
||||
@@ -113,6 +113,39 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTrackedTelemetryContact = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const current = prev.tracked_telemetry_contacts ?? [];
|
||||
const wasTracked = current.includes(key);
|
||||
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
|
||||
return { ...prev, tracked_telemetry_contacts: optimistic };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.toggleTrackedTelemetryContact(publicKey);
|
||||
setAppSettings((prev) =>
|
||||
prev ? { ...prev, tracked_telemetry_contacts: result.tracked_telemetry_contacts } : prev
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle tracked contact telemetry:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const detail = (err as any)?.body?.detail;
|
||||
if (typeof detail === 'object' && detail?.message) {
|
||||
toast.error(detail.message);
|
||||
} else {
|
||||
toast.error('Failed to update tracked contact telemetry');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Legacy favorites migration: if pre-server-side favorites exist in
|
||||
// localStorage, toggle each one via the existing API and clear the key.
|
||||
useEffect(() => {
|
||||
@@ -153,5 +186,6 @@ export function useAppSettings() {
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,11 +149,12 @@ vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
|
||||
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
|
||||
),
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
|
||||
SETTINGS_SECTION_LABELS: {
|
||||
radio: '📻 Radio',
|
||||
local: '🖥️ Local Configuration',
|
||||
database: '🗄️ Database & Messaging',
|
||||
'radio-app': '🗄️ Radio-App Management',
|
||||
database: '🗄️ Database',
|
||||
bot: '🤖 Bot',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -92,11 +92,12 @@ vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
|
||||
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
|
||||
),
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
|
||||
SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
|
||||
SETTINGS_SECTION_LABELS: {
|
||||
radio: 'Radio',
|
||||
local: 'Local Configuration',
|
||||
database: 'Database & Messaging',
|
||||
'radio-app': 'Radio-App Management',
|
||||
database: 'Database',
|
||||
bot: 'Bot',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,14 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { ContactInfoPane } from '../components/ContactInfoPane';
|
||||
import type { Contact, ContactAnalytics } from '../types';
|
||||
|
||||
const { getContactAnalytics } = vi.hoisted(() => ({
|
||||
const { getContactAnalytics, contactTelemetryHistory } = vi.hoisted(() => ({
|
||||
getContactAnalytics: vi.fn(),
|
||||
contactTelemetryHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getContactAnalytics,
|
||||
contactTelemetryHistory,
|
||||
},
|
||||
isAbortError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sheet', () => ({
|
||||
@@ -26,6 +29,13 @@ vi.mock('../components/ContactAvatar', () => ({
|
||||
ContactAvatar: () => <div data-testid="contact-avatar" />,
|
||||
}));
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: () => null,
|
||||
TileLayer: () => null,
|
||||
CircleMarker: () => null,
|
||||
Popup: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
@@ -99,6 +109,8 @@ const baseProps = {
|
||||
describe('ContactInfoPane', () => {
|
||||
beforeEach(() => {
|
||||
getContactAnalytics.mockReset();
|
||||
contactTelemetryHistory.mockReset();
|
||||
contactTelemetryHistory.mockResolvedValue([]);
|
||||
baseProps.onSearchMessagesByKey = vi.fn();
|
||||
baseProps.onSearchMessagesByName = vi.fn();
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ beforeEach(() => {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
@@ -1049,6 +1050,7 @@ describe('SettingsFanoutSection', () => {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: ['cc'.repeat(32)],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
|
||||
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
@@ -177,7 +178,7 @@ function setMatchMedia(matches: boolean) {
|
||||
}
|
||||
|
||||
function openRadioSection() {
|
||||
const radioToggle = screen.getByRole('button', { name: /Radio/i });
|
||||
const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
|
||||
fireEvent.click(radioToggle);
|
||||
}
|
||||
|
||||
@@ -250,7 +251,7 @@ describe('SettingsModal', () => {
|
||||
it('shows radio-unavailable message when config is null', () => {
|
||||
renderModal({ config: null });
|
||||
|
||||
const radioToggle = screen.getByRole('button', { name: /Radio/i });
|
||||
const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
|
||||
expect(radioToggle).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(radioToggle);
|
||||
@@ -499,7 +500,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
@@ -806,7 +807,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
@@ -831,7 +832,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
tracked_telemetry_repeaters: [directKey],
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface RadioConfig {
|
||||
path_hash_mode_supported: boolean;
|
||||
advert_location_source?: 'off' | 'current';
|
||||
multi_acks_enabled?: boolean;
|
||||
telemetry_mode_base?: number;
|
||||
telemetry_mode_loc?: number;
|
||||
telemetry_mode_env?: number;
|
||||
}
|
||||
|
||||
export interface RadioConfigUpdate {
|
||||
@@ -28,6 +31,9 @@ export interface RadioConfigUpdate {
|
||||
path_hash_mode?: number;
|
||||
advert_location_source?: 'off' | 'current';
|
||||
multi_acks_enabled?: boolean;
|
||||
telemetry_mode_base?: number;
|
||||
telemetry_mode_loc?: number;
|
||||
telemetry_mode_env?: number;
|
||||
}
|
||||
|
||||
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
|
||||
@@ -357,6 +363,7 @@ export interface AppSettings {
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
tracked_telemetry_repeaters: string[];
|
||||
tracked_telemetry_contacts: string[];
|
||||
auto_resend_channel: boolean;
|
||||
telemetry_interval_hours: number;
|
||||
telemetry_routed_hourly: boolean;
|
||||
@@ -490,6 +497,18 @@ export interface RepeaterLppTelemetryResponse {
|
||||
sensors: LppSensor[];
|
||||
}
|
||||
|
||||
export interface ContactTelemetryResponse {
|
||||
sensors: LppSensor[];
|
||||
fetched_at: number;
|
||||
telemetry_history: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface TrackedTelemetryContactsResponse {
|
||||
tracked_telemetry_contacts: string[];
|
||||
names: Record<string, string>;
|
||||
schedule: TelemetrySchedule;
|
||||
}
|
||||
|
||||
export type PaneName =
|
||||
| 'status'
|
||||
| 'nodeInfo'
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ParsedHashConversation {
|
||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'radio-app',
|
||||
'fanout',
|
||||
'database',
|
||||
'statistics',
|
||||
|
||||
@@ -30,6 +30,7 @@ async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
from app.repository import (
|
||||
channels,
|
||||
contact_telemetry,
|
||||
contacts,
|
||||
messages,
|
||||
raw_packets,
|
||||
@@ -49,6 +50,7 @@ async def test_db():
|
||||
settings,
|
||||
fanout_repo,
|
||||
repeater_telemetry,
|
||||
contact_telemetry,
|
||||
]
|
||||
originals = [(mod, mod.db) for mod in submodules]
|
||||
|
||||
|
||||
@@ -675,3 +675,89 @@ class TestRoutingOverride:
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "same width" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestContactTelemetry:
|
||||
"""Tests for on-demand contact telemetry endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_happy_path(self, test_db, client):
|
||||
"""Successful telemetry request returns sensors and persists history."""
|
||||
await _insert_contact(KEY_A, name="Alice")
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=_radio_result())
|
||||
mock_mc.commands.req_telemetry_sync = AsyncMock(
|
||||
return_value=[
|
||||
{"channel": 1, "type": "voltage", "value": 3.7},
|
||||
{"channel": 1, "type": "temperature", "value": 22.5},
|
||||
]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event"),
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.require_connected = MagicMock()
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["sensors"]) == 2
|
||||
assert data["sensors"][0]["type_name"] == "voltage"
|
||||
assert data["sensors"][0]["value"] == 3.7
|
||||
assert data["fetched_at"] > 0
|
||||
assert len(data["telemetry_history"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_timeout_returns_504(self, test_db, client):
|
||||
"""No response from contact returns 504."""
|
||||
await _insert_contact(KEY_A)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=_radio_result())
|
||||
mock_mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.require_connected = MagicMock()
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
|
||||
|
||||
assert response.status_code == 504
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_history_endpoint(self, test_db, client):
|
||||
"""History endpoint returns stored telemetry snapshots."""
|
||||
import time
|
||||
|
||||
from app.repository.contact_telemetry import ContactTelemetryRepository
|
||||
|
||||
await _insert_contact(KEY_A)
|
||||
now = int(time.time())
|
||||
await ContactTelemetryRepository.record(
|
||||
KEY_A, now, {"lpp_sensors": [{"channel": 1, "type_name": "voltage", "value": 3.6}]}
|
||||
)
|
||||
|
||||
response = await client.get(f"/api/contacts/{KEY_A}/telemetry-history")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["data"]["lpp_sensors"][0]["value"] == 3.6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_contact_not_found(self, test_db, client):
|
||||
"""Telemetry for non-existent contact returns 404."""
|
||||
with patch("app.routers.contacts.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.require_connected = MagicMock()
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/telemetry")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||
# change, not every individual assertion.
|
||||
LATEST_SCHEMA_VERSION = 61
|
||||
LATEST_SCHEMA_VERSION = 62
|
||||
|
||||
Reference in New Issue
Block a user