Compare commits

..

22 Commits

Author SHA1 Message Date
Jack Kingsman 9358bf4199 Misc. bug fixes around prefixing and channel message receipt 2026-04-25 14:25:29 -07:00
Jack Kingsman c31779f1a9 Initial tcp proxy testing 2026-04-24 18:06:25 -07:00
Jack Kingsman 4eb29f376e Make clearer save button for advert interval 2026-04-24 14:44:27 -07:00
Jack Kingsman a69eb9c534 Updating changelog + build for 3.12.3 2026-04-24 14:03:11 -07:00
Jack Kingsman 70aabb78aa Remove MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT from standard README -- new users don't need that. 2026-04-23 12:38:42 -07:00
Jack Kingsman cafd9678ee Fix e2e tests after apprise message updates 2026-04-22 18:12:01 -07:00
Jack Kingsman a8e346d0c5 Docs, schema, and error handling improvements 2026-04-22 18:08:47 -07:00
Jack Kingsman 55f05bf03b Add dropdown to choose contact addition type. Closes #216. 2026-04-22 17:43:01 -07:00
Jack Kingsman 091ba06ccf Make bulk delete sortable and filterable by last-heard. Closes #218. 2026-04-22 17:01:50 -07:00
Jack Kingsman c5c828a4ed Bypass error on fail-to-unload-contact-because-it's-not-there 2026-04-21 20:38:05 -07:00
Jack Kingsman 7eac3a9754 Use padding in repeaters 2026-04-21 20:15:16 -07:00
Jack Kingsman 329df1a0d2 Add conversational padding to toasts. Closes #214. 2026-04-21 20:09:30 -07:00
Jack Kingsman ecb4c99a43 Make Apprise strings customizable. Closes #212. 2026-04-21 19:40:14 -07:00
Jack Kingsman 2f412e1a93 Be clearer about private key export 2026-04-21 13:47:46 -07:00
Jack Kingsman 0353a98e87 Merge pull request #213 from jkingsman/dependabot/uv/uv-4ea199e985
Bump python-dotenv from 1.2.1 to 1.2.2 in the uv group across 1 directory
2026-04-21 13:15:00 -07:00
dependabot[bot] 3e2258c34b Bump python-dotenv in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [python-dotenv](https://github.com/theskumar/python-dotenv).


Updates `python-dotenv` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.2
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 20:11:34 +00:00
Jack Kingsman e695d629b9 Updating changelog + build for 3.12.2 2026-04-21 13:10:25 -07:00
Jack Kingsman 300677aca3 Disambiguate colliding sensors and display all. Closes #211. 2026-04-21 10:14:09 -07:00
jkingsman b89f7ce76b Add missing docs around pk export 2026-04-20 20:10:21 -07:00
Jack Kingsman 82bd25a09f Merge pull request #210 from jkingsman/radio-config-export
Add config export
2026-04-20 19:58:58 -07:00
Jack Kingsman b8f0228f68 Merge pull request #209 from kizniche/fix-stale-mqtt-radio-values
Fix Community MQTT publishing stale firmware_version and model
2026-04-20 19:48:11 -07:00
Kizniche 25089930f1 fIX Community MQTT publishing stale firmware_version and model 2026-04-20 21:47:38 -04:00
52 changed files with 4002 additions and 193 deletions
+7
View File
@@ -22,6 +22,7 @@ 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
@@ -321,6 +322,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
| GET | `/api/radio/private-key` | Export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`) |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
@@ -379,6 +381,7 @@ 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/muted-channels/toggle` | Toggle muted status for a channel |
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
@@ -504,6 +507,10 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `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`.
+17
View File
@@ -1,3 +1,20 @@
## [3.12.3] - 2026-04-24
* Feature: Customizable Apprise strings
* Feature: Choose contact addition type
* Featuer: Make bulk-delete sortable by last-heard
* Misc: Bypass error on fail-to-unload-contact when it's not there
* Misc: Docs & test updates
## [3.12.2] - 2026-04-21
* Feature: Auto-disambiguate colliding LPP sensor names
* Feature: Radio config import/export
* Bugfix: Don't push stale firmware version/model on community MQTT
* Misc: Expose env vars in debug blob
* Misc: Longer linger for web push error
* Misc: Docs, test, & CI/CD improvements
## [3.12.1] - 2026-04-19
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
+50 -7
View File
@@ -1,25 +1,68 @@
# Advanced Setup And Troubleshooting
## Remediation Environment Variables
## Remediation & Advanced Environment Variables
These are intended for diagnosing or working around radios that behave oddly.
These are intended for diagnosing or working around radios that behave oddly, or enabling advanced functionality.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading (see [Contact Loading Issues](#contact-loading-issues) below) |
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages ([docs](#message-poll-fallback)) |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send ([docs](#force-channel-slot-reconfigure)) |
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading ([docs](#autoevict-mode)) |
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot ([docs](#clock-wraparound)) |
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex for backup or migration. Only enable on a trusted network. Import via `PUT /api/radio/private-key` is always available. ([docs](#private-key-export)) |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
- whether messages were left on the radio without reaching the app through event subscription
- whether the app's channel-slot expectations still match the radio's actual channel listing
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
If the audit finds a mismatch, you'll see an error in the application UI and your logs.
### Message Poll Fallback
If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net.
### Force Channel Slot Reconfigure
If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
### Clock Wraparound
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
### Private Key Export
`MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true` enables `GET /api/radio/private-key`, which returns the in-memory private key as hex for backup or migration. The key is held in memory only (exported from the radio on connect) and is never persisted to disk. Only enable this on a trusted network when you need to retrieve the key.
Import via `PUT /api/radio/private-key` is always available regardless of this setting — it is write-only and does not expose key material.
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.
+15 -2
View File
@@ -55,6 +55,7 @@ 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
@@ -196,6 +197,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
### Radio
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
- `PUT /radio/private-key`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
@@ -266,6 +268,7 @@ 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/muted-channels/toggle`
### Fanout
- `GET /fanout` — list all fanout configs
@@ -396,7 +399,7 @@ tests/
├── test_message_prefix_claim.py # Message prefix claim logic
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
├── test_messages_search.py # Message search, around, forward pagination
├── test_migrations.py # Schema migration system
├── test_mqtt_ha.py # MQTT HA (high-availability) behavior
├── test_packet_pipeline.py # End-to-end packet processing
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
├── test_path_utils.py # Path hex rendering helpers
@@ -415,10 +418,20 @@ tests/
├── test_security.py # Optional Basic Auth middleware / config behavior
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_push_send.py # Web Push send/dispatch
├── test_radio_stats.py # Radio stats sampling and noise-floor history
├── test_repeater_telemetry.py # Repeater telemetry history recording
├── test_service_installer.py # Service installer script behavior
├── test_sqs_fanout.py # SQS fanout module
├── test_statistics.py # Statistics aggregation
├── 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_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
```
## Errata & Known Non-Issues
+3
View File
@@ -31,6 +31,9 @@ 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":
+18 -2
View File
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS channels (
flood_scope_override TEXT,
path_hash_mode_override INTEGER,
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
favorite INTEGER DEFAULT 0,
muted INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
@@ -112,7 +113,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
discovery_blocked_types TEXT DEFAULT '[]',
tracked_telemetry_repeaters TEXT DEFAULT '[]',
auto_resend_channel INTEGER DEFAULT 0,
telemetry_interval_hours INTEGER DEFAULT 8
telemetry_interval_hours INTEGER DEFAULT 8,
vapid_private_key TEXT DEFAULT '',
vapid_public_key TEXT DEFAULT '',
push_conversations TEXT DEFAULT '[]'
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
@@ -134,6 +138,18 @@ CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_success_at INTEGER,
failure_count INTEGER DEFAULT 0,
UNIQUE(endpoint)
);
"""
# Indexes are created after migrations so that legacy databases have all
+127 -36
View File
@@ -11,6 +11,28 @@ from app.path_utils import split_path_hex
logger = logging.getLogger(__name__)
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
DEFAULT_BODY_FORMAT_CHANNEL = (
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
)
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
# Variables available for user format strings
FORMAT_VARIABLES = (
"type",
"text",
"sender_name",
"sender_key",
"channel_name",
"conversation_key",
"hops",
"hops_backticked",
"hop_count",
"rssi",
"snr",
)
def _parse_urls(raw: str) -> list[str]:
"""Split multi-line URL string into individual URLs."""
@@ -36,41 +58,91 @@ def _normalize_discord_url(url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _format_body(data: dict, *, include_path: bool) -> str:
"""Build a human-readable notification body from message data."""
def _compute_hops(data: dict) -> tuple[str, str, int]:
"""Extract hop info from message data. Returns (hops, hops_backticked, hop_count)."""
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
path_str = first_path.get("path", "")
path_len = first_path.get("path_len")
else:
path_str = None
path_len = None
if path_str is None or path_str.strip() == "":
return ("direct", "`direct`", 0)
path_str = path_str.strip().lower()
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
hops = split_path_hex(path_str, hop_count)
if not hops:
return ("direct", "`direct`", 0)
return (
", ".join(hops),
", ".join(f"`{h}`" for h in hops),
len(hops),
)
def _build_template_vars(data: dict) -> dict[str, str]:
"""Build the variable dict for format string substitution."""
hops_raw, hops_bt, hop_count = _compute_hops(data)
paths = data.get("paths")
rssi = ""
snr = ""
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
rssi_val = first_path.get("rssi")
snr_val = first_path.get("snr")
if rssi_val is not None:
rssi = str(rssi_val)
if snr_val is not None:
snr = str(snr_val)
return {
"type": data.get("type", ""),
"text": get_fanout_message_text(data),
"sender_name": data.get("sender_name") or "Unknown",
"sender_key": data.get("sender_key") or "",
"channel_name": data.get("channel_name") or data.get("conversation_key", "channel"),
"conversation_key": data.get("conversation_key", ""),
"hops": hops_raw,
"hops_backticked": hops_bt,
"hop_count": str(hop_count),
"rssi": rssi,
"snr": snr,
}
def _apply_format(fmt: str, variables: dict[str, str]) -> str:
"""Apply template variables in a single pass to avoid re-expanding substituted values."""
import re
def _replacer(m: re.Match[str]) -> str:
key = m.group(1)
return variables.get(key, m.group(0))
return re.sub(r"\{(\w+)\}", _replacer, fmt)
def _format_body(
data: dict,
*,
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
) -> str:
"""Build a notification body from message data using format strings."""
variables = _build_template_vars(data)
msg_type = data.get("type", "")
text = get_fanout_message_text(data)
sender_name = data.get("sender_name") or "Unknown"
via = ""
if include_path:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
first_path = paths[0] if isinstance(paths[0], dict) else {}
path_str = first_path.get("path", "")
path_len = first_path.get("path_len")
else:
path_str = None
path_len = None
if msg_type == "PRIV" and path_str is None:
via = " **via:** [`direct`]"
elif path_str is not None:
path_str = path_str.strip().lower()
if path_str == "":
via = " **via:** [`direct`]"
else:
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
hops = split_path_hex(path_str, hop_count)
if hops:
hop_list = ", ".join(f"`{h}`" for h in hops)
via = f" **via:** [{hop_list}]"
if msg_type == "PRIV":
return f"**DM:** {sender_name}: {text}{via}"
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
return f"**{channel_name}:** {sender_name}: {text}{via}"
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
try:
return _apply_format(fmt, variables)
except Exception:
logger.warning("Apprise format string error, falling back to default")
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
return _apply_format(default, variables)
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
@@ -106,8 +178,27 @@ class AppriseModule(FanoutModule):
return
preserve_identity = self.config.get("preserve_identity", True)
include_path = self.config.get("include_path", True)
body = _format_body(data, include_path=include_path)
# Read format strings; treat empty/whitespace as unset (use default).
# Fall back to legacy include_path for pre-migration configs.
body_format_dm = (self.config.get("body_format_dm") or "").strip() or None
body_format_channel = (self.config.get("body_format_channel") or "").strip() or None
if body_format_dm is None or body_format_channel is None:
include_path = self.config.get("include_path", True)
if body_format_dm is None:
body_format_dm = (
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
if body_format_channel is None:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
body = _format_body(
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
)
try:
success = await asyncio.to_thread(
+15 -1
View File
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
device_info = await self._fetch_device_info()
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
# radio_lifecycle) over the per-module _cached_device_info, which was only
# cleared on module restart and therefore served stale firmware versions after
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
# where device_info_loaded is False.
if radio_manager.device_info_loaded:
raw_ver = radio_manager.firmware_version or "unknown"
fw_build = radio_manager.firmware_build or ""
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
device_info = {
"model": radio_manager.device_model or "unknown",
"firmware_version": fw_str,
}
else:
device_info = await self._fetch_device_info()
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
status_topic = _build_status_topic(settings, pubkey_hex)
+24 -8
View File
@@ -115,6 +115,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
return f"lpp_{type_name}_ch{channel}"
def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]:
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
"""
counts: dict[str, int] = {}
result: list[tuple[dict, str, int]] = []
for sensor in lpp_sensors:
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
n = counts.get(base, 0) + 1
counts[base] = n
result.append((sensor, base if n == 1 else f"{base}_{n}", n))
return result
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Build the flat HA state payload for a repeater telemetry snapshot."""
payload: dict[str, Any] = {}
@@ -123,8 +139,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
if field is not None:
payload[field] = data.get(field)
for sensor in data.get("lpp_sensors", []) or []:
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
payload[key] = sensor.get("value")
return payload
@@ -139,16 +154,19 @@ def _lpp_discovery_configs(
) -> list[tuple[str, dict]]:
"""Build HA discovery configs for a repeater's LPP sensors."""
configs: list[tuple[str, dict]] = []
for sensor in lpp_sensors:
for sensor, field, occurrence in _assign_lpp_keys(lpp_sensors):
type_name = sensor.get("type_name", "unknown")
channel = sensor.get("channel", 0)
field = _lpp_sensor_key(type_name, channel)
meta = _LPP_HA_META.get(type_name, {})
nid = _node_id(pub_key)
object_id = field
display = type_name.replace("_", " ").title()
name = f"{display} (Ch {channel})"
name = (
f"{display} (Ch {channel})"
if occurrence == 1
else f"{display} (Ch {channel}) #{occurrence}"
)
cfg: dict[str, Any] = {
"name": name,
@@ -731,9 +749,7 @@ class MqttHaModule(FanoutModule):
payload = _repeater_telemetry_payload(data)
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
rediscover = False
for sensor in lpp_sensors:
# Check if discovery for this sensor has been published yet
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
for _, key, _ in _assign_lpp_keys(lpp_sensors):
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
if expected_topic not in self._discovery_topics:
rediscover = True
+22 -9
View File
@@ -2,13 +2,14 @@ import logging
import sys
# ---------------------------------------------------------------------------
# Windows event-loop advisory for MQTT fanout
# Windows event-loop advisory for MQTT fanout and TCP proxy
# ---------------------------------------------------------------------------
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
# 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.
# 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.
# ---------------------------------------------------------------------------
if sys.platform == "win32":
import asyncio as _asyncio
@@ -21,12 +22,15 @@ 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 (aiomqtt / paho-mqtt).\n"
" compatible with MQTT fanout or the TCP proxy.\n"
"\n"
" If you use MQTT integrations, restart with --loop none:\n"
" If you use either feature, restart with:\n"
"\n"
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
" [... other options ...]\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"
"\n"
" Everything else works fine as-is.\n"
"\n" + "!" * 78 + "\n",
@@ -130,12 +134,21 @@ 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,57 @@
import json
import logging
import aiosqlite
logger = logging.getLogger(__name__)
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
DEFAULT_BODY_FORMAT_CHANNEL = (
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
)
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
async def migrate(conn: aiosqlite.Connection) -> None:
"""Migrate apprise fanout configs from include_path boolean to format strings."""
table_check = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
)
if not await table_check.fetchone():
await conn.commit()
return
cursor = await conn.execute("SELECT id, config FROM fanout_configs WHERE type = 'apprise'")
rows = await cursor.fetchall()
for row in rows:
config_id = row["id"] if isinstance(row, dict) else row[0]
config_raw = row["config"] if isinstance(row, dict) else row[1]
try:
config = json.loads(config_raw)
except (json.JSONDecodeError, TypeError):
continue
# Skip if already migrated
if "body_format_dm" in config:
continue
include_path = config.get("include_path", True)
config["body_format_dm"] = (
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
config["body_format_channel"] = (
DEFAULT_BODY_FORMAT_CHANNEL if include_path else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
config.pop("include_path", None)
await conn.execute(
"UPDATE fanout_configs SET config = ? WHERE id = ?",
(json.dumps(config), config_id),
)
logger.info(
"Migrated apprise config %s: include_path=%s -> format strings", config_id, include_path
)
await conn.commit()
+3
View File
@@ -221,6 +221,9 @@ class CreateContactRequest(BaseModel):
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
name: str | None = Field(default=None, description="Display name for the contact")
type: int = Field(
default=0, ge=0, le=3, description="Contact type (0=unknown, 1=client, 2=repeater, 3=room)"
)
try_historical: bool = Field(
default=False,
description="Attempt to decrypt historical DM packets for this contact",
+6 -1
View File
@@ -1273,7 +1273,12 @@ async def _reconcile_radio_contacts_in_background(
continue
budget -= 1
if remove_result.type == EventType.OK:
not_found = (
remove_result.type != EventType.OK
and isinstance(remove_result.payload, dict)
and remove_result.payload.get("error_code") == 2
)
if remove_result.type == EventType.OK or not_found:
radio_contacts.pop(public_key, None)
_evict_removed_contact_from_library_cache(mc, public_key)
removed += 1
+1
View File
@@ -315,6 +315,7 @@ async def create_contact(
contact_upsert = ContactUpsert(
public_key=lower_key,
name=request.name,
type=request.type,
on_radio=False,
)
await ContactRepository.upsert(contact_upsert)
+15
View File
@@ -259,6 +259,21 @@ def _validate_apprise_config(config: dict) -> None:
if not urls or not urls.strip():
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
from app.fanout.apprise_mod import FORMAT_VARIABLES, _apply_format
dummy_vars: dict[str, str] = dict.fromkeys(FORMAT_VARIABLES, "test")
for field in ("body_format_dm", "body_format_channel"):
value = config.get(field)
if value is not None and not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"{field} must be a string")
if isinstance(value, str) and value.strip():
try:
_apply_format(value, dummy_vars)
except Exception:
raise HTTPException(
status_code=400, detail=f"Invalid format string in {field}"
) from None
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""
+1 -1
View File
@@ -862,7 +862,7 @@ async def send_channel_message_to_channel(
)
)
except Exception:
pass # Never let watchdog setup failure break the send
logger.error("Echo watchdog setup failed", exc_info=True)
return outgoing_message
+118
View File
@@ -0,0 +1,118 @@
# 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
```
+28
View File
@@ -0,0 +1,28 @@
"""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()
+165
View File
@@ -0,0 +1,165 @@
"""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)
+195
View File
@@ -0,0 +1,195 @@
"""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
+92
View File
@@ -0,0 +1,92 @@
"""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")
+683
View File
@@ -0,0 +1,683 @@
"""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)
+9
View File
@@ -117,6 +117,15 @@ 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.
+1
View File
@@ -44,6 +44,7 @@ services:
# MESHCORE_DISABLE_BOTS: "true"
# MESHCORE_BASIC_AUTH_USERNAME: changeme
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
# MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false"
# Logging
# MESHCORE_LOG_LEVEL: INFO
+3 -6
View File
@@ -75,7 +75,6 @@ frontend/src/
├── utils/
│ ├── urlHash.ts # Hash parsing and encoding
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
│ ├── favorites.ts # LocalStorage migration for favorites
│ ├── messageParser.ts # Message text → rendered segments
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
@@ -132,6 +131,9 @@ frontend/src/
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
│ ├── ChannelPathHashModeOverrideModal.tsx # Per-channel path hash mode override editor
│ ├── BulkAddChannelResultModal.tsx # Results dialog for bulk channel creation
│ ├── CommandPalette.tsx # Command palette overlay
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
@@ -178,7 +180,6 @@ frontend/src/
├── prefetch.test.ts
├── rawPacketDetailModal.test.tsx
├── rawPacketFeedView.test.tsx
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterFormatters.test.ts
@@ -350,10 +351,6 @@ It falls back to a 12-char prefix when `name` is missing.
Distance/validation helpers used by path + map UI.
### `utils/favorites.ts`
LocalStorage migration helpers for favorites; canonical favorites are server-side.
## Types and Contracts (`types.ts`)
`AppSettings` currently includes:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.12.1",
"version": "3.12.3",
"type": "module",
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -158,10 +158,10 @@ export const api = {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_keys: publicKeys }),
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
createContact: (publicKey: string, name?: string, tryHistorical?: boolean, type?: number) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
body: JSON.stringify({ public_key: publicKey, name, type, try_historical: tryHistorical }),
}),
markContactRead: (publicKey: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
+35 -2
View File
@@ -1,4 +1,12 @@
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
type ComponentProps,
} from 'react';
import { useSwipeable } from 'react-swipeable';
import { StatusBar } from './StatusBar';
@@ -140,6 +148,26 @@ export function AppShell({
crackerMounted.current = true;
}
// Position toasts below the conversation header when in chat, otherwise below the status bar
const TOAST_TOP_PADDING = 10;
const [toastTopOffset, setToastTopOffset] = useState<number | undefined>(undefined);
const hasLocalLabel = !!localLabel.text;
const activeType = conversationPaneProps.activeConversation?.type;
const activeId = conversationPaneProps.activeConversation?.id;
useEffect(() => {
const measure = () => {
const anchor =
document.querySelector('[data-toast-anchor="conversation"]') ??
document.querySelector('[data-toast-anchor="statusbar"]');
setToastTopOffset(
anchor ? anchor.getBoundingClientRect().top + TOAST_TOP_PADDING : undefined
);
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [hasLocalLabel, activeType, activeId, showSettings]);
const settingsSidebarContent = (
<nav
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
@@ -220,6 +248,7 @@ export function AppShell({
onSettingsClick={onToggleSettingsView}
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
/>
<div data-toast-anchor="statusbar" aria-hidden="true" />
<div className="flex flex-1 overflow-hidden">
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
@@ -344,7 +373,11 @@ export function AppShell({
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} />
<Toaster position="top-right" />
<Toaster
position="top-right"
offset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
mobileOffset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
/>
</div>
);
}
@@ -326,6 +326,7 @@ export function ConversationPane({
{activeContactIsRoom && activeContact && (
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
)}
{showRoomChat && <div data-toast-anchor="conversation" aria-hidden="true" />}
{showRoomChat && (
<MessageList
key={activeConversation.id}
+22 -2
View File
@@ -32,7 +32,12 @@ interface NewMessageModalProps {
nonce: number;
} | null;
onClose: () => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateContact: (
name: string,
publicKey: string,
tryHistorical: boolean,
type?: number
) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
@@ -91,6 +96,7 @@ export function NewMessageModal({
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactType, setContactType] = useState(1);
const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
@@ -103,6 +109,7 @@ export function NewMessageModal({
const resetForm = () => {
setName('');
setContactType(1);
setContactKey('');
setChannelKey('');
setBulkChannelText('');
@@ -161,7 +168,7 @@ export function NewMessageModal({
setError('Name and public key are required');
return;
}
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical, contactType);
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
setError('Channel name and key are required');
@@ -293,6 +300,19 @@ export function NewMessageModal({
placeholder="64-character hex public key"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-type">Type</Label>
<select
id="contact-type"
value={contactType}
onChange={(e) => setContactType(Number(e.target.value))}
className="block h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm"
>
<option value={1}>Client</option>
<option value={2}>Repeater</option>
<option value={3}>Room Server</option>
</select>
</div>
</TabsContent>
<TabsContent value="new-channel" className="mt-4 space-y-4">
@@ -300,6 +300,7 @@ export function RepeaterDashboard({
/>
)}
</header>
<div data-toast-anchor="conversation" aria-hidden="true" />
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
@@ -1,4 +1,5 @@
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
import { useMemo } from 'react';
import { RepeaterPane, NotFetched, LppSensorRow, formatLppLabel } from './repeaterPaneShared';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
disabled?: boolean;
}) {
const { distanceUnit } = useDistanceUnit();
// Build disambiguated labels matching the telemetry history chart names
const labels = useMemo(() => {
if (!data) return [];
const counts = new Map<string, number>();
return data.sensors.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) + ` Ch${s.channel}` + (n > 1 ? ` (${n})` : '');
});
}, [data]);
return (
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
) : (
<div className="space-y-0.5">
{data.sensors.map((sensor, i) => (
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
))}
</div>
)}
@@ -37,9 +37,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
// Stable color rotation for dynamic LPP sensors
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */
function lppKey(s: TelemetryLppSensor): string {
return `lpp_${s.type_name}_ch${s.channel}`;
/** Assign disambiguated flat keys to an array of LPP sensors.
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
function assignLppKeys(
sensors: TelemetryLppSensor[]
): { sensor: TelemetryLppSensor; key: string; occurrence: number }[] {
const counts = new Map<string, number>();
return sensors.map((s) => {
const base = `lpp_${s.type_name}_ch${s.channel}`;
const n = (counts.get(base) ?? 0) + 1;
counts.set(base, n);
return { sensor: s, key: n === 1 ? base : `${base}_${n}`, occurrence: n };
});
}
const TOOLTIP_STYLE = {
@@ -93,11 +102,10 @@ export function TelemetryHistoryPane({
// Discover unique LPP sensors across all history entries
const lppMetrics = useMemo(() => {
const seen = new Map<string, { type_name: string; channel: number }>();
const seen = new Map<string, { type_name: string; channel: number; occurrence: number }>();
for (const e of entries) {
for (const s of e.data.lpp_sensors ?? []) {
const k = lppKey(s);
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel });
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
}
}
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
@@ -106,7 +114,8 @@ export function TelemetryHistoryPane({
const label =
info.type_name.charAt(0).toUpperCase() +
info.type_name.slice(1).replace(/_/g, ' ') +
` Ch${info.channel}`;
` Ch${info.channel}` +
(info.occurrence > 1 ? ` (${info.occurrence})` : '');
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
result.push({
key: k,
@@ -148,9 +157,9 @@ export function TelemetryHistoryPane({
uptime_seconds: d.uptime_seconds,
};
// Flatten LPP sensors into the point, converting units as needed
for (const s of d.lpp_sensors ?? []) {
for (const { sensor: s, key } of assignLppKeys(d.lpp_sensors ?? [])) {
if (typeof s.value === 'number') {
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
}
}
return point;
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
}
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
const label = formatLppLabel(sensor.type_name);
export function LppSensorRow({
sensor,
unitPref,
label: labelOverride,
}: {
sensor: LppSensor;
unitPref?: string;
label?: string;
}) {
const label = labelOverride ?? formatLppLabel(sensor.type_name);
if (typeof sensor.value === 'object' && sensor.value !== null) {
// Multi-value sensor (GPS, accelerometer, etc.)
@@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
4: 'Sensor',
};
type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen';
type SortDir = 'asc' | 'desc';
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
@@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number {
return Math.floor(d.getTime() / 1000);
}
function SortableHeader({
label,
field,
sortField,
sortDir,
onSort,
className,
}: {
label: string;
field: SortField;
sortField: SortField;
sortDir: SortDir;
onSort: (field: SortField) => void;
className?: string;
}) {
const active = sortField === field;
return (
<th
className={`px-3 py-1.5 cursor-pointer select-none hover:text-foreground transition-colors ${className ?? ''}`}
onClick={() => onSort(field)}
>
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
</th>
);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
@@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [lastHeardAfter, setLastHeardAfter] = useState('');
const [lastHeardBefore, setLastHeardBefore] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [sortField, setSortField] = useState<SortField>('first_seen');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const handleSort = useCallback(
(field: SortField) => {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDir(field === 'name' || field === 'key' ? 'asc' : 'desc');
}
},
[sortField]
);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setLastHeardAfter('');
setLastHeardBefore('');
setTypeFilter('all');
setSortField('first_seen');
setSortDir('desc');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
let list = [...contacts];
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
@@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
if (lastHeardAfter) {
const after = datetimeToUnix(lastHeardAfter);
list = list.filter((c) => (c.last_seen ?? 0) >= after);
}
if (lastHeardBefore) {
const before = datetimeToUnix(lastHeardBefore);
list = list.filter((c) => (c.last_seen ?? 0) <= before);
}
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
switch (sortField) {
case 'name': {
const an = getContactDisplayName(a.name, a.public_key, a.last_advert).toLowerCase();
const bn = getContactDisplayName(b.name, b.public_key, b.last_advert).toLowerCase();
return an < bn ? -dir : an > bn ? dir : 0;
}
case 'type':
return (a.type - b.type) * dir;
case 'key':
return a.public_key < b.public_key ? -dir : a.public_key > b.public_key ? dir : 0;
case 'first_seen':
return ((a.first_seen ?? 0) - (b.first_seen ?? 0)) * dir;
case 'last_seen':
return ((a.last_seen ?? 0) - (b.last_seen ?? 0)) * dir;
}
});
return list;
}, [contacts, typeFilter, startDate, endDate]);
}, [
contacts,
typeFilter,
startDate,
endDate,
lastHeardAfter,
lastHeardBefore,
sortField,
sortDir,
]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
@@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({
}
};
const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
@@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Last heard after</label>
<Input
type="datetime-local"
value={lastHeardAfter}
onChange={(e) => setLastHeardAfter(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Last heard before</label>
<Input
type="datetime-local"
value={lastHeardBefore}
onChange={(e) => setLastHeardBefore(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
@@ -211,7 +322,7 @@ export function BulkDeleteContactsModal({
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{hasFilters && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
@@ -219,17 +330,51 @@ export function BulkDeleteContactsModal({
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
No contacts match the selected filters.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
<SortableHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
/>
<SortableHeader
label="Type"
field="type"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
className="hidden sm:table-cell"
/>
<SortableHeader
label="Key"
field="key"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
/>
<SortableHeader
label="Created"
field="first_seen"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
className="hidden sm:table-cell"
/>
<SortableHeader
label="Last heard"
field="last_seen"
sortField={sortField}
sortDir={sortDir}
onSort={handleSort}
className="hidden sm:table-cell"
/>
</tr>
</thead>
<tbody>
@@ -265,6 +410,9 @@ export function BulkDeleteContactsModal({
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.last_seen ? formatDate(c.last_seen) : '—'}
</td>
</tr>
))}
</tbody>
@@ -298,6 +446,7 @@ export function BulkDeleteContactsModal({
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Last heard</th>
</tr>
</thead>
<tbody>
@@ -315,6 +464,9 @@ export function BulkDeleteContactsModal({
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.last_seen ? formatDate(c.last_seen) : '—'}
</td>
</tr>
))}
</tbody>
@@ -1,4 +1,13 @@
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
lazy,
Suspense,
type ReactNode,
} from 'react';
import { ChevronDown, Info } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
@@ -278,7 +287,9 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
config: {
urls: '',
preserve_identity: true,
include_path: true,
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
body_format_channel:
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
},
scope: { messages: 'all', raw_packets: 'none' },
},
@@ -2376,6 +2387,91 @@ function ScopeSelector({
);
}
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
const APPRISE_DEFAULT_CHANNEL =
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
const APPRISE_SAMPLE_VARS: Record<string, string> = {
type: 'CHAN',
text: 'hello world',
sender_name: 'Alice',
sender_key: 'a1b2c3d4e5f6',
channel_name: '#general',
conversation_key: 'abcdef1234567890',
hops: '2a, 3b',
hops_backticked: '`2a`, `3b`',
hop_count: '2',
rssi: '-95',
snr: '6.5',
};
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
...APPRISE_SAMPLE_VARS,
type: 'PRIV',
channel_name: '',
conversation_key: 'a1b2c3d4e5f6',
};
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
let result = fmt;
for (const [key, value] of Object.entries(vars)) {
result = result.split(`{${key}}`).join(value);
}
return result;
}
/** Render a markdown-ish string into inline React elements (bold + code spans). */
function appriseRenderMarkdown(s: string): ReactNode[] {
const nodes: ReactNode[] = [];
let key = 0;
// Split on **bold** and `code` spans
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
for (const part of parts) {
if (part.startsWith('**') && part.endsWith('**')) {
nodes.push(
<strong key={key++} className="font-bold">
{part.slice(2, -2)}
</strong>
);
} else if (part.startsWith('`') && part.endsWith('`')) {
nodes.push(
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
{part.slice(1, -1)}
</code>
);
} else if (part) {
nodes.push(<span key={key++}>{part}</span>);
}
}
return nodes;
}
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
const raw = appriseApplyFormat(format, vars);
return (
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Rendered (Discord, Slack)
</span>
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
</div>
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Raw (Telegram, email)
</span>
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
</div>
</div>
);
}
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
if (value == null) return true;
const s = String(value).trim();
return s === '' || s === defaultStr;
}
function AppriseConfigEditor({
config,
scope,
@@ -2387,6 +2483,10 @@ function AppriseConfigEditor({
onChange: (config: Record<string, unknown>) => void;
onScopeChange: (scope: Record<string, unknown>) => void;
}) {
const dmFormat = ((config.body_format_dm as string) || '').trim() || APPRISE_DEFAULT_DM;
const chanFormat =
((config.body_format_channel as string) || '').trim() || APPRISE_DEFAULT_CHANNEL;
return (
<div className="space-y-3">
<p className="text-[0.8125rem] text-muted-foreground">
@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.include_path !== false}
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
className="h-4 w-4 rounded border-border"
<Separator />
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
Available variables
</summary>
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
<span className="text-muted-foreground">Message body</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{sender_name}'}
</code>
<span className="text-muted-foreground">Sender display name</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{sender_key}'}
</code>
<span className="text-muted-foreground">Sender public key (hex)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{channel_name}'}
</code>
<span className="text-muted-foreground">Channel name (channel messages only)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{conversation_key}'}
</code>
<span className="text-muted-foreground">
Contact pubkey (DM) or channel key (channel)
</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
<span className="text-muted-foreground">PRIV or CHAN</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
<span className="text-muted-foreground">
Comma-separated hop IDs, or &quot;direct&quot;
</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{hops_backticked}'}
</code>
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{hop_count}'}
</code>
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
<span className="text-muted-foreground">Last-hop SNR in dB</span>
</div>
<p className="text-xs text-muted-foreground mt-1.5">
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
</p>
</div>
</details>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
{!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && (
<button
type="button"
aria-label="Reset DM format to default"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...config, body_format_dm: APPRISE_DEFAULT_DM })}
>
Reset to default
</button>
)}
</div>
<textarea
id="fanout-apprise-fmt-dm"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
placeholder={APPRISE_DEFAULT_DM}
value={(config.body_format_dm as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
rows={2}
/>
<span className="text-sm">Include routing path in notifications</span>
</label>
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
{!appriseIsDefault(config.body_format_channel, APPRISE_DEFAULT_CHANNEL) && (
<button
type="button"
aria-label="Reset channel format to default"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...config, body_format_channel: APPRISE_DEFAULT_CHANNEL })}
>
Reset to default
</button>
)}
</div>
<textarea
id="fanout-apprise-fmt-chan"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
placeholder={APPRISE_DEFAULT_CHANNEL}
value={(config.body_format_channel as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
rows={2}
/>
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} />
</div>
<Separator />
@@ -396,11 +396,6 @@ export function SettingsRadioSection({
try {
const update: AppSettingsUpdate = {};
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
update.advert_interval = newAdvertInterval;
}
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
update.flood_scope = floodScope;
}
@@ -419,6 +414,27 @@ export function SettingsRadioSection({
}
};
const [advertIntervalBusy, setAdvertIntervalBusy] = useState(false);
const [advertIntervalError, setAdvertIntervalError] = useState<string | null>(null);
const handleSaveAdvertInterval = async () => {
setAdvertIntervalError(null);
setAdvertIntervalBusy(true);
try {
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
await onSaveAppSettings({ advert_interval: newAdvertInterval });
}
toast.success('Advertising interval saved');
} catch (err) {
setAdvertIntervalError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setAdvertIntervalBusy(false);
}
};
const handleAdvertise = async (mode: RadioAdvertMode) => {
setAdvertisingMode(mode);
try {
@@ -1109,6 +1125,18 @@ export function SettingsRadioSection({
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
{advertIntervalError && (
<div className="text-sm text-destructive" role="alert">
{advertIntervalError}
</div>
)}
<Button
onClick={handleSaveAdvertInterval}
disabled={advertIntervalBusy}
className="w-full"
>
{advertIntervalBusy ? 'Saving...' : 'Save Advertising Interval'}
</Button>
</div>
<div className="space-y-2">
+2 -2
View File
@@ -50,8 +50,8 @@ export function useContactsAndChannels({
}, []);
const handleCreateContact = useCallback(
async (name: string, publicKey: string, tryHistorical: boolean) => {
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
async (name: string, publicKey: string, tryHistorical: boolean, type?: number) => {
const created = await api.createContact(publicKey, name || undefined, tryHistorical, type);
const data = await fetchAllContacts();
setContacts(data);
+1 -1
View File
@@ -172,7 +172,7 @@ describe('NewMessageModal form reset', () => {
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false, 1);
});
expect(onClose).toHaveBeenCalled();
});
+4
View File
@@ -29,3 +29,7 @@ MESHCORE_DISABLE_BOTS=true
# HTTP Basic Auth (recommended when bots are enabled)
#MESHCORE_BASIC_AUTH_USERNAME=
#MESHCORE_BASIC_AUTH_PASSWORD=
# Enable GET /api/radio/private-key to return the in-memory private key as hex
# for backup or migration. Only enable on a trusted network.
#MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=false
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.12.1"
version = "3.12.3"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
+12 -10
View File
@@ -63,9 +63,10 @@ test.describe('Apprise integration settings', () => {
const preserveIdentity = page.getByText('Preserve identity on Discord');
await expect(preserveIdentity).toBeVisible();
// Verify include routing path checkbox is checked by default
const includePath = page.getByText('Include routing path in notifications');
await expect(includePath).toBeVisible();
// Verify format textareas are present under Message Format heading
await expect(page.getByText('Message Format')).toBeVisible();
await expect(page.locator('#fanout-apprise-fmt-dm')).toBeVisible();
await expect(page.locator('#fanout-apprise-fmt-chan')).toBeVisible();
// Rename it
const nameInput = page.locator('#fanout-edit-name');
@@ -94,7 +95,8 @@ test.describe('Apprise integration settings', () => {
config: {
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
preserve_identity: false,
include_path: false,
body_format_dm: '{sender_name}: {text}',
body_format_channel: '{channel_name} | {sender_name}: {text}',
},
enabled: true,
});
@@ -113,18 +115,18 @@ test.describe('Apprise integration settings', () => {
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
// Verify checkboxes reflect our config (both unchecked)
// Verify preserve identity checkbox reflects our config (unchecked)
const preserveCheckbox = page
.getByText('Preserve identity on Discord')
.locator('xpath=ancestor::label[1]')
.locator('input[type="checkbox"]');
await expect(preserveCheckbox).not.toBeChecked();
const pathCheckbox = page
.getByText('Include routing path in notifications')
.locator('xpath=ancestor::label[1]')
.locator('input[type="checkbox"]');
await expect(pathCheckbox).not.toBeChecked();
// Verify format textareas reflect our custom formats
const dmFormat = page.locator('#fanout-apprise-fmt-dm');
await expect(dmFormat).toHaveValue('{sender_name}: {text}');
const chanFormat = page.locator('#fanout-apprise-fmt-chan');
await expect(chanFormat).toHaveValue('{channel_name} | {sender_name}: {text}');
// Go back
page.once('dialog', (dialog) => dialog.accept());
+87 -12
View File
@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "v2.2.2"
mock_radio.firmware_build = "2025-01-15"
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
patch.object(
pub,
"_fetch_device_info",
new_callable=AsyncMock,
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
),
@@ -852,6 +850,82 @@ class TestLwtAndStatusPublish:
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
assert payload["stats"] == {"battery_mv": 4200}
@pytest.mark.asyncio
async def test_publish_status_uses_fallback_fetch_when_device_info_not_loaded(self):
"""When device_info_loaded is False, _fetch_device_info() should be called as fallback."""
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "OldNode"}
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
patch.object(
pub,
"_fetch_device_info",
new_callable=AsyncMock,
return_value={"model": "LegacyBoard", "firmware_version": "v2"},
) as mock_fetch,
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm/0-x"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
await pub._publish_status(settings)
mock_fetch.assert_awaited_once()
payload = mock_publish.call_args[0][1]
assert payload["model"] == "LegacyBoard"
assert payload["firmware_version"] == "v2"
@pytest.mark.asyncio
async def test_publish_status_reflects_updated_firmware_version_after_reconnect(self):
"""After firmware update + radio reconnect, the published firmware_version must be fresh.
This is a regression test for the stale-cache bug: previously _cached_device_info
was never cleared between reconnects, so a radio firmware update was invisible to
the Community MQTT status payload until the fanout module itself restarted.
"""
pub = CommunityMqttPublisher()
private_key, public_key = _make_test_keys()
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "MyNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "1.14.1"
mock_radio.firmware_build = ""
async def _publish_once(radio_mock):
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", radio_mock),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.fanout.community_mqtt._get_client_version", return_value="RT/0-x"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_pub,
):
await pub._publish_status(settings)
return mock_pub.call_args[0][1]
first_payload = await _publish_once(mock_radio)
assert first_payload["firmware_version"] == "1.14.1"
# Simulate firmware update: radio reboots, radio_lifecycle refreshes the manager fields
mock_radio.firmware_version = "1.15.0"
second_payload = await _publish_once(mock_radio)
assert second_payload["firmware_version"] == "1.15.0", (
"Expected updated firmware version after reconnect; stale cache bug would return v1.14.1"
)
def test_lwt_and_online_share_same_topic(self):
"""LWT and on-connect status should use the same topic path."""
pub = CommunityMqttPublisher()
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
mock_radio.device_info_loaded = True
mock_radio.device_model = "T-Deck"
mock_radio.firmware_version = "v2.2.2"
mock_radio.firmware_build = "2025-01-15"
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
with (
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
patch.object(
pub,
"_fetch_device_info",
new_callable=AsyncMock,
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch(
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
with (
patch("app.keystore.get_public_key", return_value=public_key),
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
mock_radio = MagicMock()
mock_radio.meshcore = None
mock_radio.device_info_loaded = False
before = time.monotonic()
+136 -9
View File
@@ -1049,7 +1049,8 @@ class TestAppriseFormatBody:
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"}, include_path=False
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
body_format_dm="**DM:** {sender_name}: {text}",
)
assert body == "**DM:** Alice: hi"
@@ -1058,7 +1059,7 @@ class TestAppriseFormatBody:
body = _format_body(
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
include_path=False,
body_format_channel="**{channel_name}:** {sender_name}: {text}",
)
assert body == "**#general:** Bob: hi"
@@ -1072,7 +1073,7 @@ class TestAppriseFormatBody:
"sender_name": "Bob",
"channel_name": "#general",
},
include_path=False,
body_format_channel="**{channel_name}:** {sender_name}: {text}",
)
assert body == "**#general:** Bob: hi"
@@ -1086,7 +1087,7 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "2027"}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`20`" in body
@@ -1097,7 +1098,7 @@ class TestAppriseFormatBody:
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "`direct`" in body
@@ -1112,7 +1113,7 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "aabbccdd", "path_len": 2}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`aabb`" in body
@@ -1129,7 +1130,7 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`aabbcc`" in body
@@ -1147,7 +1148,7 @@ class TestAppriseFormatBody:
"channel_name": "#general",
"paths": [{"path": "aabbccdd", "path_len": 2}],
},
include_path=True,
body_format_channel="**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**#general:**" in body
assert "`aabb`" in body
@@ -1164,12 +1165,118 @@ class TestAppriseFormatBody:
"sender_name": "Alice",
"paths": [{"path": "aabb"}],
},
include_path=True,
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
)
assert "**via:**" in body
assert "`aa`" in body
assert "`bb`" in body
def test_default_format_strings(self):
"""Default format strings produce expected output."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": "2a3b"}],
},
)
assert body == "**DM:** Alice: hi **via:** [`2a`, `3b`]"
def test_custom_format_with_rssi(self):
"""Custom format string can include rssi/snr."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": "2a", "rssi": -95, "snr": 6.5}],
},
body_format_dm="From {sender_name}: {text} (rssi: {rssi}, snr: {snr})",
)
assert body == "From Alice: hi (rssi: -95, snr: 6.5)"
def test_unknown_placeholder_left_as_is(self):
"""Unknown {placeholders} pass through unchanged."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
body_format_dm="{sender_name}: {text} {unknown_var}",
)
assert body == "Alice: hi {unknown_var}"
def test_none_fields_render_empty(self):
"""None optional fields render as empty string, not 'None'."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
body_format_dm="{sender_name}: {text} rssi={rssi}",
)
assert body == "Alice: hi rssi="
assert "None" not in body
def test_hops_direct_when_no_paths(self):
"""hops is 'direct' when no path data exists."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
body_format_channel="{channel_name} {hops}",
)
assert body == "#gen direct"
def test_hops_direct_when_empty_path(self):
"""hops is 'direct' when path string is empty."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": ""}],
},
body_format_dm="{hops}",
)
assert body == "direct"
def test_no_re_expansion_of_substituted_values(self):
"""Placeholders in message text must not be expanded by later passes."""
from app.fanout.apprise_mod import _format_body
body = _format_body(
{"type": "PRIV", "text": "hello {sender_name}", "sender_name": "Alice"},
body_format_dm="{sender_name}: {text}",
)
assert body == "Alice: hello {sender_name}"
@pytest.mark.asyncio
async def test_empty_format_string_uses_default(self):
"""Empty format strings in config should produce default output, not blank."""
from unittest.mock import patch as _patch
from app.fanout.apprise_mod import AppriseModule
mod = AppriseModule(
"test",
{"urls": "json://localhost", "body_format_dm": "", "body_format_channel": " "},
)
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
await mod.on_message(
{"type": "PRIV", "text": "hi", "outgoing": False, "sender_name": "Alice"}
)
mock_send.assert_called_once()
body = mock_send.call_args[0][1]
assert "Alice" in body
assert "hi" in body
assert body != ""
class TestAppriseNormalizeDiscordUrl:
def test_discord_scheme(self):
@@ -1233,6 +1340,26 @@ class TestAppriseValidation:
_validate_apprise_config({"urls": "discord://123/abc"})
def test_validate_apprise_config_accepts_format_strings(self):
from app.routers.fanout import _validate_apprise_config
_validate_apprise_config(
{
"urls": "discord://123/abc",
"body_format_dm": "DM from {sender_name}: {text}",
"body_format_channel": "{channel_name}: {text}",
}
)
def test_validate_apprise_config_rejects_non_string_format(self):
from fastapi import HTTPException
from app.routers.fanout import _validate_apprise_config
with pytest.raises(HTTPException) as exc_info:
_validate_apprise_config({"urls": "discord://123/abc", "body_format_dm": 123})
assert exc_info.value.status_code == 400
def test_enforce_scope_apprise_strips_raw_packets(self):
from app.routers.fanout import _enforce_scope
+7 -4
View File
@@ -1171,7 +1171,8 @@ class TestFanoutAppriseIntegration:
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"preserve_identity": True,
"include_path": False,
"body_format_dm": "**DM:** {sender_name}: {text}",
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
@@ -1212,7 +1213,8 @@ class TestFanoutAppriseIntegration:
name="Channel Apprise",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"include_path": False,
"body_format_dm": "**DM:** {sender_name}: {text}",
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
@@ -1541,13 +1543,14 @@ class TestFanoutAppriseIntegration:
@pytest.mark.asyncio
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
"""Apprise with include_path=True shows routing hops in the body."""
"""Apprise with hops in format string shows routing hops in the body."""
cfg = await FanoutConfigRepository.create(
config_type="apprise",
name="Path Apprise",
config={
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
"include_path": True,
"body_format_dm": "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
"body_format_channel": "**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
},
scope={"messages": "all", "raw_packets": "none"},
enabled=True,
+1 -1
View File
@@ -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 = 59
LATEST_SCHEMA_VERSION = 60
+90
View File
@@ -7,6 +7,7 @@ import pytest
from app.fanout.mqtt_ha import (
MqttHaModule,
_assign_lpp_keys,
_contact_tracker_discovery_config,
_device_payload,
_lpp_discovery_configs,
@@ -552,6 +553,45 @@ class TestLppSensorKey:
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
class TestAssignLppKeys:
def test_no_duplicates(self):
sensors = [
{"type_name": "temperature", "channel": 1, "value": 20},
{"type_name": "humidity", "channel": 2, "value": 45},
]
result = _assign_lpp_keys(sensors)
assert [(k, n) for _, k, n in result] == [
("lpp_temperature_ch1", 1),
("lpp_humidity_ch2", 1),
]
def test_duplicate_type_and_channel(self):
sensors = [
{"type_name": "temperature", "channel": 1, "value": 20},
{"type_name": "humidity", "channel": 2, "value": 45},
{"type_name": "temperature", "channel": 1, "value": 53},
]
result = _assign_lpp_keys(sensors)
assert [(k, n) for _, k, n in result] == [
("lpp_temperature_ch1", 1),
("lpp_humidity_ch2", 1),
("lpp_temperature_ch1_2", 2),
]
def test_triple_duplicate(self):
sensors = [
{"type_name": "voltage", "channel": 0, "value": 3.3},
{"type_name": "voltage", "channel": 0, "value": 5.0},
{"type_name": "voltage", "channel": 0, "value": 12.0},
]
result = _assign_lpp_keys(sensors)
keys = [k for _, k, _ in result]
assert keys == ["lpp_voltage_ch0", "lpp_voltage_ch0_2", "lpp_voltage_ch0_3"]
def test_empty_list(self):
assert _assign_lpp_keys([]) == []
class TestLppDiscoveryConfigs:
def test_produces_config_per_sensor(self):
nid = "ccdd11223344"
@@ -583,6 +623,27 @@ class TestLppDiscoveryConfigs:
assert cfg["suggested_display_precision"] == 1
assert "lpp_temperature_ch1" in cfg["value_template"]
def test_duplicate_type_channel_gets_indexed_keys(self):
nid = "ccdd11223344"
device = _device_payload(nid, "Rep1", "Repeater")
sensors = [
{"channel": 1, "type_name": "temperature", "value": 20.0},
{"channel": 2, "type_name": "humidity", "value": 45.0},
{"channel": 1, "type_name": "temperature", "value": 53.0},
]
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
assert len(configs) == 3
topics = [t for t, _ in configs]
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config" in topics
assert f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config" in topics
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config" in topics
# First temperature keeps base name, second gets #2 suffix
names = {cfg["unique_id"]: cfg["name"] for _, cfg in configs}
assert names[f"meshcore_{nid}_lpp_temperature_ch1"] == "Temperature (Ch 1)"
assert names[f"meshcore_{nid}_lpp_temperature_ch1_2"] == "Temperature (Ch 1) #2"
def test_unknown_sensor_type_no_device_class(self):
nid = "ccdd11223344"
device = _device_payload(nid, "Rep1", "Repeater")
@@ -712,6 +773,35 @@ class TestMqttHaTelemetryWithLpp:
mod._publish_discovery.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_telemetry_duplicate_lpp_sensors_not_overwritten(self):
"""Two sensors with same (type_name, channel) get distinct keys."""
key = "ccdd11223344"
nid = _node_id(key)
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
mod._publisher = MagicMock()
mod._publisher.connected = True
mod._publisher.publish = AsyncMock()
mod._discovery_topics = [
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config",
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config",
]
await mod.on_telemetry(
{
"public_key": key,
"battery_volts": 4.1,
"lpp_sensors": [
{"channel": 1, "type_name": "temperature", "value": 20.0},
{"channel": 1, "type_name": "temperature", "value": 53.0},
],
}
)
payload = mod._publisher.publish.call_args[0][1]
assert payload["lpp_temperature_ch1"] == 20.0
assert payload["lpp_temperature_ch1_2"] == 53.0
@pytest.mark.asyncio
async def test_on_telemetry_without_lpp_sensors(self):
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
+204
View File
@@ -0,0 +1,204 @@
"""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
+365
View File
@@ -0,0 +1,365 @@
"""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()
+180
View File
@@ -0,0 +1,180 @@
"""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"
+695
View File
@@ -0,0 +1,695 @@
"""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
Generated
+4 -4
View File
@@ -1453,11 +1453,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.1"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
]
[[package]]
@@ -1533,7 +1533,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.12.1"
version = "3.12.3"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },