mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 10:13:02 +02:00
Initial tcp proxy testing
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user