Initial tcp proxy testing

This commit is contained in:
Jack Kingsman
2026-04-24 17:37:20 -07:00
parent 4eb29f376e
commit c31779f1a9
16 changed files with 2585 additions and 10 deletions
+4
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
@@ -507,6 +508,9 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the MeshCore TCP companion protocol proxy (see `app/tcp_proxy/AGENTS_tcp_proxy.md`) |
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the TCP proxy server |
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the TCP proxy server |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
+24
View File
@@ -39,6 +39,30 @@ Import via `PUT /api/radio/private-key` is always available regardless of this s
The Radio Settings config export/import feature uses these endpoints. When export is disabled, config exports will omit the private key and show a notice.
## MeshCore TCP Proxy
RemoteTerm can emulate a MeshCore companion radio over TCP, allowing MeshCore clients (mobile apps, meshcore-cli, meshcore-ha) to connect to it as if it were a directly-connected radio.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the TCP companion protocol proxy |
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the proxy TCP server |
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the proxy TCP server |
Once enabled, MeshCore clients can connect:
```bash
meshcore-cli --tcp <host>:5001
```
**How it works:** The proxy translates the MeshCore companion binary protocol into in-process RemoteTerm operations. Contacts, channels, and messages come from the RemoteTerm database. Outgoing messages are sent through RemoteTerm's send orchestration (with radio lock, retries, and ACK tracking). Incoming messages are pushed to connected clients in real time.
**Limitations:**
- Only favorite contacts are synced to clients
- Only favorite channels are pre-loaded into slots; clients can load additional channels via SET_CHANNEL (local to the proxy session, does not modify RemoteTerm channel configuration)
- DMs receive an immediate synthetic ACK; actual delivery retries are handled server-side by RemoteTerm
- Radio configuration changes (SET_NAME, SET_LATLON) are applied to the real radio
## Contact Loading Issues
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
+6 -1
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
@@ -426,7 +427,11 @@ tests/
├── test_telemetry_interval.py # Telemetry interval scheduling math
├── test_version_info.py # Version/build metadata resolution
├── test_websocket.py # WS manager broadcast/cleanup
── test_websocket_route.py # WS endpoint lifecycle
── test_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":
+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:
+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) # flood
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`` (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")
+644
View File
@@ -0,0 +1,644 @@
"""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,
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.
Tries 32-byte full key first (always accepted _do_send_dm resolves
from the repository), then falls back to 6-byte prefix matched against
the cached contacts list.
"""
# Try 32-byte full key first (send_msg_with_retry sends full keys)
if len(remaining) > 32:
candidate = remaining[:32].hex()
# Accept any well-formed 64-char hex key — _do_send_dm will
# resolve it from the repository, not just our favorites cache.
if len(candidate) == 64:
return candidate, remaining[32:].decode("utf-8", "ignore")
# Fall back to 6-byte prefix (send_msg default) — can only resolve
# against our cached contacts since we need an unambiguous match.
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")
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 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())
frame = bytearray([RESP_CONTACT_MSG_RECV_V3])
frame.append(0) # SNR
frame.extend(b"\x00\x00") # reserved
frame.extend(bytes.fromhex(sender_key[:12])) # 6-byte prefix
frame.append(0xFF) # flood
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())
frame = bytearray([RESP_CHANNEL_MSG_RECV_V3])
frame.append(0) # SNR
frame.extend(b"\x00\x00") # reserved
frame.append(idx)
frame.append(0xFF) # flood
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.
+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"
+526
View File
@@ -0,0 +1,526 @@
"""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_CONTACT_END,
RESP_CONTACT_START,
RESP_CURRENT_TIME,
RESP_DEVICE_INFO,
RESP_ERR,
RESP_MSG_SENT,
RESP_NO_MORE_MSGS,
RESP_OK,
RESP_SELF_INFO,
)
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
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 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_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_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