mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 20:36:05 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2de946318c | |||
| 26983667bd | |||
| 72efe214e9 | |||
| 8aac6a9771 | |||
| d019ab4ee1 | |||
| 53f701938b | |||
| 2c1279eb9e | |||
| 047d713003 | |||
| 25041e1367 | |||
| b3fe717416 | |||
| 9a4e78c504 | |||
| d436de67a2 | |||
| 89cee49725 | |||
| b37ce89c96 | |||
| f0b7842c60 | |||
| 82a6553539 |
@@ -22,7 +22,6 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
|
||||
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||
- `app/tcp_proxy/AGENTS_tcp_proxy.md` - TCP companion protocol proxy (emulates a MeshCore radio for remote clients)
|
||||
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
|
||||
## Architecture Overview
|
||||
@@ -351,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 |
|
||||
@@ -381,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,11 +511,8 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the MeshCore TCP companion protocol proxy (see `app/tcp_proxy/AGENTS_tcp_proxy.md`) |
|
||||
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the TCP proxy server |
|
||||
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the TCP proxy server |
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -39,30 +39,6 @@ Import via `PUT /api/radio/private-key` is always available regardless of this s
|
||||
|
||||
The Radio Settings config export/import feature uses these endpoints. When export is disabled, config exports will omit the private key and show a notice.
|
||||
|
||||
## MeshCore TCP Proxy
|
||||
|
||||
RemoteTerm can emulate a MeshCore companion radio over TCP, allowing MeshCore clients (mobile apps, meshcore-cli, meshcore-ha) to connect to it as if it were a directly-connected radio.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the TCP companion protocol proxy |
|
||||
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the proxy TCP server |
|
||||
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the proxy TCP server |
|
||||
|
||||
Once enabled, MeshCore clients can connect:
|
||||
|
||||
```bash
|
||||
meshcore-cli --tcp <host>:5001
|
||||
```
|
||||
|
||||
**How it works:** The proxy translates the MeshCore companion binary protocol into in-process RemoteTerm operations. Contacts, channels, and messages come from the RemoteTerm database. Outgoing messages are sent through RemoteTerm's send orchestration (with radio lock, retries, and ACK tracking). Incoming messages are pushed to connected clients in real time.
|
||||
|
||||
**Limitations:**
|
||||
- Only favorite contacts are synced to clients
|
||||
- Only favorite channels are pre-loaded into slots; clients can load additional channels via SET_CHANNEL (local to the proxy session, does not modify RemoteTerm channel configuration)
|
||||
- DMs receive an immediate synthetic ACK; actual delivery retries are handled server-side by RemoteTerm
|
||||
- Radio configuration changes (SET_NAME, SET_LATLON) are applied to the real radio
|
||||
|
||||
## Contact Loading Issues
|
||||
|
||||
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
|
||||
|
||||
+9
-8
@@ -55,7 +55,6 @@ app/
|
||||
│ ├── send.py # pywebpush wrapper (async via thread executor)
|
||||
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── tcp_proxy/ # MeshCore TCP companion protocol proxy (see tcp_proxy/AGENTS_tcp_proxy.md)
|
||||
├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||
@@ -170,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.
|
||||
@@ -228,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`
|
||||
@@ -268,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
|
||||
@@ -321,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)
|
||||
@@ -344,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`
|
||||
|
||||
@@ -427,11 +432,7 @@ tests/
|
||||
├── test_telemetry_interval.py # Telemetry interval scheduling math
|
||||
├── test_version_info.py # Version/build metadata resolution
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
├── test_websocket_route.py # WS endpoint lifecycle
|
||||
├── test_tcp_proxy_protocol.py # TCP proxy frame parsing and helpers
|
||||
├── test_tcp_proxy_encoder.py # TCP proxy binary encoding
|
||||
├── test_tcp_proxy_session.py # TCP proxy session command handlers
|
||||
└── test_tcp_proxy_integration.py # TCP proxy end-to-end frame exchange
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
```
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
@@ -31,9 +31,6 @@ class Settings(BaseSettings):
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
tcp_proxy_enabled: bool = False
|
||||
tcp_proxy_bind: str = "0.0.0.0"
|
||||
tcp_proxy_port: int = 5001
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
|
||||
+40
-2
@@ -81,6 +81,15 @@ _REPEATER_SENSORS: list[dict[str, Any]] = [
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "recv_errors",
|
||||
"name": "RX Errors",
|
||||
"object_id": "recv_errors",
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "uptime_seconds",
|
||||
"name": "Uptime",
|
||||
@@ -567,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))
|
||||
@@ -635,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:
|
||||
@@ -740,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)
|
||||
|
||||
+9
-22
@@ -2,14 +2,13 @@ import logging
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows event-loop advisory for MQTT fanout and TCP proxy
|
||||
# Windows event-loop advisory for MQTT fanout
|
||||
# ---------------------------------------------------------------------------
|
||||
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
|
||||
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) and
|
||||
# asyncio.start_server (TCP proxy) require. The loop is already created by
|
||||
# the time this module is imported, so we cannot switch it here. Log a
|
||||
# prominent warning so Windows operators know to start uvicorn with the
|
||||
# selector loop policy set before import.
|
||||
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
|
||||
# We cannot fix this from inside the app — the loop is already created by the
|
||||
# time this module is imported. Log a prominent warning so Windows operators
|
||||
# who want MQTT know to add ``--loop none`` to their uvicorn command.
|
||||
# ---------------------------------------------------------------------------
|
||||
if sys.platform == "win32":
|
||||
import asyncio as _asyncio
|
||||
@@ -22,15 +21,12 @@ if sys.platform == "win32":
|
||||
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
|
||||
"\n"
|
||||
" The running event loop is ProactorEventLoop, which is not\n"
|
||||
" compatible with MQTT fanout or the TCP proxy.\n"
|
||||
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
|
||||
"\n"
|
||||
" If you use either feature, restart with:\n"
|
||||
" If you use MQTT integrations, restart with --loop none:\n"
|
||||
"\n"
|
||||
' python -c "import asyncio; asyncio.set_event_loop_policy('
|
||||
'asyncio.WindowsSelectorEventLoopPolicy())" & '
|
||||
"uv run uvicorn app.main:app [... options ...]\n"
|
||||
"\n"
|
||||
" Or add --loop asyncio to the uvicorn command.\n"
|
||||
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
|
||||
" [... other options ...]\n"
|
||||
"\n"
|
||||
" Everything else works fine as-is.\n"
|
||||
"\n" + "!" * 78 + "\n",
|
||||
@@ -134,21 +130,12 @@ async def lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
logger.exception("Failed to start fanout modules")
|
||||
|
||||
if server_settings.tcp_proxy_enabled:
|
||||
from app.tcp_proxy import start_tcp_proxy
|
||||
|
||||
await start_tcp_proxy()
|
||||
|
||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||
app.state.startup_radio_task = startup_radio_task
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
if server_settings.tcp_proxy_enabled:
|
||||
from app.tcp_proxy import stop_tcp_proxy
|
||||
|
||||
await stop_tcp_proxy()
|
||||
if startup_radio_task and not startup_radio_task.done():
|
||||
startup_radio_task.cancel()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import logging
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
"""Add telemetry_routed_hourly boolean column to app_settings."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "telemetry_routed_hourly" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN telemetry_routed_hourly INTEGER DEFAULT 0"
|
||||
)
|
||||
await conn.commit()
|
||||
@@ -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()
|
||||
+28
-4
@@ -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"),
|
||||
@@ -540,7 +542,8 @@ class RepeaterStatusResponse(BaseModel):
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
|
||||
telemetry_history: list[TelemetryHistoryEntry] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
@@ -595,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."""
|
||||
|
||||
@@ -846,12 +859,23 @@ 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/contacts with a direct or routed (non-flood) "
|
||||
"path are polled every hour instead of on the normal scheduled interval."
|
||||
),
|
||||
)
|
||||
auto_resend_channel: bool = Field(
|
||||
|
||||
+143
-16
@@ -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,
|
||||
@@ -1821,6 +1822,7 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"flood_dups": status.get("flood_dups", 0),
|
||||
"direct_dups": status.get("direct_dups", 0),
|
||||
"full_events": status.get("full_evts", 0),
|
||||
"recv_errors": status.get("recv_errors"),
|
||||
}
|
||||
|
||||
# Best-effort LPP sensor fetch — failure here does not fail the overall
|
||||
@@ -1889,21 +1891,103 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _run_telemetry_cycle() -> None:
|
||||
"""Collect one telemetry sample from every tracked repeater."""
|
||||
async def _collect_contact_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"""Fetch LPP telemetry from a non-repeater contact and record it.
|
||||
|
||||
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.
|
||||
"""
|
||||
if not radio_manager.is_connected:
|
||||
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||
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
|
||||
|
||||
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
||||
collected = 0
|
||||
|
||||
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(
|
||||
@@ -1911,25 +1995,60 @@ async def _run_telemetry_cycle() -> None:
|
||||
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, 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 targets to poll this hour")
|
||||
return
|
||||
|
||||
label = "routed" if routed_only else "full"
|
||||
logger.info(
|
||||
"Telemetry collect: starting %s cycle for %d target(s)",
|
||||
label,
|
||||
len(candidates),
|
||||
)
|
||||
collected = 0
|
||||
|
||||
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(
|
||||
"Telemetry collect: radio busy, skipping %s",
|
||||
pub_key[:12],
|
||||
contact.public_key[:12],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Telemetry collect: cycle complete, %d/%d successful",
|
||||
"Telemetry collect: %s cycle complete, %d/%d successful",
|
||||
label,
|
||||
collected,
|
||||
len(tracked),
|
||||
len(candidates),
|
||||
)
|
||||
|
||||
|
||||
@@ -1953,15 +2072,23 @@ 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)
|
||||
if effective_hours <= 0:
|
||||
return
|
||||
if now.hour % effective_hours != 0:
|
||||
return
|
||||
await _run_telemetry_cycle()
|
||||
|
||||
is_normal_cycle = now.hour % effective_hours == 0
|
||||
|
||||
if is_normal_cycle:
|
||||
# Normal scheduled boundary: collect ALL tracked targets.
|
||||
await _run_telemetry_cycle()
|
||||
elif app_settings.telemetry_routed_hourly:
|
||||
# Hourly routed-path fast-poll: only targets with a non-flood route.
|
||||
await _run_telemetry_cycle(routed_only=True)
|
||||
|
||||
|
||||
async def _telemetry_collect_loop() -> None:
|
||||
|
||||
@@ -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,8 +41,9 @@ 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,
|
||||
telemetry_interval_hours
|
||||
tracked_telemetry_repeaters, tracked_telemetry_contacts,
|
||||
auto_resend_channel,
|
||||
telemetry_interval_hours, telemetry_routed_hourly
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
) as cursor:
|
||||
@@ -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"])
|
||||
@@ -113,6 +123,12 @@ class AppSettingsRepository:
|
||||
except (KeyError, TypeError, ValueError):
|
||||
telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS
|
||||
|
||||
# Parse telemetry_routed_hourly boolean
|
||||
try:
|
||||
telemetry_routed_hourly = bool(row["telemetry_routed_hourly"])
|
||||
except (KeyError, TypeError):
|
||||
telemetry_routed_hourly = False
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
@@ -124,8 +140,10 @@ 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,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -142,8 +160,10 @@ 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,
|
||||
) -> None:
|
||||
"""Apply field updates using an already-acquired connection.
|
||||
|
||||
@@ -193,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)
|
||||
@@ -201,6 +225,10 @@ class AppSettingsRepository:
|
||||
updates.append("telemetry_interval_hours = ?")
|
||||
params.append(telemetry_interval_hours)
|
||||
|
||||
if telemetry_routed_hourly is not None:
|
||||
updates.append("telemetry_routed_hourly = ?")
|
||||
params.append(1 if telemetry_routed_hourly else 0)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
async with conn.execute(query, params):
|
||||
@@ -227,8 +255,10 @@ 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,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
async with db.tx() as conn:
|
||||
@@ -244,8 +274,10 @@ 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,
|
||||
)
|
||||
return await AppSettingsRepository._get_in_conn(conn)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
recv_errors=status.get("recv_errors"),
|
||||
)
|
||||
|
||||
# Record to telemetry history as a JSON blob (best-effort)
|
||||
|
||||
@@ -78,6 +78,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
recv_errors=status.get("recv_errors"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+162
-8
@@ -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):
|
||||
@@ -73,6 +74,13 @@ class AppSettingsUpdate(BaseModel):
|
||||
"based on the current tracked-repeater count."
|
||||
),
|
||||
)
|
||||
telemetry_routed_hourly: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"When enabled, tracked repeaters with a direct or routed (non-flood) "
|
||||
"path are polled every hour instead of on the normal scheduled interval."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BlockKeyRequest(BaseModel):
|
||||
@@ -126,7 +134,18 @@ class TelemetrySchedule(BaseModel):
|
||||
max_tracked: int = Field(description="Maximum number of repeaters that can be tracked")
|
||||
next_run_at: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp (UTC seconds) of the next scheduled cycle",
|
||||
description="Unix timestamp (UTC seconds) of the next scheduled flood cycle",
|
||||
)
|
||||
routed_hourly: bool = Field(
|
||||
default=False,
|
||||
description="Whether hourly routed/direct-path telemetry is enabled",
|
||||
)
|
||||
next_routed_run_at: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Unix timestamp (UTC seconds) of the next hourly routed/direct check, "
|
||||
"or None when routed_hourly is off or no repeaters are tracked"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -140,20 +159,27 @@ class TrackedTelemetryResponse(BaseModel):
|
||||
schedule: TelemetrySchedule = Field(description="Current scheduling state")
|
||||
|
||||
|
||||
def _build_schedule(tracked_count: int, preferred_hours: int | None) -> TelemetrySchedule:
|
||||
def _build_schedule(
|
||||
tracked_count: int,
|
||||
preferred_hours: int | None,
|
||||
routed_hourly: bool = False,
|
||||
) -> TelemetrySchedule:
|
||||
pref = (
|
||||
preferred_hours
|
||||
if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS
|
||||
else DEFAULT_TELEMETRY_INTERVAL_HOURS
|
||||
)
|
||||
effective = clamp_telemetry_interval(pref, tracked_count)
|
||||
has_tracked = tracked_count > 0
|
||||
return TelemetrySchedule(
|
||||
preferred_hours=pref,
|
||||
effective_hours=effective,
|
||||
options=legal_interval_options(tracked_count),
|
||||
tracked_count=tracked_count,
|
||||
max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS,
|
||||
next_run_at=next_run_timestamp_utc(effective) if tracked_count > 0 else None,
|
||||
next_run_at=next_run_timestamp_utc(effective) if has_tracked else None,
|
||||
routed_hourly=routed_hourly,
|
||||
next_routed_run_at=(next_run_timestamp_utc(1) if has_tracked and routed_hourly else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -216,6 +242,11 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating telemetry_interval_hours to %d", raw_interval)
|
||||
kwargs["telemetry_interval_hours"] = raw_interval
|
||||
|
||||
# Telemetry routed hourly
|
||||
if update.telemetry_routed_hourly is not None:
|
||||
logger.info("Updating telemetry_routed_hourly to %s", update.telemetry_routed_hourly)
|
||||
kwargs["telemetry_routed_hourly"] = update.telemetry_routed_hourly
|
||||
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
@@ -320,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]
|
||||
@@ -328,7 +361,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours),
|
||||
schedule=_build_schedule(
|
||||
len(new_list) + n_contacts,
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
)
|
||||
|
||||
# Validate it's a repeater
|
||||
@@ -355,7 +392,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours),
|
||||
schedule=_build_schedule(
|
||||
len(new_list) + n_contacts,
|
||||
settings.telemetry_interval_hours,
|
||||
settings.telemetry_routed_hourly,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -366,9 +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()
|
||||
return _build_schedule(
|
||||
len(app_settings.tracked_telemetry_repeaters),
|
||||
app_settings.telemetry_interval_hours,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# TCP Proxy Architecture
|
||||
|
||||
MeshCore companion protocol proxy: emulates a MeshCore radio over TCP,
|
||||
translating the binary companion protocol into in-process RemoteTerm
|
||||
operations. MeshCore clients (mobile apps, meshcore-cli, meshcore-ha)
|
||||
connect to it and interact with RemoteTerm as if it were a physical radio.
|
||||
|
||||
Enable with `MESHCORE_TCP_PROXY_ENABLED=true`.
|
||||
|
||||
## Module Map
|
||||
|
||||
```text
|
||||
app/tcp_proxy/
|
||||
├── __init__.py # start_tcp_proxy() / stop_tcp_proxy() lifecycle
|
||||
├── protocol.py # Constants, FrameParser, frame helpers
|
||||
├── encoder.py # Binary builders: contact, self_info, device_info
|
||||
├── session.py # ProxySession: per-client command dispatch + event handlers
|
||||
├── server.py # TCP server lifecycle, session registry, dispatch_event()
|
||||
└── AGENTS_tcp_proxy.md # This file
|
||||
```
|
||||
|
||||
## Protocol (protocol.py)
|
||||
|
||||
- Frame format: `0x3C`/`0x3E` marker + 2-byte LE length + payload
|
||||
- Command constants (`CMD_*`): client → proxy (first payload byte)
|
||||
- Response constants (`RESP_*`): proxy → client
|
||||
- Push constants (`PUSH_*`): unsolicited proxy → client notifications
|
||||
- `FrameParser`: stateful streaming frame decoder (mirrors meshcore_py `tcp_cx.py`)
|
||||
- Helpers: `frame_response`, `build_ok`, `build_error`, `pad`, `encode_path_byte`
|
||||
|
||||
## Encoder (encoder.py)
|
||||
|
||||
Stateless binary serializers that build companion-protocol payloads from
|
||||
domain data. All functions return raw `bytes` (no frame wrapping).
|
||||
|
||||
- `build_contact` / `build_contact_from_dict`: Contact → RESP_CONTACT / PUSH_NEW_ADVERT
|
||||
- `build_self_info` / `build_self_info_from_runtime`: radio config → RESP_SELF_INFO
|
||||
- `build_device_info`: → RESP_DEVICE_INFO (fixed proxy identity)
|
||||
|
||||
## Session (session.py)
|
||||
|
||||
One `ProxySession` per connected TCP client. Maintains per-client state:
|
||||
|
||||
- **contacts**: cached favorite contacts from DB
|
||||
- **channels**: cached channel list
|
||||
- **channel_slots** / **key_to_idx**: bidirectional channel index ↔ key mapping
|
||||
- **_msg_queue**: queued incoming messages for the pull-based delivery model
|
||||
|
||||
### Command Dispatch
|
||||
|
||||
Command byte → handler method via class-level dispatch table. Unsupported
|
||||
commands return `ERR_UNSUPPORTED`.
|
||||
|
||||
### Message Delivery (Pull Model)
|
||||
|
||||
MeshCore mobile apps use a pull model for incoming messages:
|
||||
1. Broadcast event arrives → session builds a V3 message frame → queues it
|
||||
2. Session sends `PUSH_MSG_WAITING` (0x83) to notify the client
|
||||
3. Client calls `CMD_SYNC_NEXT_MESSAGE` (0x0A) to pull the message
|
||||
4. Session dequeues and sends the frame
|
||||
5. Client calls again → `RESP_NO_MORE_MSGS` when queue is empty
|
||||
|
||||
### DM Send Flow
|
||||
|
||||
1. Parse destination prefix/key from binary payload
|
||||
2. Resolve to full public key via contacts cache
|
||||
3. Send immediate `RESP_MSG_SENT` + `PUSH_ACK` (fake ACK) so client doesn't retry
|
||||
4. Fire-and-forget `_do_send_dm()` task calls `send_direct_message_to_contact()`
|
||||
5. RemoteTerm handles actual radio lock, retries, and ACK tracking
|
||||
|
||||
## Server (server.py)
|
||||
|
||||
- TCP server lifecycle (`start` / `stop`) following the `radio_stats.py` pattern
|
||||
- Session registry (`register` / `unregister`)
|
||||
- `dispatch_event()`: called from `broadcast_event()` in `websocket.py` for
|
||||
`message`, `message_acked`, and `contact` events
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Client → TCP frame → FrameParser → ProxySession._dispatch
|
||||
→ command handler → repository/service call → binary response → TCP frame
|
||||
|
||||
RemoteTerm event → broadcast_event → dispatch_event
|
||||
→ ProxySession.on_event_* → push frame → TCP frame
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
- `app/config.py`: `tcp_proxy_enabled`, `tcp_proxy_bind`, `tcp_proxy_port`
|
||||
- `app/main.py`: conditional `start_tcp_proxy()` / `stop_tcp_proxy()` in lifespan
|
||||
- `app/websocket.py`: `dispatch_event()` hook in `broadcast_event()` for message/ack/contact
|
||||
|
||||
## Design Constraints
|
||||
|
||||
- Never mutate RemoteTerm state from SET_CHANNEL (local slot mapping only)
|
||||
- Only sync favorite contacts to clients
|
||||
- Channel slots: pre-load favorites only, ERR_NOT_FOUND for empty slots
|
||||
- DM sends return immediate fake ACK (RemoteTerm handles retries)
|
||||
- Message delivery uses the pull model (PUSH_MSG_WAITING → SYNC_NEXT_MESSAGE)
|
||||
|
||||
## Config
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the TCP companion protocol proxy |
|
||||
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the proxy TCP server |
|
||||
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the proxy TCP server |
|
||||
|
||||
## Tests
|
||||
|
||||
```text
|
||||
tests/
|
||||
├── test_tcp_proxy_protocol.py # FrameParser, frame helpers (pure, no async)
|
||||
├── test_tcp_proxy_encoder.py # Binary encoding against expected wire bytes
|
||||
├── test_tcp_proxy_session.py # Command handlers with mocked radio + repos
|
||||
└── test_tcp_proxy_integration.py # Real TCP server, end-to-end frame exchange
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
"""MeshCore TCP companion protocol proxy.
|
||||
|
||||
Emulates a MeshCore companion radio over TCP, translating the binary
|
||||
protocol into in-process RemoteTerm operations. Enable with
|
||||
``MESHCORE_TCP_PROXY_ENABLED=true``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start_tcp_proxy() -> None:
|
||||
"""Start the TCP proxy server using settings from config."""
|
||||
from app.config import settings
|
||||
|
||||
from .server import start
|
||||
|
||||
await start(settings.tcp_proxy_bind, settings.tcp_proxy_port)
|
||||
|
||||
|
||||
async def stop_tcp_proxy() -> None:
|
||||
"""Stop the TCP proxy server."""
|
||||
from .server import stop
|
||||
|
||||
await stop()
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Binary encoders that build companion-protocol response payloads.
|
||||
|
||||
All functions return raw ``bytes`` payloads (without frame wrapping).
|
||||
The caller is responsible for framing via :func:`protocol.frame_response`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .protocol import (
|
||||
PROXY_FW_BUILD,
|
||||
PROXY_FW_VER,
|
||||
PROXY_FW_VERSION,
|
||||
PROXY_MAX_CHANNELS,
|
||||
PROXY_MAX_CONTACTS_RAW,
|
||||
PROXY_MODEL,
|
||||
PUSH_NEW_ADVERT,
|
||||
RESP_CONTACT,
|
||||
RESP_DEVICE_INFO,
|
||||
RESP_SELF_INFO,
|
||||
encode_path_byte,
|
||||
pad,
|
||||
)
|
||||
|
||||
|
||||
def build_contact(
|
||||
public_key: str,
|
||||
*,
|
||||
contact_type: int = 0,
|
||||
favorite: bool = False,
|
||||
direct_path: str | None = None,
|
||||
direct_path_len: int = -1,
|
||||
direct_path_hash_mode: int = -1,
|
||||
name: str | None = None,
|
||||
last_advert: int = 0,
|
||||
lat: float = 0.0,
|
||||
lon: float = 0.0,
|
||||
lastmod: int | None = None,
|
||||
push: bool = False,
|
||||
) -> bytes:
|
||||
"""Build a ``RESP_CONTACT`` (or ``PUSH_NEW_ADVERT``) payload.
|
||||
|
||||
Args:
|
||||
push: If True, use ``PUSH_NEW_ADVERT`` (0x8A) instead of
|
||||
``RESP_CONTACT`` (0x03) as the leading byte.
|
||||
"""
|
||||
out = bytearray()
|
||||
out.append(PUSH_NEW_ADVERT if push else RESP_CONTACT)
|
||||
|
||||
out.extend(pad(bytes.fromhex(public_key), 32))
|
||||
out.append(contact_type)
|
||||
|
||||
flags = 0x01 if favorite else 0x00
|
||||
out.append(flags)
|
||||
|
||||
if direct_path_len >= 0 and direct_path_hash_mode >= 0:
|
||||
out.append(encode_path_byte(direct_path_len, direct_path_hash_mode))
|
||||
else:
|
||||
out.append(0xFF) # no route known
|
||||
|
||||
path_bytes = bytes.fromhex(direct_path) if direct_path else b""
|
||||
out.extend(pad(path_bytes, 64))
|
||||
|
||||
out.extend(pad((name or "").encode("utf-8", "replace"), 32))
|
||||
out.extend(struct.pack("<I", last_advert))
|
||||
|
||||
out.extend(struct.pack("<i", int(lat * 1e6)))
|
||||
out.extend(struct.pack("<i", int(lon * 1e6)))
|
||||
|
||||
out.extend(struct.pack("<I", lastmod or int(time.time())))
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_contact_from_dict(data: dict[str, Any], *, push: bool = False) -> bytes:
|
||||
"""Build a contact payload from either a ``Contact`` model dict or a
|
||||
WS event ``data`` dict. Accepts both snake_case model fields and
|
||||
the shapes produced by Pydantic JSON serialisation."""
|
||||
return build_contact(
|
||||
public_key=data["public_key"],
|
||||
contact_type=data.get("type") or 0,
|
||||
favorite=bool(data.get("favorite")),
|
||||
direct_path=data.get("direct_path") or None,
|
||||
direct_path_len=data.get("direct_path_len", -1),
|
||||
direct_path_hash_mode=data.get("direct_path_hash_mode", -1),
|
||||
name=data.get("name"),
|
||||
last_advert=int(data.get("last_advert") or 0),
|
||||
lat=float(data.get("lat") or 0),
|
||||
lon=float(data.get("lon") or 0),
|
||||
lastmod=int(data.get("lastmod") or data.get("first_seen") or 0) or None,
|
||||
push=push,
|
||||
)
|
||||
|
||||
|
||||
def build_self_info(
|
||||
*,
|
||||
public_key: str = "00" * 32,
|
||||
name: str = "RemoteTerm",
|
||||
tx_power: int = 20,
|
||||
max_tx_power: int = 22,
|
||||
lat: float = 0.0,
|
||||
lon: float = 0.0,
|
||||
multi_acks: bool = False,
|
||||
advert_loc: bool = False,
|
||||
radio_freq: float = 915.0,
|
||||
radio_bw: float = 250.0,
|
||||
radio_sf: int = 10,
|
||||
radio_cr: int = 7,
|
||||
) -> bytes:
|
||||
"""Build a ``RESP_SELF_INFO`` payload (response to ``CMD_APP_START``)."""
|
||||
out = bytearray()
|
||||
out.append(RESP_SELF_INFO)
|
||||
out.append(1) # adv_type = CHAT
|
||||
out.append(tx_power)
|
||||
out.append(max_tx_power)
|
||||
out.extend(pad(bytes.fromhex(public_key), 32))
|
||||
out.extend(struct.pack("<i", int(lat * 1e6)))
|
||||
out.extend(struct.pack("<i", int(lon * 1e6)))
|
||||
out.append(1 if multi_acks else 0)
|
||||
out.append(1 if advert_loc else 0)
|
||||
out.append(0) # telemetry_mode
|
||||
out.append(0) # manual_add_contacts
|
||||
out.extend(struct.pack("<I", int(radio_freq * 1000)))
|
||||
out.extend(struct.pack("<I", int(radio_bw * 1000)))
|
||||
out.append(radio_sf)
|
||||
out.append(radio_cr)
|
||||
out.extend(name.encode("utf-8"))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_self_info_from_runtime(self_info: dict[str, Any]) -> bytes:
|
||||
"""Build ``RESP_SELF_INFO`` from ``radio_runtime.self_info``."""
|
||||
return build_self_info(
|
||||
public_key=self_info.get("public_key") or "00" * 32,
|
||||
name=self_info.get("name") or "RemoteTerm",
|
||||
tx_power=self_info.get("tx_power") or 20,
|
||||
max_tx_power=self_info.get("max_tx_power") or 22,
|
||||
lat=float(self_info.get("adv_lat") or 0),
|
||||
lon=float(self_info.get("adv_lon") or 0),
|
||||
multi_acks=bool(self_info.get("multi_acks")),
|
||||
advert_loc=bool(self_info.get("adv_loc_policy")),
|
||||
radio_freq=float(self_info.get("radio_freq") or 915.0),
|
||||
radio_bw=float(self_info.get("radio_bw") or 250.0),
|
||||
radio_sf=int(self_info.get("radio_sf") or 10),
|
||||
radio_cr=int(self_info.get("radio_cr") or 7),
|
||||
)
|
||||
|
||||
|
||||
def build_device_info(path_hash_mode: int = 0) -> bytes:
|
||||
"""Build a ``RESP_DEVICE_INFO`` payload (response to ``CMD_DEVICE_QUERY``)."""
|
||||
out = bytearray()
|
||||
out.append(RESP_DEVICE_INFO)
|
||||
out.append(PROXY_FW_VER)
|
||||
out.append(PROXY_MAX_CONTACTS_RAW) # ×2 by reader
|
||||
out.append(PROXY_MAX_CHANNELS)
|
||||
out.extend(struct.pack("<I", 0)) # ble_pin
|
||||
out.extend(pad(PROXY_FW_BUILD.encode(), 12))
|
||||
out.extend(pad(PROXY_MODEL.encode(), 40))
|
||||
out.extend(pad(PROXY_FW_VERSION.encode(), 20))
|
||||
out.append(0) # repeat mode (fw v9+)
|
||||
out.append(path_hash_mode) # (fw v10+)
|
||||
return bytes(out)
|
||||
@@ -1,195 +0,0 @@
|
||||
"""MeshCore companion protocol constants, frame helpers, and streaming parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Frame markers ────────────────────────────────────────────────────
|
||||
|
||||
FRAME_TX = 0x3C # client → radio
|
||||
FRAME_RX = 0x3E # radio → client
|
||||
MAX_FRAME_SIZE = 300 # firmware MAX_FRAME_SIZE is 172; we allow a bit more
|
||||
|
||||
# ── Command types (client → proxy) ──────────────────────────────────
|
||||
|
||||
CMD_APP_START = 0x01
|
||||
CMD_SEND_TXT_MSG = 0x02
|
||||
CMD_SEND_CHANNEL_TXT_MSG = 0x03
|
||||
CMD_GET_CONTACTS = 0x04
|
||||
CMD_GET_DEVICE_TIME = 0x05
|
||||
CMD_SET_DEVICE_TIME = 0x06
|
||||
CMD_SEND_SELF_ADVERT = 0x07
|
||||
CMD_SET_ADVERT_NAME = 0x08
|
||||
CMD_ADD_UPDATE_CONTACT = 0x09
|
||||
CMD_SYNC_NEXT_MESSAGE = 0x0A
|
||||
CMD_SET_RADIO_PARAMS = 0x0B
|
||||
CMD_SET_RADIO_TX_POWER = 0x0C
|
||||
CMD_RESET_PATH = 0x0D
|
||||
CMD_SET_ADVERT_LATLON = 0x0E
|
||||
CMD_REMOVE_CONTACT = 0x0F
|
||||
CMD_REBOOT = 0x13
|
||||
CMD_GET_BATT_AND_STORAGE = 0x14
|
||||
CMD_DEVICE_QUERY = 0x16
|
||||
CMD_EXPORT_PRIVATE_KEY = 0x17
|
||||
CMD_HAS_CONNECTION = 0x1C
|
||||
CMD_GET_CONTACT_BY_KEY = 0x1E
|
||||
CMD_GET_CHANNEL = 0x1F
|
||||
CMD_SET_CHANNEL = 0x20
|
||||
CMD_SET_FLOOD_SCOPE = 0x36
|
||||
CMD_GET_STATS = 0x38
|
||||
|
||||
CMD_NAMES: dict[int, str] = {
|
||||
0x01: "APP_START",
|
||||
0x02: "SEND_TXT_MSG",
|
||||
0x03: "SEND_CHAN_MSG",
|
||||
0x04: "GET_CONTACTS",
|
||||
0x05: "GET_TIME",
|
||||
0x06: "SET_TIME",
|
||||
0x07: "SEND_ADVERT",
|
||||
0x08: "SET_NAME",
|
||||
0x09: "ADD_CONTACT",
|
||||
0x0A: "SYNC_MSG",
|
||||
0x0B: "SET_RADIO",
|
||||
0x0C: "SET_TX_POWER",
|
||||
0x0D: "RESET_PATH",
|
||||
0x0E: "SET_LATLON",
|
||||
0x0F: "REMOVE_CONTACT",
|
||||
0x13: "REBOOT",
|
||||
0x14: "GET_BATTERY",
|
||||
0x16: "DEVICE_QUERY",
|
||||
0x17: "EXPORT_PRIV_KEY",
|
||||
0x1C: "HAS_CONNECTION",
|
||||
0x1E: "GET_CONTACT_BY_KEY",
|
||||
0x1F: "GET_CHANNEL",
|
||||
0x20: "SET_CHANNEL",
|
||||
0x36: "SET_FLOOD_SCOPE",
|
||||
0x38: "GET_STATS",
|
||||
}
|
||||
|
||||
# ── Response / push types (proxy → client) ──────────────────────────
|
||||
|
||||
RESP_OK = 0x00
|
||||
RESP_ERR = 0x01
|
||||
RESP_CONTACT_START = 0x02
|
||||
RESP_CONTACT = 0x03
|
||||
RESP_CONTACT_END = 0x04
|
||||
RESP_SELF_INFO = 0x05
|
||||
RESP_MSG_SENT = 0x06
|
||||
RESP_CONTACT_MSG_RECV = 0x07
|
||||
RESP_CHANNEL_MSG_RECV = 0x08
|
||||
RESP_CURRENT_TIME = 0x09
|
||||
RESP_NO_MORE_MSGS = 0x0A
|
||||
RESP_BATTERY = 0x0C
|
||||
RESP_DEVICE_INFO = 0x0D
|
||||
RESP_DISABLED = 0x0F
|
||||
RESP_CONTACT_MSG_RECV_V3 = 0x10
|
||||
RESP_CHANNEL_MSG_RECV_V3 = 0x11
|
||||
RESP_CHANNEL_INFO = 0x12
|
||||
|
||||
PUSH_ACK = 0x82
|
||||
PUSH_MSG_WAITING = 0x83
|
||||
PUSH_NEW_ADVERT = 0x8A
|
||||
|
||||
# ── Error codes ──────────────────────────────────────────────────────
|
||||
|
||||
ERR_UNSUPPORTED = 1
|
||||
ERR_NOT_FOUND = 2
|
||||
|
||||
# ── Virtual device identity ─────────────────────────────────────────
|
||||
|
||||
PROXY_FW_VER = 11
|
||||
PROXY_MAX_CONTACTS_RAW = 255 # reader multiplies by 2 → 510
|
||||
PROXY_MAX_CHANNELS = 40
|
||||
PROXY_MODEL = "RemoteTerm Proxy"
|
||||
PROXY_FW_VERSION = "v0.1.0-proxy"
|
||||
PROXY_FW_BUILD = "proxy"
|
||||
|
||||
|
||||
# ── Frame helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def frame_response(payload: bytes) -> bytes:
|
||||
"""Wrap *payload* in a ``0x3E`` frame for sending to the client."""
|
||||
return bytes([FRAME_RX]) + len(payload).to_bytes(2, "little") + payload
|
||||
|
||||
|
||||
def build_ok(value: int | None = None) -> bytes:
|
||||
"""Build a ``RESP_OK`` payload, optionally with a 4-byte LE value."""
|
||||
if value is not None:
|
||||
return bytes([RESP_OK]) + value.to_bytes(4, "little")
|
||||
return bytes([RESP_OK])
|
||||
|
||||
|
||||
def build_error(code: int = ERR_UNSUPPORTED) -> bytes:
|
||||
"""Build a ``RESP_ERR`` payload with the given error code."""
|
||||
return bytes([RESP_ERR, code])
|
||||
|
||||
|
||||
def pad(data: bytes, length: int) -> bytes:
|
||||
"""Pad or truncate *data* to exactly *length* bytes."""
|
||||
return data[:length].ljust(length, b"\x00")
|
||||
|
||||
|
||||
def encode_path_byte(hop_count: int, hash_mode: int) -> int:
|
||||
"""Encode hop count + hash mode into a single packed byte.
|
||||
|
||||
Returns ``0xFF`` (direct / non-flood) when either value is negative.
|
||||
"""
|
||||
if hop_count < 0 or hash_mode < 0:
|
||||
return 0xFF
|
||||
return ((hash_mode & 0x03) << 6) | (hop_count & 0x3F)
|
||||
|
||||
|
||||
# ── Streaming frame parser ──────────────────────────────────────────
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""Stateful parser for ``0x3C``-framed TCP data.
|
||||
|
||||
Mirrors the framing logic in ``meshcore_py`` ``tcp_cx.py``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.header = b""
|
||||
self.inframe = b""
|
||||
self.frame_size = 0
|
||||
self.started = False
|
||||
|
||||
def feed(self, data: bytes) -> list[bytes]:
|
||||
"""Feed raw TCP bytes, return a list of complete payloads."""
|
||||
payloads: list[bytes] = []
|
||||
offset = 0
|
||||
|
||||
while offset < len(data):
|
||||
remaining = data[offset:]
|
||||
|
||||
if not self.started:
|
||||
needed = 3 - len(self.header)
|
||||
chunk = remaining[:needed]
|
||||
self.header += chunk
|
||||
offset += len(chunk)
|
||||
|
||||
if len(self.header) < 3:
|
||||
break
|
||||
|
||||
if self.header[0] != FRAME_TX:
|
||||
self.header = b""
|
||||
continue
|
||||
|
||||
self.frame_size = int.from_bytes(self.header[1:3], "little")
|
||||
if self.frame_size > MAX_FRAME_SIZE:
|
||||
self.header = b""
|
||||
continue
|
||||
|
||||
self.started = True
|
||||
else:
|
||||
needed = self.frame_size - len(self.inframe)
|
||||
chunk = remaining[:needed]
|
||||
self.inframe += chunk
|
||||
offset += len(chunk)
|
||||
|
||||
if len(self.inframe) >= self.frame_size:
|
||||
payloads.append(self.inframe)
|
||||
self.header = b""
|
||||
self.inframe = b""
|
||||
self.started = False
|
||||
|
||||
return payloads
|
||||
@@ -1,92 +0,0 @@
|
||||
"""TCP server lifecycle, session registry, and broadcast event dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .session import ProxySession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Session registry ─────────────────────────────────────────────────
|
||||
|
||||
_sessions: set[ProxySession] = set()
|
||||
_server: asyncio.Server | None = None
|
||||
|
||||
|
||||
def register(session: ProxySession) -> None:
|
||||
_sessions.add(session)
|
||||
|
||||
|
||||
def unregister(session: ProxySession) -> None:
|
||||
_sessions.discard(session)
|
||||
|
||||
|
||||
# ── Event dispatch (called from broadcast_event) ─────────────────────
|
||||
|
||||
|
||||
async def dispatch_event(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Dispatch a broadcast event to all connected proxy sessions.
|
||||
|
||||
Called from :func:`app.websocket.broadcast_event` for ``message``,
|
||||
``message_acked``, and ``contact`` events.
|
||||
"""
|
||||
for session in list(_sessions):
|
||||
try:
|
||||
if event_type == "message":
|
||||
await session.on_event_message(data)
|
||||
elif event_type == "contact":
|
||||
await session.on_event_contact(data)
|
||||
except Exception:
|
||||
logger.exception("Error dispatching %s to %s", event_type, session.addr)
|
||||
|
||||
|
||||
# ── TCP client handler ───────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_client(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
session = ProxySession(reader, writer)
|
||||
register(session)
|
||||
try:
|
||||
await session.run()
|
||||
finally:
|
||||
unregister(session)
|
||||
|
||||
|
||||
# ── Server lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def start(host: str, port: int) -> None:
|
||||
"""Start the TCP proxy server."""
|
||||
global _server
|
||||
if _server is not None:
|
||||
return
|
||||
|
||||
_server = await asyncio.start_server(_handle_client, host, port)
|
||||
addrs = ", ".join(str(s.getsockname()) for s in _server.sockets)
|
||||
logger.info("TCP proxy listening on %s", addrs)
|
||||
|
||||
|
||||
async def stop() -> None:
|
||||
"""Stop the TCP proxy server and disconnect all clients."""
|
||||
global _server
|
||||
if _server is None:
|
||||
return
|
||||
|
||||
# Close all active sessions
|
||||
for session in list(_sessions):
|
||||
try:
|
||||
session.writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
_sessions.clear()
|
||||
|
||||
_server.close()
|
||||
await _server.wait_closed()
|
||||
_server = None
|
||||
logger.info("TCP proxy stopped")
|
||||
@@ -1,683 +0,0 @@
|
||||
"""Per-client MeshCore companion protocol session.
|
||||
|
||||
Each connected TCP client gets its own ``ProxySession`` which:
|
||||
- parses incoming 0x3C frames via :class:`protocol.FrameParser`
|
||||
- dispatches commands to handler methods
|
||||
- translates between binary companion payloads and in-process
|
||||
repository / service calls
|
||||
- receives broadcast events and queues push frames for the client
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .encoder import (
|
||||
build_contact_from_dict,
|
||||
build_device_info,
|
||||
build_self_info_from_runtime,
|
||||
)
|
||||
from .protocol import (
|
||||
CMD_ADD_UPDATE_CONTACT,
|
||||
CMD_APP_START,
|
||||
CMD_DEVICE_QUERY,
|
||||
CMD_EXPORT_PRIVATE_KEY,
|
||||
CMD_GET_BATT_AND_STORAGE,
|
||||
CMD_GET_CHANNEL,
|
||||
CMD_GET_CONTACT_BY_KEY,
|
||||
CMD_GET_CONTACTS,
|
||||
CMD_GET_DEVICE_TIME,
|
||||
CMD_HAS_CONNECTION,
|
||||
CMD_NAMES,
|
||||
CMD_REMOVE_CONTACT,
|
||||
CMD_RESET_PATH,
|
||||
CMD_SEND_CHANNEL_TXT_MSG,
|
||||
CMD_SEND_SELF_ADVERT,
|
||||
CMD_SEND_TXT_MSG,
|
||||
CMD_SET_ADVERT_LATLON,
|
||||
CMD_SET_ADVERT_NAME,
|
||||
CMD_SET_CHANNEL,
|
||||
CMD_SET_DEVICE_TIME,
|
||||
CMD_SET_FLOOD_SCOPE,
|
||||
CMD_SYNC_NEXT_MESSAGE,
|
||||
ERR_NOT_FOUND,
|
||||
ERR_UNSUPPORTED,
|
||||
PROXY_MAX_CHANNELS,
|
||||
PUSH_ACK,
|
||||
PUSH_MSG_WAITING,
|
||||
RESP_BATTERY,
|
||||
RESP_CHANNEL_INFO,
|
||||
RESP_CHANNEL_MSG_RECV_V3,
|
||||
RESP_CONTACT_END,
|
||||
RESP_CONTACT_MSG_RECV_V3,
|
||||
RESP_CONTACT_START,
|
||||
RESP_CURRENT_TIME,
|
||||
RESP_DISABLED,
|
||||
RESP_MSG_SENT,
|
||||
RESP_NO_MORE_MSGS,
|
||||
FrameParser,
|
||||
build_error,
|
||||
build_ok,
|
||||
encode_path_byte,
|
||||
frame_response,
|
||||
pad,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProxySession:
|
||||
"""Handles one MeshCore TCP client, translating commands to RemoteTerm
|
||||
repository and service calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.addr = writer.get_extra_info("peername")
|
||||
self.parser = FrameParser()
|
||||
|
||||
# Cached state
|
||||
self.contacts: list[dict[str, Any]] = []
|
||||
self.channels: list[dict[str, Any]] = []
|
||||
|
||||
# Channel index ↔ key mapping
|
||||
self.channel_slots: dict[int, str] = {} # idx → key (lowercase hex)
|
||||
self.key_to_idx: dict[str, int] = {} # key (lowercase) → idx
|
||||
|
||||
# Queued incoming messages for SYNC_NEXT_MESSAGE pull flow.
|
||||
self._msg_queue: list[bytes] = []
|
||||
|
||||
# ── send helper ──────────────────────────────────────────────────
|
||||
|
||||
async def send(self, payload: bytes) -> None:
|
||||
"""Frame and send a response payload."""
|
||||
self.writer.write(frame_response(payload))
|
||||
await self.writer.drain()
|
||||
|
||||
# ── main loop ────────────────────────────────────────────────────
|
||||
|
||||
async def run(self) -> None:
|
||||
logger.info("Client connected: %s", self.addr)
|
||||
try:
|
||||
while True:
|
||||
data = await self.reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
for payload in self.parser.feed(data):
|
||||
await self._dispatch(payload)
|
||||
except (asyncio.CancelledError, ConnectionResetError):
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Session error [%s]", self.addr)
|
||||
finally:
|
||||
self.writer.close()
|
||||
logger.info("Client disconnected: %s", self.addr)
|
||||
|
||||
# ── command dispatch ─────────────────────────────────────────────
|
||||
|
||||
_DISPATCH_TABLE: dict[int, str] | None = None
|
||||
|
||||
@classmethod
|
||||
def _build_dispatch_table(cls) -> dict[int, str]:
|
||||
if cls._DISPATCH_TABLE is None:
|
||||
cls._DISPATCH_TABLE = {
|
||||
CMD_APP_START: "_cmd_app_start",
|
||||
CMD_DEVICE_QUERY: "_cmd_device_query",
|
||||
CMD_GET_CONTACTS: "_cmd_get_contacts",
|
||||
CMD_GET_CONTACT_BY_KEY: "_cmd_get_contact_by_key",
|
||||
CMD_GET_CHANNEL: "_cmd_get_channel",
|
||||
CMD_SET_CHANNEL: "_cmd_set_channel",
|
||||
CMD_SEND_TXT_MSG: "_cmd_send_dm",
|
||||
CMD_SEND_CHANNEL_TXT_MSG: "_cmd_send_channel",
|
||||
CMD_GET_DEVICE_TIME: "_cmd_get_time",
|
||||
CMD_SET_DEVICE_TIME: "_cmd_ok_stub",
|
||||
CMD_SEND_SELF_ADVERT: "_cmd_advertise",
|
||||
CMD_GET_BATT_AND_STORAGE: "_cmd_battery",
|
||||
CMD_HAS_CONNECTION: "_cmd_has_connection",
|
||||
CMD_SYNC_NEXT_MESSAGE: "_cmd_sync_next",
|
||||
CMD_ADD_UPDATE_CONTACT: "_cmd_ok_stub",
|
||||
CMD_REMOVE_CONTACT: "_cmd_remove_contact",
|
||||
CMD_RESET_PATH: "_cmd_ok_stub",
|
||||
CMD_SET_ADVERT_NAME: "_cmd_set_name",
|
||||
CMD_SET_ADVERT_LATLON: "_cmd_set_latlon",
|
||||
CMD_SET_FLOOD_SCOPE: "_cmd_ok_stub",
|
||||
CMD_EXPORT_PRIVATE_KEY: "_cmd_disabled",
|
||||
}
|
||||
return cls._DISPATCH_TABLE
|
||||
|
||||
async def _dispatch(self, data: bytes) -> None:
|
||||
if not data:
|
||||
return
|
||||
cmd = data[0]
|
||||
name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
||||
logger.debug("[%s] ← %s (%dB)", self.addr, name, len(data))
|
||||
|
||||
table = self._build_dispatch_table()
|
||||
method_name = table.get(cmd)
|
||||
if method_name:
|
||||
handler = getattr(self, method_name)
|
||||
try:
|
||||
await handler(data)
|
||||
except Exception:
|
||||
logger.exception("[%s] Error in %s", self.addr, name)
|
||||
await self.send(build_error())
|
||||
else:
|
||||
logger.warning("[%s] Unsupported: %s", self.addr, name)
|
||||
await self.send(build_error(ERR_UNSUPPORTED))
|
||||
|
||||
# ── stubs ────────────────────────────────────────────────────────
|
||||
|
||||
async def _cmd_ok_stub(self, data: bytes) -> None:
|
||||
await self.send(build_ok())
|
||||
|
||||
async def _cmd_disabled(self, data: bytes) -> None:
|
||||
await self.send(bytes([RESP_DISABLED]))
|
||||
|
||||
# ── APP_START → SELF_INFO ────────────────────────────────────────
|
||||
|
||||
async def _cmd_app_start(self, data: bytes) -> None:
|
||||
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
self.contacts = [c.model_dump() for c in await ContactRepository.get_favorites()]
|
||||
self.channels = [c.model_dump() for c in await ChannelRepository.get_all()]
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
lmt = settings.last_message_times or {}
|
||||
self._sort_channels(lmt)
|
||||
self._rebuild_slots()
|
||||
|
||||
mc = radio_runtime.meshcore
|
||||
self_info = mc.self_info if mc else {}
|
||||
await self.send(build_self_info_from_runtime(self_info or {}))
|
||||
|
||||
name = (self_info or {}).get("name", "?")
|
||||
pubkey = (self_info or {}).get("public_key", "?" * 12)
|
||||
logger.info(
|
||||
"[%s] Session started — %s (%s...) | %d contacts, %d channel slots",
|
||||
self.addr,
|
||||
name,
|
||||
pubkey[:12],
|
||||
len(self.contacts),
|
||||
len(self.channel_slots),
|
||||
)
|
||||
|
||||
# ── DEVICE_QUERY → DEVICE_INFO ──────────────────────────────────
|
||||
|
||||
async def _cmd_device_query(self, data: bytes) -> None:
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
mc = radio_runtime.meshcore
|
||||
self_info = mc.self_info if mc else {}
|
||||
# Fall back to radio_runtime.path_hash_mode which radio_lifecycle
|
||||
# recovers from the raw device-info frame when self_info is missing it.
|
||||
phm = (self_info or {}).get("path_hash_mode")
|
||||
if phm is None:
|
||||
phm = getattr(radio_runtime, "path_hash_mode", 0) or 0
|
||||
await self.send(build_device_info(path_hash_mode=phm))
|
||||
|
||||
# ── GET_CONTACTS ─────────────────────────────────────────────────
|
||||
|
||||
async def _cmd_get_contacts(self, data: bytes) -> None:
|
||||
from app.repository import ContactRepository
|
||||
|
||||
self.contacts = [c.model_dump() for c in await ContactRepository.get_favorites()]
|
||||
|
||||
count = len(self.contacts)
|
||||
await self.send(bytes([RESP_CONTACT_START]) + count.to_bytes(4, "little"))
|
||||
|
||||
for c in self.contacts:
|
||||
await self.send(build_contact_from_dict(c))
|
||||
|
||||
await self.send(bytes([RESP_CONTACT_END]) + int(time.time()).to_bytes(4, "little"))
|
||||
logger.info("[%s] Sent %d contacts", self.addr, count)
|
||||
|
||||
# ── GET_CONTACT_BY_KEY ───────────────────────────────────────────
|
||||
|
||||
async def _cmd_get_contact_by_key(self, data: bytes) -> None:
|
||||
if len(data) < 33:
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
pubkey = data[1:33].hex()
|
||||
contact = next((c for c in self.contacts if c["public_key"] == pubkey), None)
|
||||
if contact is None:
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
await self.send(build_contact_from_dict(contact))
|
||||
|
||||
# ── GET_CHANNEL → CHANNEL_INFO ───────────────────────────────────
|
||||
|
||||
async def _cmd_get_channel(self, data: bytes) -> None:
|
||||
if len(data) < 2:
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
idx = data[1]
|
||||
key_hex = self.channel_slots.get(idx)
|
||||
if key_hex is None:
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
ch = next((c for c in self.channels if c["key"].lower() == key_hex), None)
|
||||
name = (ch.get("name") or "") if ch else ""
|
||||
|
||||
out = bytearray()
|
||||
out.append(RESP_CHANNEL_INFO)
|
||||
out.append(idx)
|
||||
out.extend(pad(name.encode("utf-8"), 32))
|
||||
out.extend(pad(bytes.fromhex(key_hex), 16))
|
||||
await self.send(bytes(out))
|
||||
|
||||
# ── SET_CHANNEL ──────────────────────────────────────────────────
|
||||
|
||||
async def _cmd_set_channel(self, data: bytes) -> None:
|
||||
if len(data) < 50:
|
||||
await self.send(build_error())
|
||||
return
|
||||
|
||||
idx = data[1]
|
||||
key_hex = data[34:50].hex()
|
||||
|
||||
# Clean up stale bidirectional mappings
|
||||
old_key = self.channel_slots.get(idx)
|
||||
if old_key is not None and old_key != key_hex:
|
||||
self.key_to_idx.pop(old_key, None)
|
||||
|
||||
old_idx = self.key_to_idx.get(key_hex)
|
||||
if old_idx is not None and old_idx != idx:
|
||||
self.channel_slots.pop(old_idx, None)
|
||||
|
||||
self.channel_slots[idx] = key_hex
|
||||
self.key_to_idx[key_hex] = idx
|
||||
await self.send(build_ok())
|
||||
|
||||
# ── SEND_TXT_MSG (DM) ───────────────────────────────────────────
|
||||
|
||||
async def _cmd_send_dm(self, data: bytes) -> None:
|
||||
buf = io.BytesIO(data)
|
||||
buf.read(1) # cmd
|
||||
buf.read(1) # txt_type
|
||||
buf.read(1) # attempt
|
||||
buf.read(4) # timestamp
|
||||
remaining = buf.read()
|
||||
|
||||
full_key, text = self._parse_destination_and_text(remaining)
|
||||
if not full_key or text is None:
|
||||
logger.warning(
|
||||
"[%s] Cannot resolve DM destination (remaining %dB)",
|
||||
self.addr,
|
||||
len(remaining),
|
||||
)
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
# Send immediate MSG_SENT + fake ACK — RemoteTerm handles retries.
|
||||
ack_code = random.randbytes(4)
|
||||
out = bytearray([RESP_MSG_SENT, 1]) # type=flood
|
||||
out.extend(ack_code)
|
||||
out.extend(struct.pack("<I", 5_000))
|
||||
await self.send(bytes(out))
|
||||
|
||||
ack_frame = bytearray([PUSH_ACK])
|
||||
ack_frame.extend(ack_code)
|
||||
ack_frame.extend(struct.pack("<I", 100)) # fake trip_time
|
||||
await self.send(bytes(ack_frame))
|
||||
|
||||
# Fire-and-forget the actual send
|
||||
asyncio.create_task(self._do_send_dm(full_key, text))
|
||||
logger.info("[%s] DM → %s...: %s", self.addr, full_key[:12], text[:40])
|
||||
|
||||
async def _do_send_dm(self, public_key: str, text: str) -> None:
|
||||
"""Background task: send a DM through the radio via the service layer."""
|
||||
try:
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.repository import ContactRepository, MessageRepository
|
||||
from app.services.message_send import send_direct_message_to_contact
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
logger.warning("DM send: contact %s not found", public_key[:12])
|
||||
return
|
||||
|
||||
await send_direct_message_to_contact(
|
||||
contact=contact,
|
||||
text=text,
|
||||
radio_manager=radio_runtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
track_pending_ack_fn=track_pending_ack,
|
||||
now_fn=time.time,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[%s] DM send failed for %s", self.addr, public_key[:12])
|
||||
|
||||
def _parse_destination_and_text(self, remaining: bytes) -> tuple[str | None, str | None]:
|
||||
"""Resolve destination key + text from the combined buffer.
|
||||
|
||||
The standard companion protocol sends a 6-byte pubkey prefix at the
|
||||
start of ``remaining``, so we try prefix resolution first. Only when
|
||||
prefix lookup fails do we attempt a 32-byte full-key parse (used by
|
||||
``meshcore_py`` ``send_msg_with_retry``).
|
||||
"""
|
||||
# Standard path: 6-byte prefix — resolve against cached contacts.
|
||||
if len(remaining) > 6:
|
||||
prefix = remaining[:6].hex()
|
||||
matches = [c["public_key"] for c in self.contacts if c["public_key"].startswith(prefix)]
|
||||
if len(matches) == 1:
|
||||
return matches[0], remaining[6:].decode("utf-8", "ignore")
|
||||
|
||||
# Extended path: 32-byte full key (send_msg_with_retry sends full
|
||||
# keys). _do_send_dm resolves from the repository, not just our
|
||||
# favorites cache.
|
||||
if len(remaining) > 32:
|
||||
candidate = remaining[:32].hex()
|
||||
return candidate, remaining[32:].decode("utf-8", "ignore")
|
||||
|
||||
return None, None
|
||||
|
||||
# ── SEND_CHANNEL_TXT_MSG ─────────────────────────────────────────
|
||||
|
||||
async def _cmd_send_channel(self, data: bytes) -> None:
|
||||
buf = io.BytesIO(data)
|
||||
buf.read(1) # cmd
|
||||
buf.read(1) # txt_type
|
||||
channel_idx = buf.read(1)[0]
|
||||
buf.read(4) # timestamp
|
||||
text = buf.read().rstrip(b"\x00").decode("utf-8", "ignore")
|
||||
|
||||
key_hex = self.channel_slots.get(channel_idx)
|
||||
if not key_hex:
|
||||
logger.warning("[%s] No channel at slot %d", self.addr, channel_idx)
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
# Verify the channel exists in RemoteTerm's DB before confirming.
|
||||
# SET_CHANNEL is local-only, so client-loaded channels that aren't in
|
||||
# the DB can't be sent on — return ERR_NOT_FOUND instead of false OK.
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
channel = await ChannelRepository.get_by_key(key_hex)
|
||||
if not channel:
|
||||
logger.warning("[%s] Channel %s not in DB", self.addr, key_hex[:12])
|
||||
await self.send(build_error(ERR_NOT_FOUND))
|
||||
return
|
||||
|
||||
await self.send(build_ok())
|
||||
asyncio.create_task(self._do_send_channel(key_hex, text))
|
||||
|
||||
label = channel.name or key_hex[:8]
|
||||
logger.info("[%s] Chan [%s]: %s", self.addr, label, text[:40])
|
||||
|
||||
async def _do_send_channel(self, channel_key: str, text: str) -> None:
|
||||
"""Background task: send a channel message through the radio."""
|
||||
try:
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.services.message_send import send_channel_message_to_channel
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
channel = await ChannelRepository.get_by_key(channel_key)
|
||||
if not channel:
|
||||
logger.warning("Channel send: key %s not found", channel_key[:12])
|
||||
return
|
||||
|
||||
key_bytes = bytes.fromhex(channel_key)
|
||||
await send_channel_message_to_channel(
|
||||
channel=channel,
|
||||
channel_key_upper=channel_key.upper(),
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
radio_manager=radio_runtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=0,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[%s] Channel send failed for %s", self.addr, channel_key[:12])
|
||||
|
||||
# ── Simple command handlers ──────────────────────────────────────
|
||||
|
||||
async def _cmd_get_time(self, data: bytes) -> None:
|
||||
t = int(time.time())
|
||||
await self.send(bytes([RESP_CURRENT_TIME]) + t.to_bytes(4, "little"))
|
||||
|
||||
async def _cmd_advertise(self, data: bytes) -> None:
|
||||
try:
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
async with radio_runtime.radio_operation("proxy_advertise") as mc:
|
||||
await mc.commands.send_advert(flood=True)
|
||||
await self.send(build_ok())
|
||||
except Exception:
|
||||
logger.exception("Advertise failed")
|
||||
await self.send(build_error())
|
||||
|
||||
async def _cmd_battery(self, data: bytes) -> None:
|
||||
out = bytearray([RESP_BATTERY])
|
||||
out.extend(struct.pack("<H", 0)) # no battery
|
||||
await self.send(bytes(out))
|
||||
|
||||
async def _cmd_has_connection(self, data: bytes) -> None:
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
val = 1 if radio_runtime.is_connected else 0
|
||||
await self.send(build_ok(val))
|
||||
|
||||
async def _cmd_sync_next(self, data: bytes) -> None:
|
||||
if self._msg_queue:
|
||||
frame = self._msg_queue.pop(0)
|
||||
await self.send(frame)
|
||||
logger.debug(
|
||||
"[%s] Delivered queued msg (%d remaining)",
|
||||
self.addr,
|
||||
len(self._msg_queue),
|
||||
)
|
||||
else:
|
||||
await self.send(bytes([RESP_NO_MORE_MSGS]))
|
||||
|
||||
async def _cmd_remove_contact(self, data: bytes) -> None:
|
||||
if len(data) < 33:
|
||||
await self.send(build_error())
|
||||
return
|
||||
pubkey = data[1:33].hex()
|
||||
self.contacts = [c for c in self.contacts if c["public_key"] != pubkey]
|
||||
await self.send(build_ok())
|
||||
|
||||
async def _cmd_set_name(self, data: bytes) -> None:
|
||||
name = data[1:].decode("utf-8", "ignore").rstrip("\x00")
|
||||
try:
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
async with radio_runtime.radio_operation("proxy_set_name") as mc:
|
||||
await mc.commands.set_name(name)
|
||||
await self.send(build_ok())
|
||||
except Exception:
|
||||
logger.exception("Set name failed")
|
||||
await self.send(build_error())
|
||||
|
||||
async def _cmd_set_latlon(self, data: bytes) -> None:
|
||||
if len(data) < 9:
|
||||
await self.send(build_error())
|
||||
return
|
||||
lat = struct.unpack_from("<i", data, 1)[0] / 1e6
|
||||
lon = struct.unpack_from("<i", data, 5)[0] / 1e6
|
||||
try:
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
async with radio_runtime.radio_operation("proxy_set_latlon") as mc:
|
||||
await mc.commands.set_coords(lat, lon)
|
||||
await self.send(build_ok())
|
||||
except Exception:
|
||||
logger.exception("Set lat/lon failed")
|
||||
await self.send(build_error())
|
||||
|
||||
# ── Channel slot management ──────────────────────────────────────
|
||||
|
||||
def _sort_channels(self, last_message_times: dict[str, Any]) -> None:
|
||||
"""Sort channels: favorites first, then most recently active."""
|
||||
lmt = last_message_times
|
||||
|
||||
def key(ch: dict) -> tuple:
|
||||
is_fav = 1 if ch.get("favorite") else 0
|
||||
state_key = f"channel-{ch['key']}"
|
||||
last_activity = lmt.get(state_key) or 0
|
||||
return (-is_fav, -last_activity)
|
||||
|
||||
self.channels.sort(key=key)
|
||||
|
||||
def _rebuild_slots(self) -> None:
|
||||
"""Pre-load only favorite channels into slots."""
|
||||
self.channel_slots.clear()
|
||||
self.key_to_idx.clear()
|
||||
favorites = [ch for ch in self.channels if ch.get("favorite")]
|
||||
for i, ch in enumerate(favorites[:PROXY_MAX_CHANNELS]):
|
||||
k = ch["key"].lower()
|
||||
self.channel_slots[i] = k
|
||||
self.key_to_idx[k] = i
|
||||
logger.debug("Pre-loaded %d favorite channel(s)", len(self.channel_slots))
|
||||
|
||||
# ── Broadcast event helpers ────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_path_meta(data: dict[str, Any]) -> tuple[int, int]:
|
||||
"""Extract (snr_byte, path_len_byte) from a broadcast message dict.
|
||||
|
||||
Returns the SNR as ``int8(snr * 4)`` and path_len as the companion-
|
||||
protocol packed byte ``(hash_mode << 6) | hop_count``. When no path
|
||||
data is available, returns ``(0, 0)`` — 0 hops at 1-byte hash mode,
|
||||
which is the safest "we don't know" default for flood messages.
|
||||
"""
|
||||
paths = data.get("paths") or []
|
||||
first = paths[0] if paths else None
|
||||
|
||||
# SNR — V3 field, signed int8 encoded as snr * 4
|
||||
snr_raw = (first.get("snr") if first else None) or 0.0
|
||||
snr_byte = max(-128, min(127, int(snr_raw * 4))) & 0xFF
|
||||
|
||||
if first is None:
|
||||
return snr_byte, 0 # no path info → 0 hops
|
||||
|
||||
hop_count = first.get("path_len")
|
||||
path_hex: str = first.get("path") or ""
|
||||
if hop_count is None:
|
||||
# Legacy: infer 1-byte hops from hex length
|
||||
hop_count = len(path_hex) // 2
|
||||
|
||||
# Determine hash mode from path hex length and hop count
|
||||
if hop_count > 0 and path_hex:
|
||||
path_byte_len = len(path_hex) // 2
|
||||
hash_size = path_byte_len // hop_count if hop_count else 1
|
||||
hash_mode = max(0, hash_size - 1) # 1-byte → 0, 2 → 1, 3 → 2
|
||||
else:
|
||||
hash_mode = 0
|
||||
|
||||
return snr_byte, encode_path_byte(hop_count, hash_mode)
|
||||
|
||||
# ── Broadcast event handlers (called by server.dispatch_event) ──
|
||||
|
||||
async def _push_contact_from_db(self, public_key: str) -> None:
|
||||
"""Fetch a contact from the DB and push it to the client so it can
|
||||
display messages from senders not in the favorites cache."""
|
||||
try:
|
||||
from app.repository import ContactRepository
|
||||
|
||||
contact = await ContactRepository.get_by_key(public_key)
|
||||
if not contact:
|
||||
return
|
||||
contact_dict = contact.model_dump()
|
||||
await self.send(build_contact_from_dict(contact_dict, push=True))
|
||||
self.contacts.append(contact_dict)
|
||||
except Exception:
|
||||
logger.debug("Failed to push contact %s from DB", public_key[:12])
|
||||
|
||||
async def on_event_message(self, data: dict[str, Any]) -> None:
|
||||
"""Translate a broadcast ``message`` event into a queued push frame."""
|
||||
if data.get("outgoing"):
|
||||
return
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "PRIV":
|
||||
sender_key = data.get("conversation_key", "")
|
||||
if len(sender_key) < 12:
|
||||
return
|
||||
|
||||
# If sender isn't in our cache, fetch from DB and push to client
|
||||
# so it knows who the message is from.
|
||||
if not any(c["public_key"] == sender_key for c in self.contacts):
|
||||
await self._push_contact_from_db(sender_key)
|
||||
|
||||
text = data.get("text") or ""
|
||||
ts = int(data.get("sender_timestamp") or time.time())
|
||||
snr_byte, path_byte = self._extract_path_meta(data)
|
||||
|
||||
frame = bytearray([RESP_CONTACT_MSG_RECV_V3])
|
||||
frame.append(snr_byte)
|
||||
frame.extend(b"\x00\x00") # reserved
|
||||
frame.extend(bytes.fromhex(sender_key[:12])) # 6-byte prefix
|
||||
frame.append(path_byte)
|
||||
frame.append(0) # txt_type
|
||||
frame.extend(struct.pack("<I", ts))
|
||||
frame.extend(text.encode("utf-8"))
|
||||
|
||||
self._msg_queue.append(bytes(frame))
|
||||
await self.send(bytes([PUSH_MSG_WAITING]))
|
||||
|
||||
elif msg_type == "CHAN":
|
||||
conv_key = data.get("conversation_key", "").lower()
|
||||
idx = self.key_to_idx.get(conv_key)
|
||||
if idx is None:
|
||||
return
|
||||
|
||||
text = data.get("text") or ""
|
||||
ts = int(data.get("sender_timestamp") or time.time())
|
||||
snr_byte, path_byte = self._extract_path_meta(data)
|
||||
|
||||
frame = bytearray([RESP_CHANNEL_MSG_RECV_V3])
|
||||
frame.append(snr_byte)
|
||||
frame.extend(b"\x00\x00") # reserved
|
||||
frame.append(idx)
|
||||
frame.append(path_byte)
|
||||
frame.append(0) # txt_type
|
||||
frame.extend(struct.pack("<I", ts))
|
||||
frame.extend(text.encode("utf-8"))
|
||||
|
||||
self._msg_queue.append(bytes(frame))
|
||||
await self.send(bytes([PUSH_MSG_WAITING]))
|
||||
|
||||
async def on_event_contact(self, data: dict[str, Any]) -> None:
|
||||
"""Translate a broadcast ``contact`` event into a PUSH_NEW_ADVERT."""
|
||||
pubkey = data.get("public_key", "")
|
||||
if len(pubkey) < 64:
|
||||
return
|
||||
|
||||
# Only push contacts that are already in our favorites cache.
|
||||
# Without this filter, a long-lived session would gradually sync
|
||||
# every contact on the mesh, defeating the favorites-only policy.
|
||||
existing = next((c for c in self.contacts if c["public_key"] == pubkey), None)
|
||||
if existing is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.send(build_contact_from_dict(data, push=True))
|
||||
except Exception:
|
||||
logger.debug("Failed to build contact push for %s", pubkey[:12])
|
||||
|
||||
existing.update(data)
|
||||
@@ -117,15 +117,6 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
|
||||
elif event_type == "contact":
|
||||
asyncio.create_task(fanout_manager.broadcast_contact(data))
|
||||
|
||||
# TCP proxy dispatch
|
||||
if event_type in ("message", "message_acked", "contact"):
|
||||
from app.config import settings
|
||||
|
||||
if settings.tcp_proxy_enabled:
|
||||
from app.tcp_proxy.server import dispatch_event
|
||||
|
||||
asyncio.create_task(dispatch_event(event_type, data))
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
"""Broadcast an error notification to all connected clients.
|
||||
|
||||
+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
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.12.0",
|
||||
"version": "3.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.12.0",
|
||||
"version": "3.12.3",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -53,7 +53,7 @@
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.10",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.6.3",
|
||||
@@ -5619,9 +5619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.10",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.6.3",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useMemo,
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
@@ -12,6 +13,11 @@ import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
getTextReplaceEnabled,
|
||||
getTextReplaceMapJson,
|
||||
applyTextReplacements,
|
||||
} from '../utils/textReplace';
|
||||
|
||||
// MeshCore message size limits (empirically determined from LoRa packet constraints)
|
||||
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
|
||||
@@ -139,6 +145,31 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
[text, sending, disabled, onSend]
|
||||
);
|
||||
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target;
|
||||
const raw = input.value;
|
||||
// Skip replacement during IME / dead-key composition to avoid garbling interim input
|
||||
if (!e.nativeEvent || (e.nativeEvent as InputEvent).isComposing) {
|
||||
setText(raw);
|
||||
return;
|
||||
}
|
||||
if (getTextReplaceEnabled()) {
|
||||
const result = applyTextReplacements(
|
||||
raw,
|
||||
input.selectionStart ?? raw.length,
|
||||
getTextReplaceMapJson()
|
||||
);
|
||||
if (result) {
|
||||
setText(result.text);
|
||||
// Schedule cursor restore after React flushes the new value
|
||||
const pos = result.cursor;
|
||||
requestAnimationFrame(() => input.setSelectionRange(pos, pos));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setText(raw);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -173,7 +204,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || 'Type a message...'}
|
||||
disabled={disabled || sending}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -17,7 +17,12 @@ import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../t
|
||||
|
||||
const MAX_TRACKED = 8;
|
||||
|
||||
type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||
type BuiltinMetric =
|
||||
| 'battery_volts'
|
||||
| 'noise_floor_dbm'
|
||||
| 'packets'
|
||||
| 'recv_errors'
|
||||
| 'uptime_seconds';
|
||||
|
||||
interface MetricConfig {
|
||||
label: string;
|
||||
@@ -29,6 +34,7 @@ const BUILTIN_METRIC_CONFIG: Record<BuiltinMetric, MetricConfig> = {
|
||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||
recv_errors: { label: 'RX Errors', unit: '', color: '#ef4444' },
|
||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||
};
|
||||
|
||||
@@ -148,12 +154,19 @@ export function TelemetryHistoryPane({
|
||||
const chartData = useMemo(() => {
|
||||
return entries.map((e) => {
|
||||
const d = e.data;
|
||||
const recvErrors = d.recv_errors ?? undefined;
|
||||
const packetsReceived = d.packets_received;
|
||||
const point: Record<string, number | undefined> = {
|
||||
timestamp: e.timestamp,
|
||||
battery_volts: d.battery_volts,
|
||||
noise_floor_dbm: d.noise_floor_dbm,
|
||||
packets_received: d.packets_received,
|
||||
packets_received: packetsReceived,
|
||||
packets_sent: d.packets_sent,
|
||||
recv_errors: recvErrors,
|
||||
recv_error_pct:
|
||||
recvErrors != null && packetsReceived != null && packetsReceived + recvErrors > 0
|
||||
? +((recvErrors / (packetsReceived + recvErrors)) * 100).toFixed(2)
|
||||
: undefined,
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
// Flatten LPP sensors into the point, converting units as needed
|
||||
@@ -167,7 +180,11 @@ export function TelemetryHistoryPane({
|
||||
}, [entries, distanceUnit]);
|
||||
|
||||
const dataKeys =
|
||||
activeMetric === 'packets' ? ['packets_received', 'packets_sent'] : [activeMetric];
|
||||
activeMetric === 'packets'
|
||||
? ['packets_received', 'packets_sent']
|
||||
: activeMetric === 'recv_errors'
|
||||
? ['recv_errors', 'recv_error_pct']
|
||||
: [activeMetric];
|
||||
|
||||
const yDomain = useMemo<[number, number] | undefined>(() => {
|
||||
if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined;
|
||||
@@ -178,6 +195,20 @@ export function TelemetryHistoryPane({
|
||||
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
||||
}, [activeMetric, chartData]);
|
||||
|
||||
const yDomainPct = useMemo<[number, number]>(() => {
|
||||
const MIN_SPAN = 5;
|
||||
const values = chartData.map((d) => d.recv_error_pct).filter((v) => v != null) as number[];
|
||||
if (values.length === 0) return [0, MIN_SPAN];
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const span = hi - lo;
|
||||
if (span >= MIN_SPAN)
|
||||
return [Math.max(0, Math.floor(lo - span * 0.1)), Math.ceil(hi + span * 0.1)];
|
||||
const pad = (MIN_SPAN - span) / 2;
|
||||
const bottom = Math.max(0, Math.floor(lo - pad));
|
||||
return [bottom, Math.ceil(bottom + MIN_SPAN)];
|
||||
}, [chartData]);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
@@ -214,16 +245,16 @@ export function TelemetryHistoryPane({
|
||||
via the repeater pane, API calls to the endpoint (
|
||||
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
||||
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
|
||||
into this flow in 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"
|
||||
>
|
||||
Database & Messaging
|
||||
</a>{' '}
|
||||
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
||||
of keeping mesh congestion reasonable.
|
||||
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
|
||||
reasonable.
|
||||
</p>
|
||||
|
||||
{isTracked ? (
|
||||
@@ -252,7 +283,7 @@ export function TelemetryHistoryPane({
|
||||
disabled={toggling}
|
||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
|
||||
{toggling ? 'Updating...' : 'Opt Repeater into Interval Metrics Tracking'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -299,7 +330,15 @@ export function TelemetryHistoryPane({
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 4,
|
||||
right: activeMetric === 'recv_errors' ? 8 : 4,
|
||||
bottom: 0,
|
||||
left: -8,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
@@ -311,6 +350,7 @@ export function TelemetryHistoryPane({
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
@@ -319,6 +359,17 @@ export function TelemetryHistoryPane({
|
||||
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
|
||||
}
|
||||
/>
|
||||
{activeMetric === 'recv_errors' && (
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={yDomainPct}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
)}
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
@@ -330,6 +381,10 @@ export function TelemetryHistoryPane({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
const numVal = typeof value === 'number' ? value : Number(value);
|
||||
if (activeMetric === 'recv_errors') {
|
||||
if (name === 'recv_error_pct') return [`${numVal}%`, 'Error Rate'];
|
||||
return [`${value}`, 'RX Errors'];
|
||||
}
|
||||
const display =
|
||||
activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||
const suffix =
|
||||
@@ -347,51 +402,44 @@ export function TelemetryHistoryPane({
|
||||
return [`${display}${suffix}`, label];
|
||||
}}
|
||||
/>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
stroke={
|
||||
activeMetric === 'packets'
|
||||
{dataKeys.map((key, i) => {
|
||||
const color =
|
||||
activeMetric === 'packets'
|
||||
? i === 0
|
||||
? '#0ea5e9'
|
||||
: '#f43f5e'
|
||||
: activeMetric === 'recv_errors'
|
||||
? i === 0
|
||||
? '#0ea5e9'
|
||||
: '#f43f5e'
|
||||
: activeConfig.color
|
||||
}
|
||||
fill={
|
||||
activeMetric === 'packets'
|
||||
? i === 0
|
||||
? '#0ea5e9'
|
||||
: '#f43f5e'
|
||||
: activeConfig.color
|
||||
}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill:
|
||||
activeMetric === 'packets'
|
||||
? i === 0
|
||||
? '#0ea5e9'
|
||||
: '#f43f5e'
|
||||
: activeConfig.color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill:
|
||||
activeMetric === 'packets'
|
||||
? i === 0
|
||||
? '#0ea5e9'
|
||||
: '#f43f5e'
|
||||
: activeConfig.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
? '#ef4444'
|
||||
: '#f59e0b'
|
||||
: activeConfig.color;
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
yAxisId={
|
||||
activeMetric === 'recv_errors' && key === 'recv_error_pct' ? 'right' : 'left'
|
||||
}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
@@ -91,6 +91,26 @@ export function TelemetryPane({
|
||||
label="Duplicates"
|
||||
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
||||
/>
|
||||
{data.recv_errors != null && (
|
||||
<KvRow
|
||||
label="RX Errors"
|
||||
value={
|
||||
<>
|
||||
{data.recv_errors.toLocaleString()}
|
||||
{data.packets_received > 0 && (
|
||||
<Secondary>
|
||||
(
|
||||
{(
|
||||
(data.recv_errors / (data.packets_received + data.recv_errors)) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%)
|
||||
</Secondary>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
||||
<KvRow label="Debug Flags" value={data.full_events} />
|
||||
|
||||
@@ -6,113 +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]);
|
||||
|
||||
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) {
|
||||
@@ -159,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 {
|
||||
@@ -291,280 +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>
|
||||
)}
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next run at {formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 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>
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ import {
|
||||
setSavedFontScale,
|
||||
} from '../../utils/fontScale';
|
||||
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
||||
import {
|
||||
getTextReplaceEnabled,
|
||||
setTextReplaceEnabled as saveTextReplaceEnabled,
|
||||
getTextReplaceMapJson,
|
||||
setTextReplaceMapJson,
|
||||
DEFAULT_MAP_JSON,
|
||||
} from '../../utils/textReplace';
|
||||
import {
|
||||
BATTERY_DISPLAY_CHANGE_EVENT,
|
||||
getShowBatteryPercent,
|
||||
@@ -232,6 +239,9 @@ export function SettingsLocalSection({
|
||||
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
|
||||
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
|
||||
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
|
||||
const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled);
|
||||
const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson);
|
||||
const [textReplaceError, setTextReplaceError] = useState<string | null>(null);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||
@@ -439,6 +449,63 @@ export function SettingsLocalSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/60 p-3 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="text-replace"
|
||||
checked={textReplaceEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
const v = checked === true;
|
||||
setTextReplaceEnabled(v);
|
||||
saveTextReplaceEnabled(v);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="text-replace">Replace as you Type</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Automatically replace characters as you type in the message input. Define
|
||||
replacements as a JSON object mapping source strings to their replacements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{textReplaceEnabled && (
|
||||
<div className="space-y-2 pl-7">
|
||||
<textarea
|
||||
value={textReplaceJson}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setTextReplaceJson(val);
|
||||
setTextReplaceError(setTextReplaceMapJson(val));
|
||||
}}
|
||||
spellCheck={false}
|
||||
rows={10}
|
||||
className={cn(
|
||||
'w-full rounded-md border bg-background px-3 py-2 text-sm font-mono',
|
||||
textReplaceError ? 'border-destructive' : 'border-input'
|
||||
)}
|
||||
aria-label="Text replacement map (JSON)"
|
||||
/>
|
||||
{textReplaceError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{textReplaceError} Changes are not saved until this is resolved.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTextReplaceJson(DEFAULT_MAP_JSON);
|
||||
setTextReplaceMapJson(DEFAULT_MAP_JSON);
|
||||
setTextReplaceError(null);
|
||||
}}
|
||||
className="inline-flex h-8 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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,8 +109,10 @@ beforeEach(() => {
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
tracked_telemetry_contacts: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
});
|
||||
mockedApi.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -1048,8 +1050,10 @@ 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,
|
||||
});
|
||||
|
||||
renderSection();
|
||||
|
||||
@@ -438,6 +438,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
recv_errors: 5,
|
||||
telemetry_history: [],
|
||||
};
|
||||
|
||||
@@ -707,6 +708,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
recv_errors: null,
|
||||
telemetry_history: [liveEntry],
|
||||
};
|
||||
|
||||
@@ -742,6 +744,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
recv_errors: null,
|
||||
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SettingsModal } from '../components/SettingsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -69,8 +70,10 @@ 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,
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
@@ -89,6 +92,8 @@ function renderModal(overrides?: {
|
||||
meshDiscovery?: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
contacts?: Contact[];
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
open?: boolean;
|
||||
pageMode?: boolean;
|
||||
externalSidebarNav?: boolean;
|
||||
@@ -127,6 +132,8 @@ function renderModal(overrides?: {
|
||||
onDiscoverMesh,
|
||||
onHealthRefresh: vi.fn(async () => {}),
|
||||
onRefreshAppSettings,
|
||||
contacts: overrides?.contacts,
|
||||
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
|
||||
};
|
||||
|
||||
const view = overrides?.externalSidebarNav
|
||||
@@ -171,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);
|
||||
}
|
||||
|
||||
@@ -244,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);
|
||||
@@ -493,7 +500,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
desktopSection: 'radio-app',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
@@ -794,4 +801,68 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Network')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders routed hourly checkbox and calls save on toggle', async () => {
|
||||
const onSaveAppSettings = vi.fn(async () => {});
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'radio-app',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', {
|
||||
name: /Poll direct\/routed-path repeaters hourly/i,
|
||||
}) as HTMLInputElement;
|
||||
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaveAppSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ telemetry_routed_hourly: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows route badge per tracked repeater', async () => {
|
||||
const directKey = 'bb'.repeat(32);
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'radio-app',
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
tracked_telemetry_repeaters: [directKey],
|
||||
},
|
||||
trackedTelemetryRepeaters: [directKey],
|
||||
contacts: [
|
||||
{
|
||||
public_key: directKey,
|
||||
name: 'DirectRepeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
direct_path: 'aabb',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
|
||||
effective_route_source: 'direct',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
|
||||
expect(screen.getByText('direct')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
getTextReplaceEnabled,
|
||||
setTextReplaceEnabled,
|
||||
getTextReplaceMapJson,
|
||||
setTextReplaceMapJson,
|
||||
applyTextReplacements,
|
||||
DEFAULT_MAP_JSON,
|
||||
} from '../utils/textReplace';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('enabled toggle', () => {
|
||||
it('defaults to disabled', () => {
|
||||
expect(getTextReplaceEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('persists enabled state', () => {
|
||||
setTextReplaceEnabled(true);
|
||||
expect(getTextReplaceEnabled()).toBe(true);
|
||||
setTextReplaceEnabled(false);
|
||||
expect(getTextReplaceEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('map JSON persistence', () => {
|
||||
it('returns default map when nothing stored', () => {
|
||||
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
|
||||
});
|
||||
|
||||
it('persists valid JSON and returns null', () => {
|
||||
const json = '{"a":"b"}';
|
||||
expect(setTextReplaceMapJson(json)).toBeNull();
|
||||
expect(getTextReplaceMapJson()).toBe(json);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON with error string', () => {
|
||||
const err = setTextReplaceMapJson('not json');
|
||||
expect(err).toBeTypeOf('string');
|
||||
// localStorage unchanged — still returns default
|
||||
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
|
||||
});
|
||||
|
||||
it('rejects arrays', () => {
|
||||
expect(setTextReplaceMapJson('["a","b"]')).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('rejects non-string values', () => {
|
||||
expect(setTextReplaceMapJson('{"a":123}')).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('rejects null', () => {
|
||||
expect(setTextReplaceMapJson('null')).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('accepts empty object', () => {
|
||||
expect(setTextReplaceMapJson('{}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-expansion validation', () => {
|
||||
it('rejects when a key appears in its own replacement', () => {
|
||||
const err = setTextReplaceMapJson(JSON.stringify({ a: 'aa' }));
|
||||
expect(err).toBeTypeOf('string');
|
||||
expect(err).toContain('"a"');
|
||||
expect(err).toContain('"aa"');
|
||||
});
|
||||
|
||||
it('rejects when a key appears in another replacement', () => {
|
||||
const err = setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'ab' }));
|
||||
expect(err).toBeTypeOf('string');
|
||||
expect(err).toContain('"a"');
|
||||
expect(err).toContain('"ab"');
|
||||
});
|
||||
|
||||
it('allows replacements that do not contain any key', () => {
|
||||
expect(setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'Y' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('allows the default Cyrillic map', () => {
|
||||
expect(setTextReplaceMapJson(DEFAULT_MAP_JSON)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not check empty keys for re-expansion', () => {
|
||||
// Empty key is silently skipped by buildReplacements, so it should not
|
||||
// cause a re-expansion rejection for other entries.
|
||||
expect(setTextReplaceMapJson(JSON.stringify({ '': 'x', b: 'Y' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTextReplacements', () => {
|
||||
const simpleMap = JSON.stringify({ a: 'X', b: 'Y' });
|
||||
|
||||
it('returns null when no replacements match', () => {
|
||||
expect(applyTextReplacements('hello', 5, simpleMap)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty map', () => {
|
||||
expect(applyTextReplacements('abc', 3, '{}')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
expect(applyTextReplacements('abc', 3, 'broken')).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces a single character with cursor at end', () => {
|
||||
const result = applyTextReplacements('a', 1, simpleMap);
|
||||
expect(result).toEqual({ text: 'X', cursor: 1 });
|
||||
});
|
||||
|
||||
it('replaces multiple characters in one pass', () => {
|
||||
const result = applyTextReplacements('ab', 2, simpleMap);
|
||||
expect(result).toEqual({ text: 'XY', cursor: 2 });
|
||||
});
|
||||
|
||||
it('adjusts cursor when replacement is longer than needle', () => {
|
||||
const map = JSON.stringify({ ':)': 'smiley' });
|
||||
// "hello :)" cursor at end (8)
|
||||
const result = applyTextReplacements('hello :)', 8, map);
|
||||
expect(result).toEqual({ text: 'hello smiley', cursor: 12 });
|
||||
});
|
||||
|
||||
it('adjusts cursor when replacement is shorter than needle', () => {
|
||||
const map = JSON.stringify({ abc: 'Z' });
|
||||
// "abcdef" cursor at end (6)
|
||||
const result = applyTextReplacements('abcdef', 6, map);
|
||||
expect(result).toEqual({ text: 'Zdef', cursor: 4 });
|
||||
});
|
||||
|
||||
it('preserves cursor position when replacement is before cursor', () => {
|
||||
const map = JSON.stringify({ a: 'XX' });
|
||||
// "a_b" cursor at 2 (on 'b'), 'a' replaced with 'XX'
|
||||
const result = applyTextReplacements('a_b', 2, map);
|
||||
expect(result).toEqual({ text: 'XX_b', cursor: 3 });
|
||||
});
|
||||
|
||||
it('does not adjust cursor for replacements after cursor', () => {
|
||||
const map = JSON.stringify({ b: 'YY' });
|
||||
// "ab" cursor at 1 (after 'a'), 'b' is after cursor
|
||||
const result = applyTextReplacements('ab', 1, map);
|
||||
expect(result).toEqual({ text: 'aYY', cursor: 1 });
|
||||
});
|
||||
|
||||
it('places cursor after replacement when cursor is inside a multi-char match', () => {
|
||||
const map = JSON.stringify({ abc: 'Z' });
|
||||
// "abc" cursor at 2 (inside the match)
|
||||
const result = applyTextReplacements('abc', 2, map);
|
||||
expect(result).toEqual({ text: 'Z', cursor: 1 });
|
||||
});
|
||||
|
||||
it('handles multiple replacements with cursor tracking', () => {
|
||||
const map = JSON.stringify({ ':)': 'S' });
|
||||
// ":):)" cursor at end (4) — two replacements, each shrinks by 1
|
||||
const result = applyTextReplacements(':):)', 4, map);
|
||||
expect(result).toEqual({ text: 'SS', cursor: 2 });
|
||||
});
|
||||
|
||||
it('cursor between two replacements stays correct', () => {
|
||||
const map = JSON.stringify({ ':)': 'S' });
|
||||
// ":):)" cursor at 2 (between the two smileys)
|
||||
const result = applyTextReplacements(':):)', 2, map);
|
||||
expect(result).toEqual({ text: 'SS', cursor: 1 });
|
||||
});
|
||||
|
||||
it('uses longest match first', () => {
|
||||
const map = JSON.stringify({ ab: 'LONG', a: 'X' });
|
||||
const result = applyTextReplacements('ab', 2, map);
|
||||
expect(result).toEqual({ text: 'LONG', cursor: 4 });
|
||||
});
|
||||
|
||||
it('ignores empty-string keys (no infinite loop)', () => {
|
||||
const map = JSON.stringify({ '': 'oops', a: 'X' });
|
||||
const result = applyTextReplacements('abc', 3, map);
|
||||
expect(result).toEqual({ text: 'Xbc', cursor: 3 });
|
||||
});
|
||||
|
||||
it('works with the default Cyrillic map', () => {
|
||||
// "Привет" — П has no mapping, р→p, и has no mapping, в has no mapping, е→e, т has no mapping
|
||||
const result = applyTextReplacements('Привет', 6, DEFAULT_MAP_JSON);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe('Пpивeт');
|
||||
expect(result!.cursor).toBe(6);
|
||||
});
|
||||
|
||||
it('handles paste with many replacements', () => {
|
||||
const map = JSON.stringify({ А: 'A', В: 'B', С: 'C' });
|
||||
const result = applyTextReplacements('АВС', 3, map);
|
||||
expect(result).toEqual({ text: 'ABC', cursor: 3 });
|
||||
});
|
||||
});
|
||||
@@ -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,8 +363,10 @@ 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;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -371,6 +379,7 @@ export interface AppSettingsUpdate {
|
||||
blocked_names?: string[];
|
||||
discovery_blocked_types?: number[];
|
||||
telemetry_interval_hours?: number;
|
||||
telemetry_routed_hourly?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetrySchedule {
|
||||
@@ -380,6 +389,8 @@ export interface TelemetrySchedule {
|
||||
tracked_count: number;
|
||||
max_tracked: number;
|
||||
next_run_at: number | null;
|
||||
routed_hourly: boolean;
|
||||
next_routed_run_at: number | null;
|
||||
}
|
||||
|
||||
export interface TrackedTelemetryResponse {
|
||||
@@ -438,6 +449,7 @@ export interface RepeaterStatusResponse {
|
||||
flood_dups: number;
|
||||
direct_dups: number;
|
||||
full_events: number;
|
||||
recv_errors: number | null;
|
||||
telemetry_history: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
@@ -485,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'
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
const ENABLED_KEY = 'remoteterm-text-replace-enabled';
|
||||
const MAP_KEY = 'remoteterm-text-replace-map';
|
||||
|
||||
const DEFAULT_MAP: Record<string, string> = {
|
||||
А: 'A',
|
||||
В: 'B',
|
||||
Е: 'E',
|
||||
Ё: 'E',
|
||||
З: '3',
|
||||
К: 'K',
|
||||
М: 'M',
|
||||
Н: 'H',
|
||||
О: 'O',
|
||||
Р: 'P',
|
||||
С: 'C',
|
||||
Т: 'T',
|
||||
Х: 'X',
|
||||
Ь: 'b',
|
||||
а: 'a',
|
||||
е: 'e',
|
||||
ё: 'e',
|
||||
о: 'o',
|
||||
р: 'p',
|
||||
с: 'c',
|
||||
у: 'y',
|
||||
х: 'x',
|
||||
};
|
||||
|
||||
export const DEFAULT_MAP_JSON = JSON.stringify(DEFAULT_MAP, null, 2);
|
||||
|
||||
export function getTextReplaceEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(ENABLED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setTextReplaceEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.setItem(ENABLED_KEY, 'true');
|
||||
} else {
|
||||
localStorage.removeItem(ENABLED_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getTextReplaceMapJson(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem(MAP_KEY);
|
||||
if (raw !== null) return raw;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return DEFAULT_MAP_JSON;
|
||||
}
|
||||
|
||||
/** Persist the map JSON only if it's valid. Returns null on success or an error string. */
|
||||
export function setTextReplaceMapJson(json: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
||||
return 'Must be a JSON object.';
|
||||
const rawEntries = Object.entries(parsed);
|
||||
for (const [k, v] of rawEntries) {
|
||||
if (typeof k !== 'string' || typeof v !== 'string')
|
||||
return 'All keys and values must be strings.';
|
||||
}
|
||||
const entries = rawEntries as [string, string][];
|
||||
// Check for re-expansion: no key may appear as a substring of any replacement value.
|
||||
for (const [needle] of entries) {
|
||||
if (needle.length === 0) continue;
|
||||
for (const [, replacement] of entries) {
|
||||
if (replacement.includes(needle)) {
|
||||
return `Key "${needle}" appears inside replacement "${replacement}" and would re-expand on every keystroke.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
localStorage.setItem(MAP_KEY, json);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid JSON.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a sorted-by-length-desc array of [needle, replacement] for efficient matching. */
|
||||
function buildReplacements(json: string): [string, string][] {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, string>;
|
||||
return Object.entries(parsed)
|
||||
.filter(([k]) => k.length > 0)
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply text replacements and compute the adjusted cursor position.
|
||||
* Returns null if nothing changed.
|
||||
*/
|
||||
export function applyTextReplacements(
|
||||
text: string,
|
||||
cursorPos: number,
|
||||
mapJson: string
|
||||
): { text: string; cursor: number } | null {
|
||||
const replacements = buildReplacements(mapJson);
|
||||
if (replacements.length === 0) return null;
|
||||
|
||||
let result = '';
|
||||
let newCursor = cursorPos;
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
let matched = false;
|
||||
for (const [needle, replacement] of replacements) {
|
||||
if (text.startsWith(needle, i)) {
|
||||
result += replacement;
|
||||
// Adjust cursor if this match is before or spans the cursor
|
||||
if (i + needle.length <= cursorPos) {
|
||||
newCursor += replacement.length - needle.length;
|
||||
} else if (i < cursorPos) {
|
||||
// Cursor is inside this match — place it after the replacement
|
||||
newCursor = result.length;
|
||||
}
|
||||
i += needle.length;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
result += text[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (result === text) return null;
|
||||
return { text: result, cursor: newCursor };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface ParsedHashConversation {
|
||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'radio-app',
|
||||
'fanout',
|
||||
'database',
|
||||
'statistics',
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore==2.3.2",
|
||||
"meshcore==2.3.7",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.8",
|
||||
"boto3>=1.38.0",
|
||||
|
||||
@@ -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 = 60
|
||||
LATEST_SCHEMA_VERSION = 62
|
||||
|
||||
@@ -125,7 +125,7 @@ class TestRadioDiscovery:
|
||||
class TestRepeaterDiscovery:
|
||||
def test_produces_sensor_per_field(self):
|
||||
configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb")
|
||||
assert len(configs) == 7 # matches _REPEATER_SENSORS length
|
||||
assert len(configs) == 8 # matches _REPEATER_SENSORS length
|
||||
|
||||
topics = [t for t, _ in configs]
|
||||
assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics
|
||||
|
||||
@@ -2219,6 +2219,262 @@ class TestCollectRepeaterTelemetryLpp:
|
||||
assert "lpp_sensors" not in recorded_data
|
||||
|
||||
|
||||
class TestRunTelemetryCycleRoutedOnly:
|
||||
"""Verify that _run_telemetry_cycle(routed_only=True) skips flood repeaters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_only_skips_flood_contacts(self):
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models import AppSettings, Contact
|
||||
from app.radio_sync import _run_telemetry_cycle
|
||||
|
||||
flood_key = "aa" * 32
|
||||
direct_key = "bb" * 32
|
||||
override_key = "cc" * 32
|
||||
|
||||
flood_contact = Contact(
|
||||
public_key=flood_key,
|
||||
name="Flood",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
direct_contact = Contact(
|
||||
public_key=direct_key,
|
||||
name="Direct",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
override_contact = Contact(
|
||||
public_key=override_key,
|
||||
name="Override",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
route_override_path="ccdd",
|
||||
route_override_len=1,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=[flood_key, direct_key, override_key],
|
||||
)
|
||||
|
||||
contact_map = {
|
||||
flood_key: flood_contact,
|
||||
direct_key: direct_contact,
|
||||
override_key: override_contact,
|
||||
}
|
||||
collected_keys: list[str] = []
|
||||
|
||||
async def fake_get_by_key(key):
|
||||
return contact_map.get(key)
|
||||
|
||||
async def fake_collect(mc, contact):
|
||||
collected_keys.append(contact.public_key)
|
||||
return True
|
||||
|
||||
fake_radio_manager = MagicMock()
|
||||
fake_radio_manager.is_connected = True
|
||||
fake_radio_manager.radio_operation = MagicMock()
|
||||
|
||||
# Make radio_operation an async context manager that yields a MagicMock
|
||||
fake_mc = MagicMock()
|
||||
|
||||
class FakeRadioOp:
|
||||
async def __aenter__(self):
|
||||
return fake_mc
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.ContactRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_get_by_key,
|
||||
),
|
||||
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
|
||||
patch("app.radio_sync.radio_manager", fake_radio_manager),
|
||||
):
|
||||
await _run_telemetry_cycle(routed_only=True)
|
||||
|
||||
# Flood contact should be skipped; direct and override should be collected
|
||||
assert flood_key not in collected_keys
|
||||
assert direct_key in collected_keys
|
||||
assert override_key in collected_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_only_skips_forced_flood_override(self):
|
||||
"""A contact with a forced-flood override (path_len=-1) should be
|
||||
treated as flood even though effective_route_source is 'override'."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models import AppSettings, Contact
|
||||
from app.radio_sync import _run_telemetry_cycle
|
||||
|
||||
forced_flood_key = "aa" * 32
|
||||
direct_key = "bb" * 32
|
||||
|
||||
forced_flood_contact = Contact(
|
||||
public_key=forced_flood_key,
|
||||
name="ForcedFlood",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
route_override_path="",
|
||||
route_override_len=-1,
|
||||
route_override_hash_mode=-1,
|
||||
)
|
||||
direct_contact = Contact(
|
||||
public_key=direct_key,
|
||||
name="Direct",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
|
||||
# Verify the forced-flood contact reports "override" source
|
||||
assert forced_flood_contact.effective_route_source == "override"
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=[forced_flood_key, direct_key],
|
||||
)
|
||||
|
||||
contact_map = {forced_flood_key: forced_flood_contact, direct_key: direct_contact}
|
||||
collected_keys: list[str] = []
|
||||
|
||||
async def fake_get_by_key(key):
|
||||
return contact_map.get(key)
|
||||
|
||||
async def fake_collect(mc, contact):
|
||||
collected_keys.append(contact.public_key)
|
||||
return True
|
||||
|
||||
fake_radio_manager = MagicMock()
|
||||
fake_radio_manager.is_connected = True
|
||||
|
||||
fake_mc = MagicMock()
|
||||
|
||||
class FakeRadioOp:
|
||||
async def __aenter__(self):
|
||||
return fake_mc
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.ContactRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_get_by_key,
|
||||
),
|
||||
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
|
||||
patch("app.radio_sync.radio_manager", fake_radio_manager),
|
||||
):
|
||||
await _run_telemetry_cycle(routed_only=True)
|
||||
|
||||
# Forced-flood override should be excluded; direct should be collected
|
||||
assert forced_flood_key not in collected_keys
|
||||
assert direct_key in collected_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_cycle_includes_all_contacts(self):
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models import AppSettings, Contact
|
||||
from app.radio_sync import _run_telemetry_cycle
|
||||
|
||||
flood_key = "aa" * 32
|
||||
direct_key = "bb" * 32
|
||||
|
||||
flood_contact = Contact(
|
||||
public_key=flood_key,
|
||||
name="Flood",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
direct_contact = Contact(
|
||||
public_key=direct_key,
|
||||
name="Direct",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=[flood_key, direct_key],
|
||||
)
|
||||
|
||||
contact_map = {flood_key: flood_contact, direct_key: direct_contact}
|
||||
collected_keys: list[str] = []
|
||||
|
||||
async def fake_get_by_key(key):
|
||||
return contact_map.get(key)
|
||||
|
||||
async def fake_collect(mc, contact):
|
||||
collected_keys.append(contact.public_key)
|
||||
return True
|
||||
|
||||
fake_radio_manager = MagicMock()
|
||||
fake_radio_manager.is_connected = True
|
||||
|
||||
fake_mc = MagicMock()
|
||||
|
||||
class FakeRadioOp:
|
||||
async def __aenter__(self):
|
||||
return fake_mc
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.ContactRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_get_by_key,
|
||||
),
|
||||
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
|
||||
patch("app.radio_sync.radio_manager", fake_radio_manager),
|
||||
):
|
||||
await _run_telemetry_cycle(routed_only=False)
|
||||
|
||||
# Full cycle collects both
|
||||
assert flood_key in collected_keys
|
||||
assert direct_key in collected_keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _telemetry_collect_loop — UTC modulo scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2518,6 +2774,113 @@ class TestTelemetryCollectSchedulerDecision:
|
||||
)
|
||||
|
||||
|
||||
class TestRoutedHourlySchedulerDecision:
|
||||
"""Verify the routed_hourly feature in _maybe_run_scheduled_cycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_hourly_fires_on_non_modulo_hour(self):
|
||||
"""At 09:00 UTC with 8h interval and routed_hourly=True, the scheduler
|
||||
should call _run_telemetry_cycle(routed_only=True)."""
|
||||
import datetime as real_datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app import radio_sync
|
||||
from app.models import AppSettings
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=["aa" * 32],
|
||||
telemetry_interval_hours=8,
|
||||
telemetry_routed_hourly=True,
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_cycle(*, routed_only=False):
|
||||
calls.append({"routed_only": routed_only})
|
||||
|
||||
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
|
||||
):
|
||||
await radio_sync._maybe_run_scheduled_cycle(now)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["routed_only"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_hourly_disabled_skips_non_modulo_hour(self):
|
||||
"""At 09:00 UTC with 8h interval and routed_hourly=False, nothing runs."""
|
||||
import datetime as real_datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app import radio_sync
|
||||
from app.models import AppSettings
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=["aa" * 32],
|
||||
telemetry_interval_hours=8,
|
||||
telemetry_routed_hourly=False,
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_cycle(*, routed_only=False):
|
||||
calls.append({"routed_only": routed_only})
|
||||
|
||||
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
|
||||
):
|
||||
await radio_sync._maybe_run_scheduled_cycle(now)
|
||||
|
||||
assert len(calls) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modulo_hour_runs_full_cycle_even_with_routed_hourly(self):
|
||||
"""At 16:00 UTC with 8h interval, a normal full cycle runs regardless
|
||||
of whether routed_hourly is enabled — it covers all repeaters."""
|
||||
import datetime as real_datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app import radio_sync
|
||||
from app.models import AppSettings
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=["aa" * 32],
|
||||
telemetry_interval_hours=8,
|
||||
telemetry_routed_hourly=True,
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_cycle(*, routed_only=False):
|
||||
calls.append({"routed_only": routed_only})
|
||||
|
||||
now = real_datetime.datetime(2026, 4, 16, 16, 0, 0, tzinfo=real_datetime.UTC)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
|
||||
):
|
||||
await radio_sync._maybe_run_scheduled_cycle(now)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["routed_only"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_contacts_selected_for_radio_sync — DM-active prioritization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -722,6 +722,7 @@ class TestRepeaterStatus:
|
||||
"flood_dups": 10,
|
||||
"direct_dups": 5,
|
||||
"full_evts": 0,
|
||||
"recv_errors": 42,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -741,6 +742,7 @@ class TestRepeaterStatus:
|
||||
assert response.uptime_seconds == 86400
|
||||
assert response.sent_flood == 100
|
||||
assert response.recv_direct == 700
|
||||
assert response.recv_errors == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_504_on_timeout(self, test_db):
|
||||
|
||||
@@ -31,6 +31,7 @@ SAMPLE_STATUS = {
|
||||
"flood_dups": 5,
|
||||
"direct_dups": 2,
|
||||
"full_events": 0,
|
||||
"recv_errors": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class TestRoomStatus:
|
||||
"flood_dups": 2,
|
||||
"direct_dups": 1,
|
||||
"full_evts": 0,
|
||||
"recv_errors": 7,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -147,6 +148,7 @@ class TestRoomStatus:
|
||||
assert response.battery_volts == 4.025
|
||||
assert response.packets_received == 80
|
||||
assert response.recv_direct == 73
|
||||
assert response.recv_errors == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_acl_maps_entries(self, test_db):
|
||||
|
||||
@@ -330,3 +330,66 @@ class TestTelemetryScheduleEndpoint:
|
||||
assert schedule.tracked_count == 5
|
||||
assert schedule.options == [6, 8, 12, 24]
|
||||
assert schedule.next_run_at is not None
|
||||
|
||||
|
||||
class TestRoutedHourlySetting:
|
||||
"""Tests for the telemetry_routed_hourly setting."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_to_false(self, test_db):
|
||||
settings = await AppSettingsRepository.get()
|
||||
assert settings.telemetry_routed_hourly is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_trip_via_patch(self, test_db):
|
||||
result = await update_settings(AppSettingsUpdate(telemetry_routed_hourly=True))
|
||||
assert result.telemetry_routed_hourly is True
|
||||
|
||||
result = await update_settings(AppSettingsUpdate(telemetry_routed_hourly=False))
|
||||
assert result.telemetry_routed_hourly is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_includes_routed_fields_when_enabled(self, test_db):
|
||||
key = "aa" * 32
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name="R1", type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
await AppSettingsRepository.update(
|
||||
tracked_telemetry_repeaters=[key],
|
||||
telemetry_routed_hourly=True,
|
||||
)
|
||||
|
||||
schedule = await get_telemetry_schedule()
|
||||
|
||||
assert schedule.routed_hourly is True
|
||||
assert schedule.next_routed_run_at is not None
|
||||
assert schedule.next_run_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_omits_routed_run_when_disabled(self, test_db):
|
||||
key = "aa" * 32
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name="R1", type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
await AppSettingsRepository.update(
|
||||
tracked_telemetry_repeaters=[key],
|
||||
telemetry_routed_hourly=False,
|
||||
)
|
||||
|
||||
schedule = await get_telemetry_schedule()
|
||||
|
||||
assert schedule.routed_hourly is False
|
||||
assert schedule.next_routed_run_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_response_carries_routed_hourly(self, test_db):
|
||||
key = "bb" * 32
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name="R2", type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
await AppSettingsRepository.update(telemetry_routed_hourly=True)
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
|
||||
assert result.schedule.routed_hourly is True
|
||||
assert result.schedule.next_routed_run_at is not None
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
"""Tests for app.tcp_proxy.encoder — binary payload builders."""
|
||||
|
||||
import struct
|
||||
|
||||
from app.tcp_proxy.encoder import (
|
||||
build_contact,
|
||||
build_contact_from_dict,
|
||||
build_device_info,
|
||||
build_self_info,
|
||||
build_self_info_from_runtime,
|
||||
)
|
||||
from app.tcp_proxy.protocol import (
|
||||
PROXY_FW_VER,
|
||||
PROXY_MAX_CHANNELS,
|
||||
PROXY_MAX_CONTACTS_RAW,
|
||||
PUSH_NEW_ADVERT,
|
||||
RESP_CONTACT,
|
||||
RESP_DEVICE_INFO,
|
||||
RESP_SELF_INFO,
|
||||
)
|
||||
|
||||
EXAMPLE_KEY = "ab" * 32 # 64-char hex → 32 bytes
|
||||
|
||||
|
||||
# ── build_contact ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildContact:
|
||||
def test_basic_structure(self):
|
||||
payload = build_contact(EXAMPLE_KEY, name="Alice")
|
||||
assert payload[0] == RESP_CONTACT
|
||||
# public key at bytes 1-32
|
||||
assert payload[1:33] == bytes.fromhex(EXAMPLE_KEY)
|
||||
# total length: 1 + 32 + 1(type) + 1(flags) + 1(path) + 64(path) + 32(name) + 4(adv) + 4(lat) + 4(lon) + 4(lastmod) = 148
|
||||
assert len(payload) == 148
|
||||
|
||||
def test_push_variant(self):
|
||||
payload = build_contact(EXAMPLE_KEY, push=True)
|
||||
assert payload[0] == PUSH_NEW_ADVERT
|
||||
assert len(payload) == 148
|
||||
|
||||
def test_favorite_flag(self):
|
||||
payload = build_contact(EXAMPLE_KEY, favorite=True)
|
||||
flags_byte = payload[34] # byte 1+32+1 = 34
|
||||
assert flags_byte & 0x01 == 1
|
||||
|
||||
def test_not_favorite(self):
|
||||
payload = build_contact(EXAMPLE_KEY, favorite=False)
|
||||
flags_byte = payload[34]
|
||||
assert flags_byte & 0x01 == 0
|
||||
|
||||
def test_flood_path(self):
|
||||
payload = build_contact(EXAMPLE_KEY)
|
||||
path_byte = payload[35] # byte 1+32+1+1 = 35
|
||||
assert path_byte == 0xFF
|
||||
|
||||
def test_direct_path(self):
|
||||
payload = build_contact(
|
||||
EXAMPLE_KEY,
|
||||
direct_path="aabb",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
path_byte = payload[35]
|
||||
# mode=1 → 0x40, hops=2 → 0x02 → packed = 0x42
|
||||
assert path_byte == 0x42
|
||||
|
||||
def test_name_truncated(self):
|
||||
long_name = "A" * 50
|
||||
payload = build_contact(EXAMPLE_KEY, name=long_name)
|
||||
# name field is 32 bytes at offset 100 (1+32+1+1+1+64)
|
||||
name_bytes = payload[100:132]
|
||||
assert name_bytes == b"A" * 32
|
||||
|
||||
def test_lat_lon_encoding(self):
|
||||
payload = build_contact(EXAMPLE_KEY, lat=45.123456, lon=-122.654321)
|
||||
lat_offset = 136 # 1+32+1+1+1+64+32+4 = 136
|
||||
lat = struct.unpack_from("<i", payload, lat_offset)[0]
|
||||
lon = struct.unpack_from("<i", payload, lat_offset + 4)[0]
|
||||
assert abs(lat - 45123456) < 2
|
||||
assert abs(lon - (-122654321)) < 2
|
||||
|
||||
def test_contact_type(self):
|
||||
payload = build_contact(EXAMPLE_KEY, contact_type=2)
|
||||
assert payload[33] == 2 # type byte at offset 1+32
|
||||
|
||||
|
||||
# ── build_contact_from_dict ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildContactFromDict:
|
||||
def test_minimal_dict(self):
|
||||
data = {"public_key": EXAMPLE_KEY}
|
||||
payload = build_contact_from_dict(data)
|
||||
assert payload[0] == RESP_CONTACT
|
||||
assert len(payload) == 148
|
||||
|
||||
def test_full_dict(self):
|
||||
data = {
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"type": 1,
|
||||
"favorite": True,
|
||||
"name": "Bob",
|
||||
"direct_path": "ff",
|
||||
"direct_path_len": 1,
|
||||
"direct_path_hash_mode": 0,
|
||||
"last_advert": 1700000000,
|
||||
"lat": 37.7749,
|
||||
"lon": -122.4194,
|
||||
"first_seen": 1699000000,
|
||||
}
|
||||
payload = build_contact_from_dict(data)
|
||||
assert payload[33] == 1 # type
|
||||
assert payload[34] & 0x01 == 1 # favorite
|
||||
|
||||
def test_push_flag(self):
|
||||
data = {"public_key": EXAMPLE_KEY}
|
||||
payload = build_contact_from_dict(data, push=True)
|
||||
assert payload[0] == PUSH_NEW_ADVERT
|
||||
|
||||
|
||||
# ── build_self_info ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSelfInfo:
|
||||
def test_basic_structure(self):
|
||||
payload = build_self_info()
|
||||
assert payload[0] == RESP_SELF_INFO
|
||||
assert payload[1] == 1 # adv_type = CHAT
|
||||
# minimum length: 1+1+1+1+32+4+4+1+1+1+1+4+4+1+1 + len("RemoteTerm") = 68
|
||||
assert len(payload) >= 58
|
||||
|
||||
def test_name_appended(self):
|
||||
payload = build_self_info(name="TestNode")
|
||||
# name starts at offset 58
|
||||
name_bytes = payload[58:]
|
||||
assert name_bytes == b"TestNode"
|
||||
|
||||
def test_public_key_encoded(self):
|
||||
payload = build_self_info(public_key=EXAMPLE_KEY)
|
||||
assert payload[4:36] == bytes.fromhex(EXAMPLE_KEY)
|
||||
|
||||
def test_radio_params(self):
|
||||
payload = build_self_info(radio_freq=868.0, radio_bw=125.0, radio_sf=12, radio_cr=8)
|
||||
freq = struct.unpack_from("<I", payload, 48)[0]
|
||||
bw = struct.unpack_from("<I", payload, 52)[0]
|
||||
assert freq == 868000
|
||||
assert bw == 125000
|
||||
assert payload[56] == 12 # sf
|
||||
assert payload[57] == 8 # cr
|
||||
|
||||
def test_multi_acks_flag(self):
|
||||
on = build_self_info(multi_acks=True)
|
||||
off = build_self_info(multi_acks=False)
|
||||
assert on[44] == 1
|
||||
assert off[44] == 0
|
||||
|
||||
|
||||
class TestBuildSelfInfoFromRuntime:
|
||||
def test_from_self_info_dict(self):
|
||||
info = {
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"name": "MyRadio",
|
||||
"tx_power": 18,
|
||||
"max_tx_power": 22,
|
||||
"adv_lat": 40.0,
|
||||
"adv_lon": -74.0,
|
||||
"multi_acks": 1,
|
||||
"adv_loc_policy": 1,
|
||||
"radio_freq": 915.0,
|
||||
"radio_bw": 250.0,
|
||||
"radio_sf": 10,
|
||||
"radio_cr": 7,
|
||||
}
|
||||
payload = build_self_info_from_runtime(info)
|
||||
assert payload[0] == RESP_SELF_INFO
|
||||
assert payload[58:] == b"MyRadio"
|
||||
|
||||
def test_missing_fields_use_defaults(self):
|
||||
payload = build_self_info_from_runtime({})
|
||||
assert payload[0] == RESP_SELF_INFO
|
||||
assert payload[58:] == b"RemoteTerm"
|
||||
|
||||
|
||||
# ── build_device_info ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildDeviceInfo:
|
||||
def test_basic_structure(self):
|
||||
payload = build_device_info()
|
||||
assert payload[0] == RESP_DEVICE_INFO
|
||||
assert payload[1] == PROXY_FW_VER
|
||||
assert payload[2] == PROXY_MAX_CONTACTS_RAW
|
||||
assert payload[3] == PROXY_MAX_CHANNELS
|
||||
|
||||
def test_path_hash_mode(self):
|
||||
payload = build_device_info(path_hash_mode=2)
|
||||
# path_hash_mode is at offset 81 (1+1+1+1+4+12+40+20+1 = 81)
|
||||
assert payload[81] == 2
|
||||
|
||||
def test_expected_length(self):
|
||||
# fw_ver=11 → 1+1+1+1+4+12+40+20+1+1 = 82 bytes
|
||||
payload = build_device_info()
|
||||
assert len(payload) == 82
|
||||
@@ -1,365 +0,0 @@
|
||||
"""Integration tests for the TCP proxy — real asyncio TCP server + client."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tcp_proxy.protocol import (
|
||||
CMD_APP_START,
|
||||
CMD_DEVICE_QUERY,
|
||||
CMD_GET_CHANNEL,
|
||||
CMD_GET_CONTACTS,
|
||||
CMD_GET_DEVICE_TIME,
|
||||
CMD_HAS_CONNECTION,
|
||||
CMD_SET_CHANNEL,
|
||||
CMD_SYNC_NEXT_MESSAGE,
|
||||
FRAME_RX,
|
||||
FRAME_TX,
|
||||
PROXY_FW_VER,
|
||||
PUSH_MSG_WAITING,
|
||||
RESP_CONTACT_END,
|
||||
RESP_CONTACT_START,
|
||||
RESP_CURRENT_TIME,
|
||||
RESP_DEVICE_INFO,
|
||||
RESP_ERR,
|
||||
RESP_NO_MORE_MSGS,
|
||||
RESP_OK,
|
||||
RESP_SELF_INFO,
|
||||
)
|
||||
from app.tcp_proxy.server import dispatch_event, register, unregister
|
||||
from app.tcp_proxy.session import ProxySession
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
EXAMPLE_KEY = "ab" * 32
|
||||
|
||||
|
||||
def _frame_cmd(payload: bytes) -> bytes:
|
||||
"""Wrap a command payload in a 0x3C frame."""
|
||||
return bytes([FRAME_TX]) + len(payload).to_bytes(2, "little") + payload
|
||||
|
||||
|
||||
async def _read_response(reader: asyncio.StreamReader) -> bytes:
|
||||
"""Read one 0x3E-framed response and return the payload."""
|
||||
marker = await reader.readexactly(1)
|
||||
assert marker[0] == FRAME_RX
|
||||
size_bytes = await reader.readexactly(2)
|
||||
size = int.from_bytes(size_bytes, "little")
|
||||
payload = await reader.readexactly(size)
|
||||
return payload
|
||||
|
||||
|
||||
class _ProxyTestHarness:
|
||||
"""Manages a real TCP proxy server for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._server: asyncio.Server | None = None
|
||||
self.port: int = 0
|
||||
self.sessions: list[ProxySession] = []
|
||||
|
||||
async def start(self):
|
||||
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
|
||||
self.port = self._server.sockets[0].getsockname()[1]
|
||||
|
||||
async def stop(self):
|
||||
for s in self.sessions:
|
||||
try:
|
||||
s.writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.sessions.clear()
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
|
||||
async def _handle(self, reader, writer):
|
||||
session = ProxySession(reader, writer)
|
||||
self.sessions.append(session)
|
||||
register(session)
|
||||
try:
|
||||
await session.run()
|
||||
finally:
|
||||
unregister(session)
|
||||
|
||||
async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", self.port)
|
||||
return reader, writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def harness():
|
||||
h = _ProxyTestHarness()
|
||||
await h.start()
|
||||
yield h
|
||||
await h.stop()
|
||||
|
||||
|
||||
def _mock_repos_and_runtime():
|
||||
"""Return a context manager that mocks repositories and radio_runtime."""
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
contacts = [
|
||||
MagicMock(
|
||||
model_dump=MagicMock(
|
||||
return_value={
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"name": "Alice",
|
||||
"type": 1,
|
||||
"favorite": True,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": 0,
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"first_seen": int(time.time()),
|
||||
}
|
||||
)
|
||||
)
|
||||
]
|
||||
channels = [
|
||||
MagicMock(
|
||||
model_dump=MagicMock(return_value={"key": "cc" * 16, "name": "test", "favorite": True})
|
||||
)
|
||||
]
|
||||
settings_obj = MagicMock(last_message_times={})
|
||||
|
||||
rt = MagicMock()
|
||||
rt.is_connected = True
|
||||
mc = MagicMock()
|
||||
mc.self_info = {
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"name": "TestNode",
|
||||
"tx_power": 20,
|
||||
"max_tx_power": 22,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
"radio_freq": 915.0,
|
||||
"radio_bw": 250.0,
|
||||
"radio_sf": 10,
|
||||
"radio_cr": 7,
|
||||
}
|
||||
rt.meshcore = mc
|
||||
|
||||
class _Ctx:
|
||||
def __enter__(self_):
|
||||
self_._patches = [
|
||||
patch(
|
||||
"app.repository.ContactRepository.get_favorites",
|
||||
new_callable=AsyncMock,
|
||||
return_value=contacts,
|
||||
),
|
||||
patch(
|
||||
"app.repository.ChannelRepository.get_all",
|
||||
new_callable=AsyncMock,
|
||||
return_value=channels,
|
||||
),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings_obj,
|
||||
),
|
||||
patch(
|
||||
"app.services.radio_runtime.radio_runtime",
|
||||
rt,
|
||||
),
|
||||
]
|
||||
for p in self_._patches:
|
||||
p.__enter__()
|
||||
return self_
|
||||
|
||||
def __exit__(self_, *args):
|
||||
for p in reversed(self_._patches):
|
||||
p.__exit__(*args)
|
||||
|
||||
return _Ctx()
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTcpProxyIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_start_returns_self_info(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
with _mock_repos_and_runtime():
|
||||
writer.write(_frame_cmd(bytes([CMD_APP_START]) + b"\x03" + b" " * 6 + b"test"))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_SELF_INFO
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_query_returns_device_info(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
with _mock_repos_and_runtime():
|
||||
# First do APP_START to initialize session state
|
||||
writer.write(_frame_cmd(bytes([CMD_APP_START]) + b"\x03" + b" " * 6 + b"test"))
|
||||
await writer.drain()
|
||||
await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
|
||||
writer.write(_frame_cmd(bytes([CMD_DEVICE_QUERY, 0x03])))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_DEVICE_INFO
|
||||
assert resp[1] == PROXY_FW_VER
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_contacts_flow(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
with _mock_repos_and_runtime():
|
||||
writer.write(_frame_cmd(bytes([CMD_GET_CONTACTS])))
|
||||
await writer.drain()
|
||||
|
||||
# Should get CONTACT_START
|
||||
resp1 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp1[0] == RESP_CONTACT_START
|
||||
count = int.from_bytes(resp1[1:5], "little")
|
||||
assert count == 1
|
||||
|
||||
# One contact
|
||||
resp2 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp2[0] == 0x03 # RESP_CONTACT
|
||||
|
||||
# CONTACT_END
|
||||
resp3 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp3[0] == RESP_CONTACT_END
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_time(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
writer.write(_frame_cmd(bytes([CMD_GET_DEVICE_TIME])))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_CURRENT_TIME
|
||||
assert len(resp) == 5
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_connection(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
with _mock_repos_and_runtime():
|
||||
writer.write(_frame_cmd(bytes([CMD_HAS_CONNECTION])))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_OK
|
||||
val = int.from_bytes(resp[1:5], "little")
|
||||
assert val == 1
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_channel_returns_error(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
writer.write(_frame_cmd(bytes([CMD_GET_CHANNEL, 5])))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_ERR
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_then_get_channel(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
# SET_CHANNEL: cmd(1) + idx(1) + name(32) + secret(16) = 50
|
||||
name = b"mychan" + b"\x00" * 26 # 32 bytes
|
||||
secret = b"\xdd" * 16
|
||||
cmd = bytes([CMD_SET_CHANNEL, 2]) + name + secret
|
||||
writer.write(_frame_cmd(cmd))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_OK
|
||||
|
||||
# GET_CHANNEL for slot 2
|
||||
writer.write(_frame_cmd(bytes([CMD_GET_CHANNEL, 2])))
|
||||
await writer.drain()
|
||||
resp2 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp2[0] == 0x12 # RESP_CHANNEL_INFO
|
||||
assert resp2[1] == 2 # idx
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_next_empty(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
writer.write(_frame_cmd(bytes([CMD_SYNC_NEXT_MESSAGE])))
|
||||
await writer.drain()
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == RESP_NO_MORE_MSGS
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_dispatch_queues_message(self, harness):
|
||||
reader, writer = await harness.connect()
|
||||
try:
|
||||
with _mock_repos_and_runtime():
|
||||
# APP_START to init session
|
||||
writer.write(_frame_cmd(bytes([CMD_APP_START]) + b"\x03" + b" " * 6 + b"test"))
|
||||
await writer.drain()
|
||||
await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
|
||||
# Set a channel so CHAN messages can be routed
|
||||
name = b"\x00" * 32
|
||||
secret = bytes.fromhex("cc" * 16)
|
||||
writer.write(_frame_cmd(bytes([CMD_SET_CHANNEL, 0]) + name + secret))
|
||||
await writer.drain()
|
||||
await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
|
||||
# Simulate a broadcast event
|
||||
await dispatch_event(
|
||||
"message",
|
||||
{
|
||||
"type": "CHAN",
|
||||
"outgoing": False,
|
||||
"conversation_key": "cc" * 16,
|
||||
"text": "hello from event",
|
||||
"sender_timestamp": 1700000000,
|
||||
},
|
||||
)
|
||||
|
||||
# Should receive PUSH_MSG_WAITING
|
||||
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert resp[0] == PUSH_MSG_WAITING
|
||||
|
||||
# Pull the message
|
||||
writer.write(_frame_cmd(bytes([CMD_SYNC_NEXT_MESSAGE])))
|
||||
await writer.drain()
|
||||
msg = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||
assert msg[0] == 0x11 # RESP_CHANNEL_MSG_RECV_V3
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_clients_isolated(self, harness):
|
||||
r1, w1 = await harness.connect()
|
||||
r2, w2 = await harness.connect()
|
||||
try:
|
||||
# Both can get time independently
|
||||
w1.write(_frame_cmd(bytes([CMD_GET_DEVICE_TIME])))
|
||||
w2.write(_frame_cmd(bytes([CMD_GET_DEVICE_TIME])))
|
||||
await w1.drain()
|
||||
await w2.drain()
|
||||
|
||||
resp1 = await asyncio.wait_for(_read_response(r1), timeout=3)
|
||||
resp2 = await asyncio.wait_for(_read_response(r2), timeout=3)
|
||||
assert resp1[0] == RESP_CURRENT_TIME
|
||||
assert resp2[0] == RESP_CURRENT_TIME
|
||||
finally:
|
||||
w1.close()
|
||||
w2.close()
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Tests for app.tcp_proxy.protocol — frame parsing, helpers, constants."""
|
||||
|
||||
from app.tcp_proxy.protocol import (
|
||||
ERR_NOT_FOUND,
|
||||
ERR_UNSUPPORTED,
|
||||
FRAME_RX,
|
||||
FRAME_TX,
|
||||
RESP_ERR,
|
||||
RESP_OK,
|
||||
FrameParser,
|
||||
build_error,
|
||||
build_ok,
|
||||
encode_path_byte,
|
||||
frame_response,
|
||||
pad,
|
||||
)
|
||||
|
||||
# ── frame_response ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFrameResponse:
|
||||
def test_empty_payload(self):
|
||||
result = frame_response(b"")
|
||||
assert result == bytes([FRAME_RX, 0x00, 0x00])
|
||||
|
||||
def test_short_payload(self):
|
||||
result = frame_response(b"\x05\x01")
|
||||
assert result[0] == FRAME_RX
|
||||
size = int.from_bytes(result[1:3], "little")
|
||||
assert size == 2
|
||||
assert result[3:] == b"\x05\x01"
|
||||
|
||||
def test_larger_payload(self):
|
||||
payload = b"\xaa" * 200
|
||||
result = frame_response(payload)
|
||||
assert result[0] == FRAME_RX
|
||||
size = int.from_bytes(result[1:3], "little")
|
||||
assert size == 200
|
||||
assert result[3:] == payload
|
||||
|
||||
|
||||
# ── build_ok / build_error ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildOk:
|
||||
def test_no_value(self):
|
||||
assert build_ok() == bytes([RESP_OK])
|
||||
|
||||
def test_with_value(self):
|
||||
result = build_ok(42)
|
||||
assert result[0] == RESP_OK
|
||||
assert int.from_bytes(result[1:5], "little") == 42
|
||||
|
||||
def test_zero_value(self):
|
||||
result = build_ok(0)
|
||||
assert len(result) == 5
|
||||
assert int.from_bytes(result[1:5], "little") == 0
|
||||
|
||||
|
||||
class TestBuildError:
|
||||
def test_default_code(self):
|
||||
assert build_error() == bytes([RESP_ERR, ERR_UNSUPPORTED])
|
||||
|
||||
def test_not_found(self):
|
||||
assert build_error(ERR_NOT_FOUND) == bytes([RESP_ERR, ERR_NOT_FOUND])
|
||||
|
||||
|
||||
# ── pad ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPad:
|
||||
def test_shorter_data(self):
|
||||
result = pad(b"AB", 5)
|
||||
assert result == b"AB\x00\x00\x00"
|
||||
assert len(result) == 5
|
||||
|
||||
def test_exact_data(self):
|
||||
assert pad(b"ABCDE", 5) == b"ABCDE"
|
||||
|
||||
def test_longer_data(self):
|
||||
assert pad(b"ABCDEFGH", 5) == b"ABCDE"
|
||||
|
||||
def test_empty_data(self):
|
||||
assert pad(b"", 3) == b"\x00\x00\x00"
|
||||
|
||||
|
||||
# ── encode_path_byte ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEncodePathByte:
|
||||
def test_flood_negative_hop(self):
|
||||
assert encode_path_byte(-1, 0) == 0xFF
|
||||
|
||||
def test_flood_negative_mode(self):
|
||||
assert encode_path_byte(0, -1) == 0xFF
|
||||
|
||||
def test_flood_both_negative(self):
|
||||
assert encode_path_byte(-1, -1) == 0xFF
|
||||
|
||||
def test_zero_hops_mode_zero(self):
|
||||
assert encode_path_byte(0, 0) == 0x00
|
||||
|
||||
def test_three_hops_mode_one(self):
|
||||
# mode=1 → bits 6-7 = 01 → 0x40; hops=3 → 0x03
|
||||
assert encode_path_byte(3, 1) == 0x43
|
||||
|
||||
def test_max_hops_mode_two(self):
|
||||
# mode=2 → bits 6-7 = 10 → 0x80; hops=63 → 0x3F
|
||||
assert encode_path_byte(63, 2) == 0xBF
|
||||
|
||||
|
||||
# ── FrameParser ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFrameParser:
|
||||
def test_single_complete_frame(self):
|
||||
parser = FrameParser()
|
||||
# 0x3C + 2-byte LE size (3) + 3 bytes payload
|
||||
data = bytes([FRAME_TX, 0x03, 0x00, 0xAA, 0xBB, 0xCC])
|
||||
payloads = parser.feed(data)
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0] == b"\xaa\xbb\xcc"
|
||||
|
||||
def test_two_frames_in_one_chunk(self):
|
||||
parser = FrameParser()
|
||||
frame1 = bytes([FRAME_TX, 0x02, 0x00, 0x01, 0x02])
|
||||
frame2 = bytes([FRAME_TX, 0x01, 0x00, 0xFF])
|
||||
payloads = parser.feed(frame1 + frame2)
|
||||
assert len(payloads) == 2
|
||||
assert payloads[0] == b"\x01\x02"
|
||||
assert payloads[1] == b"\xff"
|
||||
|
||||
def test_split_across_chunks(self):
|
||||
parser = FrameParser()
|
||||
full = bytes([FRAME_TX, 0x04, 0x00, 0x01, 0x02, 0x03, 0x04])
|
||||
# Split in the middle of the payload
|
||||
p1 = parser.feed(full[:5])
|
||||
assert p1 == []
|
||||
p2 = parser.feed(full[5:])
|
||||
assert len(p2) == 1
|
||||
assert p2[0] == b"\x01\x02\x03\x04"
|
||||
|
||||
def test_split_in_header(self):
|
||||
parser = FrameParser()
|
||||
full = bytes([FRAME_TX, 0x01, 0x00, 0xAA])
|
||||
p1 = parser.feed(full[:2]) # marker + first size byte
|
||||
assert p1 == []
|
||||
p2 = parser.feed(full[2:]) # second size byte + payload
|
||||
assert len(p2) == 1
|
||||
assert p2[0] == b"\xaa"
|
||||
|
||||
def test_bad_marker_skipped(self):
|
||||
parser = FrameParser()
|
||||
junk = b"\x00\x00\x00"
|
||||
good = bytes([FRAME_TX, 0x01, 0x00, 0xBB])
|
||||
payloads = parser.feed(junk + good)
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0] == b"\xbb"
|
||||
|
||||
def test_oversized_frame_skipped(self):
|
||||
parser = FrameParser()
|
||||
# Size = 400 (> MAX_FRAME_SIZE=300)
|
||||
bad = bytes([FRAME_TX, 0x90, 0x01])
|
||||
good = bytes([FRAME_TX, 0x01, 0x00, 0xCC])
|
||||
payloads = parser.feed(bad + good)
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0] == b"\xcc"
|
||||
|
||||
def test_empty_feed(self):
|
||||
parser = FrameParser()
|
||||
assert parser.feed(b"") == []
|
||||
|
||||
def test_byte_at_a_time(self):
|
||||
parser = FrameParser()
|
||||
full = bytes([FRAME_TX, 0x02, 0x00, 0xDE, 0xAD])
|
||||
payloads = []
|
||||
for b in full:
|
||||
payloads.extend(parser.feed(bytes([b])))
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0] == b"\xde\xad"
|
||||
@@ -1,695 +0,0 @@
|
||||
"""Tests for app.tcp_proxy.session — ProxySession command handlers."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tcp_proxy.protocol import (
|
||||
CMD_APP_START,
|
||||
CMD_DEVICE_QUERY,
|
||||
CMD_GET_BATT_AND_STORAGE,
|
||||
CMD_GET_CHANNEL,
|
||||
CMD_GET_CONTACT_BY_KEY,
|
||||
CMD_GET_CONTACTS,
|
||||
CMD_GET_DEVICE_TIME,
|
||||
CMD_HAS_CONNECTION,
|
||||
CMD_RESET_PATH,
|
||||
CMD_SEND_CHANNEL_TXT_MSG,
|
||||
CMD_SEND_TXT_MSG,
|
||||
CMD_SET_CHANNEL,
|
||||
CMD_SYNC_NEXT_MESSAGE,
|
||||
ERR_NOT_FOUND,
|
||||
PROXY_FW_VER,
|
||||
PUSH_MSG_WAITING,
|
||||
RESP_BATTERY,
|
||||
RESP_CHANNEL_MSG_RECV_V3,
|
||||
RESP_CONTACT_END,
|
||||
RESP_CONTACT_MSG_RECV_V3,
|
||||
RESP_CONTACT_START,
|
||||
RESP_CURRENT_TIME,
|
||||
RESP_DEVICE_INFO,
|
||||
RESP_ERR,
|
||||
RESP_MSG_SENT,
|
||||
RESP_NO_MORE_MSGS,
|
||||
RESP_OK,
|
||||
RESP_SELF_INFO,
|
||||
encode_path_byte,
|
||||
)
|
||||
from app.tcp_proxy.session import ProxySession
|
||||
|
||||
EXAMPLE_KEY = "ab" * 32
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_session() -> tuple[ProxySession, list[bytes]]:
|
||||
"""Create a ProxySession with a capturing writer."""
|
||||
reader = AsyncMock(spec=asyncio.StreamReader)
|
||||
writer = MagicMock(spec=asyncio.StreamWriter)
|
||||
writer.get_extra_info.return_value = ("127.0.0.1", 12345)
|
||||
|
||||
sent: list[bytes] = []
|
||||
|
||||
def capture_write(data: bytes):
|
||||
sent.append(data)
|
||||
|
||||
writer.write = capture_write
|
||||
writer.drain = AsyncMock()
|
||||
|
||||
session = ProxySession(reader, writer)
|
||||
return session, sent
|
||||
|
||||
|
||||
def _extract_payloads(sent: list[bytes]) -> list[bytes]:
|
||||
"""Extract payloads from framed response bytes."""
|
||||
payloads = []
|
||||
for frame in sent:
|
||||
assert frame[0] == 0x3E
|
||||
size = int.from_bytes(frame[1:3], "little")
|
||||
payloads.append(frame[3 : 3 + size])
|
||||
return payloads
|
||||
|
||||
|
||||
def _make_contact(public_key: str = EXAMPLE_KEY, name: str = "Alice", **kw):
|
||||
return MagicMock(
|
||||
model_dump=MagicMock(
|
||||
return_value={
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": 1,
|
||||
"favorite": True,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": 0,
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"first_seen": int(time.time()),
|
||||
**kw,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _make_channel(key: str = "cc" * 16, name: str = "test", favorite: bool = True):
|
||||
return MagicMock(
|
||||
model_dump=MagicMock(return_value={"key": key, "name": name, "favorite": favorite})
|
||||
)
|
||||
|
||||
|
||||
def _make_settings(last_message_times=None):
|
||||
return MagicMock(last_message_times=last_message_times or {})
|
||||
|
||||
|
||||
def _mock_radio_runtime(connected: bool = True, self_info: dict | None = None):
|
||||
rt = MagicMock()
|
||||
rt.is_connected = connected
|
||||
mc = MagicMock()
|
||||
mc.self_info = self_info or {
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"name": "TestNode",
|
||||
"tx_power": 20,
|
||||
"max_tx_power": 22,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
"radio_freq": 915.0,
|
||||
"radio_bw": 250.0,
|
||||
"radio_sf": 10,
|
||||
"radio_cr": 7,
|
||||
}
|
||||
rt.meshcore = mc
|
||||
return rt
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAppStart:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_self_info(self):
|
||||
session, sent = _make_session()
|
||||
contacts = [_make_contact()]
|
||||
channels = [_make_channel()]
|
||||
settings = _make_settings()
|
||||
rt = _mock_radio_runtime()
|
||||
|
||||
with (
|
||||
patch("app.repository.ContactRepository") as cr,
|
||||
patch("app.repository.ChannelRepository") as chr_,
|
||||
patch("app.repository.AppSettingsRepository") as sr,
|
||||
patch("app.services.radio_runtime.radio_runtime", rt),
|
||||
):
|
||||
cr.get_favorites = AsyncMock(return_value=contacts)
|
||||
chr_.get_all = AsyncMock(return_value=channels)
|
||||
sr.get = AsyncMock(return_value=settings)
|
||||
|
||||
await session._cmd_app_start(bytes([CMD_APP_START]))
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0][0] == RESP_SELF_INFO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populates_contacts_and_channels(self):
|
||||
session, sent = _make_session()
|
||||
contacts = [_make_contact(), _make_contact(public_key="cd" * 32, name="Bob")]
|
||||
channels = [_make_channel(), _make_channel(key="dd" * 16, name="ch2")]
|
||||
settings = _make_settings()
|
||||
rt = _mock_radio_runtime()
|
||||
|
||||
with (
|
||||
patch("app.repository.ContactRepository") as cr,
|
||||
patch("app.repository.ChannelRepository") as chr_,
|
||||
patch("app.repository.AppSettingsRepository") as sr,
|
||||
patch("app.services.radio_runtime.radio_runtime", rt),
|
||||
):
|
||||
cr.get_favorites = AsyncMock(return_value=contacts)
|
||||
chr_.get_all = AsyncMock(return_value=channels)
|
||||
sr.get = AsyncMock(return_value=settings)
|
||||
|
||||
await session._cmd_app_start(bytes([CMD_APP_START]))
|
||||
|
||||
assert len(session.contacts) == 2
|
||||
# Only favorite channels are slotted
|
||||
assert len(session.channel_slots) == 2
|
||||
|
||||
|
||||
class TestDeviceQuery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_device_info(self):
|
||||
session, sent = _make_session()
|
||||
rt = _mock_radio_runtime()
|
||||
|
||||
with patch("app.services.radio_runtime.radio_runtime", rt):
|
||||
await session._cmd_device_query(bytes([CMD_DEVICE_QUERY, 0x03]))
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_DEVICE_INFO
|
||||
assert payloads[0][1] == PROXY_FW_VER
|
||||
|
||||
|
||||
class TestGetContacts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_start_contacts_end(self):
|
||||
session, sent = _make_session()
|
||||
contacts = [_make_contact()]
|
||||
|
||||
with patch("app.repository.ContactRepository") as cr:
|
||||
cr.get_favorites = AsyncMock(return_value=contacts)
|
||||
await session._cmd_get_contacts(bytes([CMD_GET_CONTACTS]))
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_CONTACT_START
|
||||
count = int.from_bytes(payloads[0][1:5], "little")
|
||||
assert count == 1
|
||||
# Middle payload(s) are contacts
|
||||
assert payloads[-1][0] == RESP_CONTACT_END
|
||||
|
||||
|
||||
class TestGetContactByKey:
|
||||
@pytest.mark.asyncio
|
||||
async def test_found(self):
|
||||
session, sent = _make_session()
|
||||
session.contacts = [
|
||||
{
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"type": 1,
|
||||
"name": "Alice",
|
||||
"favorite": True,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": 0,
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"first_seen": 0,
|
||||
}
|
||||
]
|
||||
|
||||
cmd = bytes([CMD_GET_CONTACT_BY_KEY]) + bytes.fromhex(EXAMPLE_KEY)
|
||||
await session._cmd_get_contact_by_key(cmd)
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0][0] == 0x03 # RESP_CONTACT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_found(self):
|
||||
session, sent = _make_session()
|
||||
session.contacts = []
|
||||
|
||||
cmd = bytes([CMD_GET_CONTACT_BY_KEY]) + bytes.fromhex(EXAMPLE_KEY)
|
||||
await session._cmd_get_contact_by_key(cmd)
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_ERR
|
||||
assert payloads[0][1] == ERR_NOT_FOUND
|
||||
|
||||
|
||||
class TestGetChannel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_found(self):
|
||||
session, sent = _make_session()
|
||||
key = "cc" * 16
|
||||
session.channel_slots = {0: key}
|
||||
session.channels = [{"key": key, "name": "test"}]
|
||||
|
||||
await session._cmd_get_channel(bytes([CMD_GET_CHANNEL, 0]))
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == 0x12 # RESP_CHANNEL_INFO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_slot_returns_error(self):
|
||||
session, sent = _make_session()
|
||||
session.channel_slots = {}
|
||||
|
||||
await session._cmd_get_channel(bytes([CMD_GET_CHANNEL, 5]))
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_ERR
|
||||
|
||||
|
||||
class TestSetChannel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_slot_mapping(self):
|
||||
session, sent = _make_session()
|
||||
|
||||
name = b"test" + b"\x00" * 28 # 32 bytes
|
||||
secret = b"\xaa" * 16
|
||||
cmd = bytes([CMD_SET_CHANNEL, 3]) + name + secret
|
||||
await session._cmd_set_channel(cmd)
|
||||
|
||||
assert session.channel_slots[3] == "aa" * 16
|
||||
assert session.key_to_idx["aa" * 16] == 3
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_OK
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleans_stale_mapping(self):
|
||||
session, sent = _make_session()
|
||||
|
||||
# Pre-load slot 0 with key_a
|
||||
session.channel_slots[0] = "aa" * 16
|
||||
session.key_to_idx["aa" * 16] = 0
|
||||
|
||||
# Overwrite slot 0 with key_b
|
||||
name = b"\x00" * 32
|
||||
secret_b = b"\xbb" * 16
|
||||
cmd = bytes([CMD_SET_CHANNEL, 0]) + name + secret_b
|
||||
await session._cmd_set_channel(cmd)
|
||||
|
||||
assert session.channel_slots[0] == "bb" * 16
|
||||
assert "aa" * 16 not in session.key_to_idx
|
||||
|
||||
|
||||
class TestSendDm:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_msg_sent_and_ack(self):
|
||||
session, sent = _make_session()
|
||||
session.contacts = [{"public_key": EXAMPLE_KEY}]
|
||||
|
||||
# CMD_SEND_TXT_MSG: cmd(1) + txt_type(1) + attempt(1) + ts(4) + prefix(6) + text
|
||||
prefix = bytes.fromhex(EXAMPLE_KEY[:12])
|
||||
cmd = (
|
||||
bytes([CMD_SEND_TXT_MSG, 0, 0])
|
||||
+ int(time.time()).to_bytes(4, "little")
|
||||
+ prefix
|
||||
+ b"Hello"
|
||||
)
|
||||
|
||||
with patch.object(session, "_do_send_dm", new_callable=AsyncMock):
|
||||
await session._cmd_send_dm(cmd)
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_MSG_SENT
|
||||
assert payloads[1][0] == 0x82 # PUSH_ACK
|
||||
# ACK code should match
|
||||
ack_from_sent = payloads[0][2:6]
|
||||
ack_from_push = payloads[1][1:5]
|
||||
assert ack_from_sent == ack_from_push
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_text_with_prefix(self):
|
||||
"""6-byte prefix + long text (>26 chars) must resolve correctly."""
|
||||
session, sent = _make_session()
|
||||
session.contacts = [{"public_key": EXAMPLE_KEY}]
|
||||
|
||||
prefix = bytes.fromhex(EXAMPLE_KEY[:12])
|
||||
long_text = b"A" * 50 # well over 26 chars
|
||||
cmd = (
|
||||
bytes([CMD_SEND_TXT_MSG, 0, 0])
|
||||
+ int(time.time()).to_bytes(4, "little")
|
||||
+ prefix
|
||||
+ long_text
|
||||
)
|
||||
|
||||
with patch.object(session, "_do_send_dm", new_callable=AsyncMock) as mock_send:
|
||||
await session._cmd_send_dm(cmd)
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_MSG_SENT # not ERR
|
||||
|
||||
mock_send.assert_called_once()
|
||||
call_key, call_text = mock_send.call_args[0]
|
||||
assert call_key == EXAMPLE_KEY
|
||||
assert call_text == "A" * 50
|
||||
|
||||
|
||||
class TestSendChannel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_ok(self):
|
||||
session, sent = _make_session()
|
||||
key = "cc" * 16
|
||||
session.channel_slots = {0: key}
|
||||
session.channels = [{"key": key, "name": "test"}]
|
||||
|
||||
cmd = (
|
||||
bytes([CMD_SEND_CHANNEL_TXT_MSG, 0, 0])
|
||||
+ int(time.time()).to_bytes(4, "little")
|
||||
+ b"Hello"
|
||||
)
|
||||
|
||||
fake_channel = MagicMock(name="test")
|
||||
with (
|
||||
patch(
|
||||
"app.repository.ChannelRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_channel,
|
||||
),
|
||||
patch.object(session, "_do_send_channel", new_callable=AsyncMock),
|
||||
):
|
||||
await session._cmd_send_channel(cmd)
|
||||
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_OK
|
||||
|
||||
|
||||
class TestSimpleCommands:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_time(self):
|
||||
session, sent = _make_session()
|
||||
await session._cmd_get_time(bytes([CMD_GET_DEVICE_TIME]))
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_CURRENT_TIME
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_battery(self):
|
||||
session, sent = _make_session()
|
||||
await session._cmd_battery(bytes([CMD_GET_BATT_AND_STORAGE]))
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_BATTERY
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_connection(self):
|
||||
session, sent = _make_session()
|
||||
rt = _mock_radio_runtime(connected=True)
|
||||
with patch("app.services.radio_runtime.radio_runtime", rt):
|
||||
await session._cmd_has_connection(bytes([CMD_HAS_CONNECTION]))
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_OK
|
||||
val = int.from_bytes(payloads[0][1:5], "little")
|
||||
assert val == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ok_stub(self):
|
||||
session, sent = _make_session()
|
||||
await session._cmd_ok_stub(bytes([CMD_RESET_PATH]))
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_OK
|
||||
|
||||
|
||||
class TestSyncNext:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_queue(self):
|
||||
session, sent = _make_session()
|
||||
await session._cmd_sync_next(bytes([CMD_SYNC_NEXT_MESSAGE]))
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == RESP_NO_MORE_MSGS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dequeues_message(self):
|
||||
session, sent = _make_session()
|
||||
fake_msg = bytes([0x10, 0x00, 0x00, 0x00]) + b"\xaa" * 10
|
||||
session._msg_queue.append(fake_msg)
|
||||
|
||||
await session._cmd_sync_next(bytes([CMD_SYNC_NEXT_MESSAGE]))
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0] == fake_msg
|
||||
assert len(session._msg_queue) == 0
|
||||
|
||||
|
||||
class TestExtractPathMeta:
|
||||
"""Tests for _extract_path_meta static helper."""
|
||||
|
||||
def test_no_paths(self):
|
||||
snr, path_byte = ProxySession._extract_path_meta({"paths": None})
|
||||
assert snr == 0
|
||||
assert path_byte == 0 # 0 hops, mode 0
|
||||
|
||||
def test_empty_paths_list(self):
|
||||
snr, path_byte = ProxySession._extract_path_meta({"paths": []})
|
||||
assert snr == 0
|
||||
assert path_byte == 0
|
||||
|
||||
def test_one_byte_hops(self):
|
||||
"""2 hops at 1-byte hash mode → path_byte = (0 << 6) | 2 = 0x02."""
|
||||
snr, path_byte = ProxySession._extract_path_meta(
|
||||
{
|
||||
"paths": [{"path": "aabb", "path_len": 2, "snr": None, "rssi": None}],
|
||||
}
|
||||
)
|
||||
assert path_byte == encode_path_byte(2, 0)
|
||||
assert path_byte == 0x02
|
||||
|
||||
def test_two_byte_hops(self):
|
||||
"""3 hops at 2-byte hash mode → path_byte = (1 << 6) | 3 = 0x43."""
|
||||
snr, path_byte = ProxySession._extract_path_meta(
|
||||
{
|
||||
"paths": [{"path": "aabbccddee11", "path_len": 3, "snr": None, "rssi": None}],
|
||||
}
|
||||
)
|
||||
assert path_byte == encode_path_byte(3, 1)
|
||||
assert path_byte == 0x43
|
||||
|
||||
def test_three_byte_hops(self):
|
||||
"""1 hop at 3-byte hash mode → path_byte = (2 << 6) | 1 = 0x81."""
|
||||
snr, path_byte = ProxySession._extract_path_meta(
|
||||
{
|
||||
"paths": [{"path": "aabbcc", "path_len": 1, "snr": None, "rssi": None}],
|
||||
}
|
||||
)
|
||||
assert path_byte == encode_path_byte(1, 2)
|
||||
assert path_byte == 0x81
|
||||
|
||||
def test_snr_encoded(self):
|
||||
"""SNR is encoded as int8(snr * 4)."""
|
||||
snr, _ = ProxySession._extract_path_meta(
|
||||
{
|
||||
"paths": [{"path": "aa", "path_len": 1, "snr": -5.25, "rssi": -100}],
|
||||
}
|
||||
)
|
||||
assert snr == (-21) & 0xFF # -5.25 * 4 = -21 → unsigned byte
|
||||
|
||||
def test_zero_hops_empty_path(self):
|
||||
"""0 hops, empty path → path_byte 0."""
|
||||
snr, path_byte = ProxySession._extract_path_meta(
|
||||
{
|
||||
"paths": [{"path": "", "path_len": 0, "snr": None, "rssi": None}],
|
||||
}
|
||||
)
|
||||
assert path_byte == 0
|
||||
|
||||
def test_legacy_no_path_len(self):
|
||||
"""path_len=None falls back to inferring from hex length (1-byte hops)."""
|
||||
snr, path_byte = ProxySession._extract_path_meta(
|
||||
{
|
||||
"paths": [{"path": "aabb", "path_len": None, "snr": None, "rssi": None}],
|
||||
}
|
||||
)
|
||||
# Inferred: 2 hops, path is 2 bytes → 1-byte hash → mode 0
|
||||
assert path_byte == encode_path_byte(2, 0)
|
||||
|
||||
|
||||
class TestEventHandlers:
|
||||
@pytest.mark.asyncio
|
||||
async def test_priv_message_queued(self):
|
||||
session, sent = _make_session()
|
||||
data = {
|
||||
"type": "PRIV",
|
||||
"outgoing": False,
|
||||
"conversation_key": EXAMPLE_KEY,
|
||||
"text": "hello",
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
await session.on_event_message(data)
|
||||
|
||||
assert len(session._msg_queue) == 1
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == PUSH_MSG_WAITING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priv_message_path_encoding(self):
|
||||
"""DM frame encodes path_len byte from message path data."""
|
||||
session, sent = _make_session()
|
||||
data = {
|
||||
"type": "PRIV",
|
||||
"outgoing": False,
|
||||
"conversation_key": EXAMPLE_KEY,
|
||||
"text": "hi",
|
||||
"sender_timestamp": 1700000000,
|
||||
"paths": [{"path": "aabb", "path_len": 2, "snr": 3.0, "rssi": -80}],
|
||||
}
|
||||
await session.on_event_message(data)
|
||||
|
||||
frame = session._msg_queue[0]
|
||||
assert frame[0] == RESP_CONTACT_MSG_RECV_V3
|
||||
snr_byte = frame[1]
|
||||
assert snr_byte == 12 # 3.0 * 4
|
||||
# path_len byte is at offset 10 (after: type, snr, 2 reserved, 6 prefix)
|
||||
path_byte = frame[10]
|
||||
assert path_byte == encode_path_byte(2, 0) # 2 hops, 1-byte hash
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chan_message_queued(self):
|
||||
session, sent = _make_session()
|
||||
key = "cc" * 16
|
||||
session.key_to_idx = {key: 0}
|
||||
|
||||
data = {
|
||||
"type": "CHAN",
|
||||
"outgoing": False,
|
||||
"conversation_key": key.upper(), # test case normalization
|
||||
"text": "hello",
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
await session.on_event_message(data)
|
||||
|
||||
assert len(session._msg_queue) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chan_message_path_encoding(self):
|
||||
"""Channel frame encodes path_len byte correctly instead of 0xFF."""
|
||||
session, sent = _make_session()
|
||||
key = "cc" * 16
|
||||
session.key_to_idx = {key: 0}
|
||||
|
||||
data = {
|
||||
"type": "CHAN",
|
||||
"outgoing": False,
|
||||
"conversation_key": key,
|
||||
"text": "hello",
|
||||
"sender_timestamp": 1700000000,
|
||||
"paths": [{"path": "aabbccdd", "path_len": 2, "snr": -2.5, "rssi": -90}],
|
||||
}
|
||||
await session.on_event_message(data)
|
||||
|
||||
frame = session._msg_queue[0]
|
||||
assert frame[0] == RESP_CHANNEL_MSG_RECV_V3
|
||||
snr_byte = frame[1]
|
||||
assert snr_byte == (-10) & 0xFF # -2.5 * 4
|
||||
# path_len byte is at offset 5 (after: type, snr, 2 reserved, channel_idx)
|
||||
path_byte = frame[5]
|
||||
assert path_byte == encode_path_byte(2, 1) # 2 hops, 2-byte hash
|
||||
assert path_byte != 0xFF # Must NOT be the old wrong value
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chan_message_no_paths_defaults_zero(self):
|
||||
"""Channel message with no path data uses 0 (not 0xFF)."""
|
||||
session, sent = _make_session()
|
||||
key = "cc" * 16
|
||||
session.key_to_idx = {key: 0}
|
||||
|
||||
data = {
|
||||
"type": "CHAN",
|
||||
"outgoing": False,
|
||||
"conversation_key": key,
|
||||
"text": "hello",
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
await session.on_event_message(data)
|
||||
|
||||
frame = session._msg_queue[0]
|
||||
path_byte = frame[5]
|
||||
assert path_byte == 0 # 0 hops, not 0xFF
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_message_ignored(self):
|
||||
session, sent = _make_session()
|
||||
data = {"type": "PRIV", "outgoing": True, "conversation_key": EXAMPLE_KEY}
|
||||
await session.on_event_message(data)
|
||||
assert len(session._msg_queue) == 0
|
||||
assert len(sent) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chan_unmapped_dropped(self):
|
||||
session, sent = _make_session()
|
||||
session.key_to_idx = {}
|
||||
data = {
|
||||
"type": "CHAN",
|
||||
"outgoing": False,
|
||||
"conversation_key": "ff" * 16,
|
||||
"text": "hello",
|
||||
"sender_timestamp": 0,
|
||||
}
|
||||
await session.on_event_message(data)
|
||||
assert len(session._msg_queue) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_event_updates_existing_cache(self):
|
||||
session, sent = _make_session()
|
||||
# Contact must already be in favorites cache to receive pushes
|
||||
session.contacts = [
|
||||
{
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"name": "Old",
|
||||
"type": 1,
|
||||
"favorite": True,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": 0,
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"first_seen": 0,
|
||||
}
|
||||
]
|
||||
|
||||
data = {
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"type": 1,
|
||||
"name": "Updated",
|
||||
"favorite": True,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": 100,
|
||||
"lat": 0.0,
|
||||
"lon": 0.0,
|
||||
"first_seen": 0,
|
||||
}
|
||||
await session.on_event_contact(data)
|
||||
assert len(session.contacts) == 1
|
||||
assert session.contacts[0]["name"] == "Updated"
|
||||
# Should have sent a PUSH_NEW_ADVERT
|
||||
payloads = _extract_payloads(sent)
|
||||
assert payloads[0][0] == 0x8A # PUSH_NEW_ADVERT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_event_ignored_for_non_favorites(self):
|
||||
session, sent = _make_session()
|
||||
session.contacts = []
|
||||
|
||||
data = {
|
||||
"public_key": EXAMPLE_KEY,
|
||||
"type": 1,
|
||||
"name": "Stranger",
|
||||
"favorite": False,
|
||||
}
|
||||
await session.on_event_contact(data)
|
||||
assert len(session.contacts) == 0
|
||||
assert len(sent) == 0
|
||||
@@ -768,7 +768,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meshcore"
|
||||
version = "2.3.2"
|
||||
version = "2.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bleak" },
|
||||
@@ -776,9 +776,9 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyserial-asyncio-fast" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/d1/e45d8fa3cac24d58c3bc2523fe67b8cd00c05ea68e1704fbbaf56cb19753/meshcore-2.3.7.tar.gz", hash = "sha256:267107e09a96f7d0d63f4bdb1402d033a724baadd9c9becf9b71a458170f60bb", size = 90787 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3d/ff4b5971a3210da07dc793b54af9b1231fea42dfb87e2818fdcc83e10d72/meshcore-2.3.7-py3-none-any.whl", hash = "sha256:952f028b25527155e78103d01598fa3897cccfa793ba2028a32bc36c86759f14", size = 60352 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1569,7 +1569,7 @@ requires-dist = [
|
||||
{ name = "boto3", specifier = ">=1.38.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "meshcore", specifier = "==2.3.2" },
|
||||
{ name = "meshcore", specifier = "==2.3.7" },
|
||||
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
|
||||
Reference in New Issue
Block a user