mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
1 Commits
community-
...
loopback
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00bc68a83 |
@@ -29,7 +29,6 @@ frontend/src/test/
|
||||
# Docs
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSES.md
|
||||
|
||||
# Other
|
||||
references/
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -152,9 +152,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ ├── websocket.py # Real-time broadcasts
|
||||
│ ├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
|
||||
│ ├── mqtt.py # Private MQTT publisher
|
||||
│ └── community_mqtt.py # Community MQTT publisher (raw packet sharing)
|
||||
│ └── mqtt.py # Optional MQTT publisher
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
@@ -235,7 +233,6 @@ Key test files:
|
||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -360,21 +357,10 @@ Optional MQTT integration forwards mesh events to an external broker for home au
|
||||
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
|
||||
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
|
||||
|
||||
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status` (`disabled` when no broker host is set, or when both publish toggles are off).
|
||||
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status`.
|
||||
|
||||
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
|
||||
|
||||
### Community MQTT Sharing
|
||||
|
||||
Separate from private MQTT, the community publisher (`app/community_mqtt.py`) shares raw packets with the MeshCore community aggregator for coverage mapping and analysis. Only raw packets are shared — never decrypted messages.
|
||||
|
||||
- Connects to community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
|
||||
- Authentication via Ed25519 JWT signed with the radio's private key. Tokens auto-renew before 24h expiry.
|
||||
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
|
||||
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code.
|
||||
- JWT `email` claim enables node claiming on the community aggregator.
|
||||
- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` in `app_settings`.
|
||||
|
||||
### Server-Side Decryption
|
||||
|
||||
The server can decrypt packets using stored keys, both in real-time and for historical packets.
|
||||
@@ -416,7 +402,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
|
||||
**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`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
**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`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, and all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ RUN uv sync --frozen --no-dev
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
|
||||
# Copy license attributions
|
||||
COPY LICENSES.md ./
|
||||
|
||||
# Copy built frontend from first stage
|
||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||
|
||||
|
||||
1475
LICENSES.md
1475
LICENSES.md
File diff suppressed because it is too large
Load Diff
12
README.md
12
README.md
@@ -300,15 +300,3 @@ npx playwright test --headed # show the browser window
|
||||
## API Documentation
|
||||
|
||||
With the backend running: http://localhost:8000/docs
|
||||
|
||||
## Debugging & Bug Reports
|
||||
|
||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||
|
||||
To start the server with debug logging:
|
||||
|
||||
```bash
|
||||
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Please include the relevant debug log output when filing an issue on GitHub.
|
||||
|
||||
@@ -27,9 +27,7 @@ app/
|
||||
├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
|
||||
├── mqtt.py # Private MQTT publisher (fire-and-forget forwarding)
|
||||
├── community_mqtt.py # Community MQTT publisher (raw packet sharing)
|
||||
├── mqtt.py # Optional MQTT publisher (fire-and-forget forwarding)
|
||||
├── bot.py # Bot execution and outbound bot sends
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
@@ -106,31 +104,16 @@ app/
|
||||
### MQTT publishing
|
||||
|
||||
- Optional forwarding of mesh events to an external MQTT broker.
|
||||
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
|
||||
- Disabled when `mqtt_broker_host` is empty, or when both publish toggles are off (`mqtt_publish_messages=false` and `mqtt_publish_raw_packets=false`).
|
||||
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
|
||||
- Disabled when `mqtt_broker_host` is empty.
|
||||
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
|
||||
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
|
||||
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
|
||||
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
|
||||
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`), where `disabled` covers both "no broker host configured" and "nothing enabled to publish".
|
||||
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`).
|
||||
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
|
||||
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
|
||||
|
||||
### Community MQTT
|
||||
|
||||
- Separate publisher (`app/community_mqtt.py`) for sharing raw packets with the MeshCore community aggregator.
|
||||
- Implementation intent: keep functional parity with the reference implementation at `https://github.com/agessaman/meshcore-packet-capture` unless this repository explicitly documents a deliberate deviation.
|
||||
- Independent from the private `MqttPublisher` — different broker, authentication, and topic structure.
|
||||
- Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
|
||||
- Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours.
|
||||
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
|
||||
- JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator).
|
||||
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default).
|
||||
- Only raw packets are published — never decrypted messages.
|
||||
- Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds.
|
||||
- Health endpoint includes `community_mqtt_status` field.
|
||||
- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`.
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
|
||||
### Health
|
||||
@@ -239,7 +222,6 @@ Main tables:
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
@@ -278,7 +260,6 @@ tests/
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
"""Community MQTT publisher for sharing raw packets with the MeshCore community.
|
||||
|
||||
Publishes raw packet data to mqtt-us-v1.letsmesh.net using the protocol
|
||||
defined by meshcore-packet-capture (https://github.com/agessaman/meshcore-packet-capture).
|
||||
|
||||
Authentication uses Ed25519 JWT tokens signed with the radio's private key.
|
||||
This module is independent from the private MqttPublisher in app/mqtt.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import nacl.bindings
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
|
||||
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
|
||||
_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
|
||||
|
||||
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
|
||||
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
|
||||
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
||||
|
||||
# Ed25519 group order
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
|
||||
# Route type mapping: bottom 2 bits of first byte
|
||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||
|
||||
|
||||
def _base64url_encode(data: bytes) -> str:
|
||||
"""Base64url encode without padding."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _ed25519_sign_expanded(
|
||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
||||
) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
||||
This performs the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
# r = SHA-512(prefix || message) mod L
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
# R = r * B (base point multiplication)
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
# k = SHA-512(R || public_key || message) mod L
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
# s = (r + k * scalar) mod L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def _generate_jwt_token(
|
||||
private_key: bytes,
|
||||
public_key: bytes,
|
||||
*,
|
||||
audience: str = _DEFAULT_BROKER,
|
||||
email: str = "",
|
||||
) -> str:
|
||||
"""Generate a JWT token for community MQTT authentication.
|
||||
|
||||
Creates a token with Ed25519 signature using MeshCore's expanded key format.
|
||||
Token format: header_b64.payload_b64.signature_hex
|
||||
|
||||
Optional ``email`` embeds a node-claiming identity so the community
|
||||
aggregator can associate this radio with an owner.
|
||||
"""
|
||||
header = {"alg": "Ed25519", "typ": "JWT"}
|
||||
now = int(time.time())
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
payload: dict[str, object] = {
|
||||
"publicKey": pubkey_hex,
|
||||
"iat": now,
|
||||
"exp": now + _TOKEN_LIFETIME,
|
||||
"aud": audience,
|
||||
"owner": pubkey_hex,
|
||||
"client": _CLIENT_ID,
|
||||
}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
|
||||
header_b64 = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
|
||||
scalar = private_key[:32]
|
||||
prefix = private_key[32:]
|
||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||
|
||||
|
||||
def _calculate_packet_hash(raw_bytes: bytes) -> str:
|
||||
"""Calculate packet hash matching MeshCore's Packet::calculatePacketHash().
|
||||
|
||||
Parses the packet structure to extract payload type and payload data,
|
||||
then hashes: payload_type(1 byte) [+ path_len(2 bytes LE) for TRACE] + payload_data.
|
||||
Returns first 16 hex characters (uppercase).
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return "0" * 16
|
||||
|
||||
try:
|
||||
header = raw_bytes[0]
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
route_type = header & 0x03
|
||||
|
||||
# Transport codes present for TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3)
|
||||
has_transport = route_type in (0x00, 0x03)
|
||||
|
||||
offset = 1 # Past header
|
||||
if has_transport:
|
||||
offset += 4 # Skip 4 bytes of transport codes
|
||||
|
||||
# Read path_len (1 byte on wire). Invalid/truncated packets map to zero hash.
|
||||
if offset >= len(raw_bytes):
|
||||
return "0" * 16
|
||||
path_len = raw_bytes[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
|
||||
if len(raw_bytes) < offset + path_len:
|
||||
return "0" * 16
|
||||
payload_start = offset + path_len
|
||||
payload_data = raw_bytes[payload_start:]
|
||||
|
||||
# Hash: payload_type(1 byte) [+ path_len as uint16_t LE for TRACE] + payload_data
|
||||
hash_obj = hashlib.sha256()
|
||||
hash_obj.update(bytes([payload_type]))
|
||||
if payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
hash_obj.update(path_len.to_bytes(2, byteorder="little"))
|
||||
hash_obj.update(payload_data)
|
||||
|
||||
return hash_obj.hexdigest()[:16].upper()
|
||||
except Exception:
|
||||
return "0" * 16
|
||||
|
||||
|
||||
def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], int | None]:
|
||||
"""Decode packet fields used by the community uploader payload format.
|
||||
|
||||
Returns:
|
||||
(route_letter, packet_type_str, payload_len_str, path_values, payload_type_int)
|
||||
"""
|
||||
# Reference defaults when decode fails
|
||||
route = "U"
|
||||
packet_type = "0"
|
||||
payload_len = "0"
|
||||
path_values: list[str] = []
|
||||
payload_type: int | None = None
|
||||
|
||||
try:
|
||||
if len(raw_bytes) < 2:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
header = raw_bytes[0]
|
||||
payload_version = (header >> 6) & 0x03
|
||||
if payload_version != 0:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
route_type = header & 0x03
|
||||
has_transport = route_type in (0x00, 0x03)
|
||||
|
||||
offset = 1
|
||||
if has_transport:
|
||||
offset += 4
|
||||
|
||||
if len(raw_bytes) <= offset:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
path_len = raw_bytes[offset]
|
||||
offset += 1
|
||||
|
||||
if len(raw_bytes) < offset + path_len:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
path_bytes = raw_bytes[offset : offset + path_len]
|
||||
offset += path_len
|
||||
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
route = _ROUTE_MAP.get(route_type, "U")
|
||||
packet_type = str(payload_type)
|
||||
payload_len = str(max(0, len(raw_bytes) - offset))
|
||||
path_values = [f"{b:02x}" for b in path_bytes]
|
||||
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
except Exception:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
|
||||
def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict:
|
||||
"""Convert a RawPacketBroadcast dict to meshcore-packet-capture format."""
|
||||
raw_hex = data.get("data", "")
|
||||
raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
|
||||
|
||||
route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes)
|
||||
|
||||
# Reference format uses local "now" timestamp and derived time/date fields.
|
||||
current_time = datetime.now()
|
||||
ts_str = current_time.isoformat()
|
||||
|
||||
# SNR/RSSI are always strings in reference output.
|
||||
snr_val = data.get("snr")
|
||||
rssi_val = data.get("rssi")
|
||||
snr = str(snr_val) if snr_val is not None else "Unknown"
|
||||
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
|
||||
|
||||
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||
|
||||
packet = {
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": public_key_hex.upper(),
|
||||
"timestamp": ts_str,
|
||||
"type": "PACKET",
|
||||
"direction": "rx",
|
||||
"time": current_time.strftime("%H:%M:%S"),
|
||||
"date": current_time.strftime("%d/%m/%Y"),
|
||||
"len": str(len(raw_bytes)),
|
||||
"packet_type": packet_type,
|
||||
"route": route,
|
||||
"payload_len": payload_len,
|
||||
"raw": raw_hex.upper(),
|
||||
"SNR": snr,
|
||||
"RSSI": rssi,
|
||||
"hash": packet_hash,
|
||||
}
|
||||
|
||||
if route == "D":
|
||||
packet["path"] = ",".join(path_values)
|
||||
|
||||
return packet
|
||||
|
||||
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
"""Manages the community MQTT connection and publishes raw packets."""
|
||||
|
||||
_backoff_max = 60
|
||||
_log_prefix = "Community MQTT"
|
||||
_not_configured_timeout: float | None = 30
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._key_unavailable_warned: bool = False
|
||||
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
self._key_unavailable_warned = False
|
||||
await super().start(settings)
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
from app.keystore import has_private_key
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
if (
|
||||
self._settings
|
||||
and self._settings.community_mqtt_enabled
|
||||
and not has_private_key()
|
||||
and not self._key_unavailable_warned
|
||||
):
|
||||
broadcast_error(
|
||||
"Community MQTT unavailable",
|
||||
"Radio firmware does not support private key export.",
|
||||
)
|
||||
self._key_unavailable_warned = True
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if community MQTT is enabled and keys are available."""
|
||||
from app.keystore import has_private_key
|
||||
|
||||
return bool(self._settings and self._settings.community_mqtt_enabled and has_private_key())
|
||||
|
||||
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
jwt_token = _generate_jwt_token(
|
||||
private_key,
|
||||
public_key,
|
||||
audience=broker_host,
|
||||
email=settings.community_mqtt_email or "",
|
||||
)
|
||||
|
||||
tls_context = ssl.create_default_context()
|
||||
|
||||
return {
|
||||
"hostname": broker_host,
|
||||
"port": broker_port,
|
||||
"transport": "websockets",
|
||||
"tls_context": tls_context,
|
||||
"websocket_path": "/",
|
||||
"username": f"v1_{pubkey_hex}",
|
||||
"password": jwt_token,
|
||||
}
|
||||
|
||||
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
|
||||
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return (
|
||||
"Community MQTT connection failure",
|
||||
"Check your internet connection or try again later.",
|
||||
)
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
if not self.connected:
|
||||
logger.info("Community MQTT publish failure detected, reconnecting")
|
||||
return True
|
||||
if elapsed >= _TOKEN_RENEWAL_THRESHOLD:
|
||||
logger.info("Community MQTT JWT nearing expiry, reconnecting")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: AppSettings) -> bool:
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
if private_key is None or public_key is None:
|
||||
# Keys not available yet, wait for settings change or key export
|
||||
self.connected = False
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
community_publisher = CommunityMqttPublisher()
|
||||
|
||||
|
||||
def community_mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget community MQTT publish for raw packets only."""
|
||||
if event_type != "raw_packet":
|
||||
return
|
||||
if not community_publisher.connected or community_publisher._settings is None:
|
||||
return
|
||||
asyncio.create_task(_community_maybe_publish(data))
|
||||
|
||||
|
||||
async def _community_maybe_publish(data: dict[str, Any]) -> None:
|
||||
"""Format and publish a raw packet to the community broker."""
|
||||
settings = community_publisher._settings
|
||||
if settings is None or not settings.community_mqtt_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
return
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
|
||||
# Get device name from radio
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
packet = _format_raw_packet(data, device_name, pubkey_hex)
|
||||
iata = settings.community_mqtt_iata.upper().strip()
|
||||
if not _IATA_RE.fullmatch(iata):
|
||||
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
|
||||
return
|
||||
topic = f"meshcore/{iata}/{pubkey_hex}/packets"
|
||||
|
||||
await community_publisher.publish(topic, packet)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Community MQTT broadcast error: %s", e)
|
||||
@@ -35,6 +35,11 @@ class Settings(BaseSettings):
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
return self
|
||||
|
||||
@property
|
||||
def loopback_eligible(self) -> bool:
|
||||
"""True when no explicit transport env var is set."""
|
||||
return not self.serial_port and not self.tcp_host and not self.ble_address
|
||||
|
||||
@property
|
||||
def connection_type(self) -> Literal["serial", "tcp", "ble"]:
|
||||
if self.tcp_host:
|
||||
|
||||
125
app/loopback.py
Normal file
125
app/loopback.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Loopback transport: bridges a browser-side serial/BLE connection over WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from starlette.websockets import WebSocket, WebSocketState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoopbackTransport:
|
||||
"""ConnectionProtocol implementation that tunnels bytes over a WebSocket.
|
||||
|
||||
For serial mode, applies the same 0x3c + 2-byte LE size framing that
|
||||
meshcore's SerialConnection uses. For BLE mode, passes raw bytes through
|
||||
(matching BLEConnection behaviour).
|
||||
"""
|
||||
|
||||
def __init__(self, websocket: WebSocket, mode: Literal["serial", "ble"]) -> None:
|
||||
self._ws = websocket
|
||||
self._mode = mode
|
||||
self._reader: Any = None
|
||||
self._disconnect_callback: Any = None
|
||||
|
||||
# Serial framing state (mirrors meshcore serial_cx.py handle_rx)
|
||||
self._header = b""
|
||||
self._inframe = b""
|
||||
self._frame_started = False
|
||||
self._frame_size = 0
|
||||
|
||||
# -- ConnectionProtocol methods ------------------------------------------
|
||||
|
||||
async def connect(self) -> str:
|
||||
"""No-op — the WebSocket is already established."""
|
||||
info = f"Loopback ({self._mode})"
|
||||
logger.info("Loopback transport connected: %s", info)
|
||||
return info
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Ask the browser to release the hardware and close the WS."""
|
||||
try:
|
||||
if self._ws.client_state == WebSocketState.CONNECTED:
|
||||
await self._ws.send_json({"type": "disconnect"})
|
||||
except Exception:
|
||||
pass # WS may already be closed
|
||||
|
||||
async def send(self, data: Any) -> None:
|
||||
"""Send data to the browser (which writes it to the physical radio).
|
||||
|
||||
Serial mode: prepend 0x3c + 2-byte LE size header.
|
||||
BLE mode: send raw bytes.
|
||||
"""
|
||||
try:
|
||||
if self._ws.client_state != WebSocketState.CONNECTED:
|
||||
return
|
||||
if self._mode == "serial":
|
||||
size = len(data)
|
||||
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + bytes(data)
|
||||
await self._ws.send_bytes(pkt)
|
||||
else:
|
||||
await self._ws.send_bytes(bytes(data))
|
||||
except Exception as e:
|
||||
logger.debug("Loopback send error: %s", e)
|
||||
|
||||
def set_reader(self, reader: Any) -> None:
|
||||
self._reader = reader
|
||||
|
||||
def set_disconnect_callback(self, callback: Any) -> None:
|
||||
self._disconnect_callback = callback
|
||||
|
||||
# -- Incoming data from browser ------------------------------------------
|
||||
|
||||
def handle_rx(self, data: bytes) -> None:
|
||||
"""Process bytes received from the browser.
|
||||
|
||||
Serial mode: accumulate bytes, strip framing, deliver payload.
|
||||
BLE mode: deliver raw bytes directly.
|
||||
"""
|
||||
if self._mode == "serial":
|
||||
self._handle_rx_serial(data)
|
||||
else:
|
||||
self._handle_rx_ble(data)
|
||||
|
||||
def _handle_rx_ble(self, data: bytes) -> None:
|
||||
if self._reader is not None:
|
||||
asyncio.create_task(self._reader.handle_rx(data))
|
||||
|
||||
def _handle_rx_serial(self, data: bytes) -> None:
|
||||
"""Mirror meshcore's SerialConnection.handle_rx state machine."""
|
||||
raw = bytes(data)
|
||||
headerlen = len(self._header)
|
||||
|
||||
if not self._frame_started:
|
||||
if len(raw) >= 3 - headerlen:
|
||||
self._header = self._header + raw[: 3 - headerlen]
|
||||
self._frame_started = True
|
||||
self._frame_size = int.from_bytes(self._header[1:], byteorder="little")
|
||||
remainder = raw[3 - headerlen :]
|
||||
# Reset header for next frame
|
||||
self._header = b""
|
||||
if remainder:
|
||||
self._handle_rx_serial(remainder)
|
||||
else:
|
||||
self._header = self._header + raw
|
||||
else:
|
||||
framelen = len(self._inframe)
|
||||
if framelen + len(raw) < self._frame_size:
|
||||
self._inframe = self._inframe + raw
|
||||
else:
|
||||
self._inframe = self._inframe + raw[: self._frame_size - framelen]
|
||||
if self._reader is not None:
|
||||
asyncio.create_task(self._reader.handle_rx(self._inframe))
|
||||
remainder = raw[self._frame_size - framelen :]
|
||||
self._frame_started = False
|
||||
self._inframe = b""
|
||||
if remainder:
|
||||
self._handle_rx_serial(remainder)
|
||||
|
||||
def reset_framing(self) -> None:
|
||||
"""Reset the serial framing state machine."""
|
||||
self._header = b""
|
||||
self._inframe = b""
|
||||
self._frame_started = False
|
||||
self._frame_size = 0
|
||||
@@ -19,6 +19,7 @@ from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
health,
|
||||
loopback,
|
||||
messages,
|
||||
packets,
|
||||
radio,
|
||||
@@ -56,22 +57,19 @@ async def lifespan(app: FastAPI):
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
# Start MQTT publishers if configured
|
||||
from app.community_mqtt import community_publisher
|
||||
# Start MQTT publisher if configured
|
||||
from app.mqtt import mqtt_publisher
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
try:
|
||||
mqtt_settings = await AppSettingsRepository.get()
|
||||
await mqtt_publisher.start(mqtt_settings)
|
||||
await community_publisher.start(mqtt_settings)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start MQTT publisher(s): %s", e)
|
||||
logger.warning("Failed to start MQTT publisher: %s", e)
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
await community_publisher.stop()
|
||||
await mqtt_publisher.stop()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_message_polling()
|
||||
@@ -129,6 +127,7 @@ app.include_router(read_state.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
app.include_router(loopback.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
|
||||
@@ -254,13 +254,6 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 31)
|
||||
applied += 1
|
||||
|
||||
# Migration 32: Add community MQTT columns to app_settings
|
||||
if version < 32:
|
||||
logger.info("Applying migration 32: add community MQTT columns to app_settings")
|
||||
await _migrate_032_add_community_mqtt_columns(conn)
|
||||
await set_version(conn, 32)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -1898,31 +1891,3 @@ async def _migrate_031_add_mqtt_columns(conn: aiosqlite.Connection) -> None:
|
||||
await conn.execute(f"ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_032_add_community_mqtt_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Add community MQTT configuration columns to app_settings."""
|
||||
# Guard: app_settings may not exist in partial-schema test setups
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
new_columns = [
|
||||
("community_mqtt_enabled", "INTEGER DEFAULT 0"),
|
||||
("community_mqtt_iata", "TEXT DEFAULT ''"),
|
||||
("community_mqtt_broker_host", "TEXT DEFAULT 'mqtt-us-v1.letsmesh.net'"),
|
||||
("community_mqtt_broker_port", "INTEGER DEFAULT 443"),
|
||||
("community_mqtt_email", "TEXT DEFAULT ''"),
|
||||
]
|
||||
|
||||
for col_name, col_def in new_columns:
|
||||
if col_name not in columns:
|
||||
await conn.execute(f"ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -463,26 +463,6 @@ class AppSettings(BaseModel):
|
||||
default=False,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
community_mqtt_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish raw packets to the community MQTT broker (letsmesh.net)",
|
||||
)
|
||||
community_mqtt_iata: str = Field(
|
||||
default="",
|
||||
description="IATA region code for community MQTT topic routing (3 alpha chars)",
|
||||
)
|
||||
community_mqtt_broker_host: str = Field(
|
||||
default="mqtt-us-v1.letsmesh.net",
|
||||
description="Community MQTT broker hostname",
|
||||
)
|
||||
community_mqtt_broker_port: int = Field(
|
||||
default=443,
|
||||
description="Community MQTT broker port",
|
||||
)
|
||||
community_mqtt_email: str = Field(
|
||||
default="",
|
||||
description="Email address for node claiming on the community aggregator (optional)",
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
|
||||
158
app/mqtt.py
158
app/mqtt.py
@@ -3,44 +3,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reconnect backoff: start at 5s, cap at 30s
|
||||
_BACKOFF_MIN = 5
|
||||
_BACKOFF_MAX = 30
|
||||
|
||||
class MqttPublisher(BaseMqttPublisher):
|
||||
|
||||
class MqttPublisher:
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
_backoff_max = 30
|
||||
_log_prefix = "MQTT"
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: AppSettings | None = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if MQTT is configured and has something to publish."""
|
||||
return bool(
|
||||
self._settings
|
||||
and self._settings.mqtt_broker_host
|
||||
and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets)
|
||||
)
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
|
||||
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
|
||||
return {
|
||||
"hostname": settings.mqtt_broker_host,
|
||||
"port": settings.mqtt_broker_port,
|
||||
"username": settings.mqtt_username or None,
|
||||
"password": settings.mqtt_password or None,
|
||||
"tls_context": self._build_tls_context(settings),
|
||||
}
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background task and disconnect."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
|
||||
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
|
||||
return ("MQTT connected", f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}")
|
||||
async def restart(self, settings: AppSettings) -> None:
|
||||
"""Called when MQTT settings change — stop + start."""
|
||||
await self.stop()
|
||||
await self.start(settings)
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return ("MQTT connection failure", "Please correct the settings or disable.")
|
||||
async def publish(self, topic: str, payload: dict[str, Any]) -> None:
|
||||
"""Publish a JSON payload. Drops silently if not connected."""
|
||||
if self._client is None or not self.connected:
|
||||
return
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.warning("MQTT publish failed on %s: %s", topic, e)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
|
||||
def _mqtt_configured(self) -> bool:
|
||||
"""Check if MQTT is configured (broker host is set)."""
|
||||
return bool(self._settings and self._settings.mqtt_broker_host)
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Background loop: connect, wait, reconnect on failure."""
|
||||
from app.websocket import broadcast_error, broadcast_success
|
||||
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
while True:
|
||||
if not self._mqtt_configured():
|
||||
self.connected = False
|
||||
self._client = None
|
||||
# Wait until settings change (which might configure MQTT)
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await self._version_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
continue
|
||||
|
||||
settings = self._settings
|
||||
assert settings is not None # guaranteed by _mqtt_configured()
|
||||
version_at_connect = self._settings_version
|
||||
|
||||
try:
|
||||
tls_context = self._build_tls_context(settings)
|
||||
|
||||
async with aiomqtt.Client(
|
||||
hostname=settings.mqtt_broker_host,
|
||||
port=settings.mqtt_broker_port,
|
||||
username=settings.mqtt_username or None,
|
||||
password=settings.mqtt_password or None,
|
||||
tls_context=tls_context,
|
||||
) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
broadcast_success(
|
||||
"MQTT connected",
|
||||
f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}",
|
||||
)
|
||||
_broadcast_mqtt_health()
|
||||
|
||||
# Wait until cancelled or settings version changes.
|
||||
# The 60s timeout is a housekeeping wake-up; actual connection
|
||||
# liveness is handled by paho-mqtt's keepalive mechanism.
|
||||
while self._settings_version == version_at_connect:
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
|
||||
broadcast_error(
|
||||
"MQTT connection failure",
|
||||
"Please correct the settings or disable.",
|
||||
)
|
||||
_broadcast_mqtt_health()
|
||||
logger.warning("MQTT connection error: %s (reconnecting in %ds)", e, backoff)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
backoff = min(backoff * 2, _BACKOFF_MAX)
|
||||
|
||||
@staticmethod
|
||||
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
|
||||
@@ -58,6 +162,14 @@ class MqttPublisher(BaseMqttPublisher):
|
||||
mqtt_publisher = MqttPublisher()
|
||||
|
||||
|
||||
def _broadcast_mqtt_health() -> None:
|
||||
"""Push updated health (including mqtt_status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
|
||||
if event_type not in ("message", "raw_packet"):
|
||||
|
||||
216
app/mqtt_base.py
216
app/mqtt_base.py
@@ -1,216 +0,0 @@
|
||||
"""Shared base class for MQTT publisher lifecycle management.
|
||||
|
||||
Both ``MqttPublisher`` (private broker) and ``CommunityMqttPublisher``
|
||||
(community aggregator) inherit from ``BaseMqttPublisher``, which owns
|
||||
the connection-loop skeleton, reconnect/backoff logic, and publish method.
|
||||
Subclasses override a small set of hooks to control configuration checks,
|
||||
client construction, toast messages, and optional wait-loop behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from app.models import AppSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BACKOFF_MIN = 5
|
||||
|
||||
|
||||
def _broadcast_health() -> None:
|
||||
"""Push updated health (including MQTT status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
class BaseMqttPublisher(ABC):
|
||||
"""Base class for MQTT publishers with shared lifecycle management.
|
||||
|
||||
Subclasses implement the abstract hooks to control configuration checks,
|
||||
client construction, toast messages, and optional wait-loop behavior.
|
||||
"""
|
||||
|
||||
_backoff_max: int = 30
|
||||
_log_prefix: str = "MQTT"
|
||||
_not_configured_timeout: float | None = None # None = block forever
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: AppSettings | None = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background task and disconnect."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
|
||||
async def restart(self, settings: AppSettings) -> None:
|
||||
"""Called when settings change — stop + start."""
|
||||
await self.stop()
|
||||
await self.start(settings)
|
||||
|
||||
async def publish(self, topic: str, payload: dict[str, Any]) -> None:
|
||||
"""Publish a JSON payload. Drops silently if not connected."""
|
||||
if self._client is None or not self.connected:
|
||||
return
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.warning("%s publish failed on %s: %s", self._log_prefix, topic, e)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
|
||||
# ── Abstract hooks ─────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def _is_configured(self) -> bool:
|
||||
"""Return True when this publisher should attempt to connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
|
||||
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the success toast on connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the error toast on connect failure."""
|
||||
|
||||
# ── Optional hooks ─────────────────────────────────────────────────
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
"""Return True to break the inner wait (e.g. token expiry)."""
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: AppSettings) -> bool:
|
||||
"""Called before connecting. Return True to proceed, False to retry."""
|
||||
return True
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
"""Called each time the loop finds the publisher not configured."""
|
||||
return # no-op by default; subclasses may override
|
||||
|
||||
# ── Connection loop ────────────────────────────────────────────────
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Background loop: connect, wait for version change, reconnect on failure."""
|
||||
from app.websocket import broadcast_error, broadcast_success
|
||||
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
while True:
|
||||
if not self._is_configured():
|
||||
self._on_not_configured()
|
||||
self.connected = False
|
||||
self._client = None
|
||||
self._version_event.clear()
|
||||
try:
|
||||
if self._not_configured_timeout is None:
|
||||
await self._version_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self._version_event.wait(),
|
||||
timeout=self._not_configured_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
continue
|
||||
|
||||
settings = self._settings
|
||||
assert settings is not None # guaranteed by _is_configured()
|
||||
version_at_connect = self._settings_version
|
||||
|
||||
try:
|
||||
if not await self._pre_connect(settings):
|
||||
continue
|
||||
|
||||
client_kwargs = self._build_client_kwargs(settings)
|
||||
connect_time = time.monotonic()
|
||||
|
||||
async with aiomqtt.Client(**client_kwargs) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
title, detail = self._on_connected(settings)
|
||||
broadcast_success(title, detail)
|
||||
_broadcast_health()
|
||||
|
||||
# Wait until cancelled or settings version changes.
|
||||
# The 60s timeout is a housekeeping wake-up; actual connection
|
||||
# liveness is handled by paho-mqtt's keepalive mechanism.
|
||||
while self._settings_version == version_at_connect:
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.monotonic() - connect_time
|
||||
if self._should_break_wait(elapsed):
|
||||
break
|
||||
continue
|
||||
|
||||
# async with exited — client is now closed
|
||||
self._client = None
|
||||
self.connected = False
|
||||
_broadcast_health()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
logger.warning(
|
||||
"%s connection error: %s (reconnecting in %ds)",
|
||||
self._log_prefix,
|
||||
e,
|
||||
backoff,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
backoff = min(backoff * 2, self._backoff_max)
|
||||
35
app/radio.py
35
app/radio.py
@@ -128,6 +128,7 @@ class RadioManager:
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self._loopback_active: bool = False
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -317,6 +318,36 @@ class RadioManager:
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self._setup_complete
|
||||
|
||||
@property
|
||||
def loopback_active(self) -> bool:
|
||||
return self._loopback_active
|
||||
|
||||
def connect_loopback(self, mc: MeshCore, connection_info: str) -> None:
|
||||
"""Adopt a MeshCore instance created by the loopback WebSocket endpoint."""
|
||||
self._meshcore = mc
|
||||
self._connection_info = connection_info
|
||||
self._loopback_active = True
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
|
||||
async def disconnect_loopback(self) -> None:
|
||||
"""Tear down a loopback session and resume normal auto-detect."""
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
mc = self._meshcore
|
||||
self._meshcore = None
|
||||
self._loopback_active = False
|
||||
self._connection_info = None
|
||||
self._setup_complete = False
|
||||
|
||||
if mc is not None:
|
||||
try:
|
||||
await mc.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
broadcast_health(False, None)
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
if self._meshcore is not None:
|
||||
@@ -461,6 +492,10 @@ class RadioManager:
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# Skip auto-detect/reconnect while loopback is active
|
||||
if self._loopback_active:
|
||||
continue
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
# Detect status change
|
||||
|
||||
@@ -29,10 +29,7 @@ class AppSettingsRepository:
|
||||
advert_interval, last_advert_time, bots,
|
||||
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
|
||||
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets,
|
||||
community_mqtt_enabled, community_mqtt_iata,
|
||||
community_mqtt_broker_host, community_mqtt_broker_port,
|
||||
community_mqtt_email
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -106,12 +103,6 @@ class AppSettingsRepository:
|
||||
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
|
||||
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
|
||||
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
|
||||
community_mqtt_enabled=bool(row["community_mqtt_enabled"]),
|
||||
community_mqtt_iata=row["community_mqtt_iata"] or "",
|
||||
community_mqtt_broker_host=row["community_mqtt_broker_host"]
|
||||
or "mqtt-us-v1.letsmesh.net",
|
||||
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
|
||||
community_mqtt_email=row["community_mqtt_email"] or "",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -134,11 +125,6 @@ class AppSettingsRepository:
|
||||
mqtt_topic_prefix: str | None = None,
|
||||
mqtt_publish_messages: bool | None = None,
|
||||
mqtt_publish_raw_packets: bool | None = None,
|
||||
community_mqtt_enabled: bool | None = None,
|
||||
community_mqtt_iata: str | None = None,
|
||||
community_mqtt_broker_host: str | None = None,
|
||||
community_mqtt_broker_port: int | None = None,
|
||||
community_mqtt_email: str | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -218,26 +204,6 @@ class AppSettingsRepository:
|
||||
updates.append("mqtt_publish_raw_packets = ?")
|
||||
params.append(1 if mqtt_publish_raw_packets else 0)
|
||||
|
||||
if community_mqtt_enabled is not None:
|
||||
updates.append("community_mqtt_enabled = ?")
|
||||
params.append(1 if community_mqtt_enabled else 0)
|
||||
|
||||
if community_mqtt_iata is not None:
|
||||
updates.append("community_mqtt_iata = ?")
|
||||
params.append(community_mqtt_iata)
|
||||
|
||||
if community_mqtt_broker_host is not None:
|
||||
updates.append("community_mqtt_broker_host = ?")
|
||||
params.append(community_mqtt_broker_host)
|
||||
|
||||
if community_mqtt_broker_port is not None:
|
||||
updates.append("community_mqtt_broker_port = ?")
|
||||
params.append(community_mqtt_broker_port)
|
||||
|
||||
if community_mqtt_email is not None:
|
||||
updates.append("community_mqtt_email = ?")
|
||||
params.append(community_mqtt_email)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
|
||||
@@ -17,7 +17,7 @@ class HealthResponse(BaseModel):
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
mqtt_status: str | None = None
|
||||
community_mqtt_status: str | None = None
|
||||
loopback_eligible: bool = False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
@@ -40,25 +40,13 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
try:
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
if mqtt_publisher._is_configured():
|
||||
if mqtt_publisher._mqtt_configured():
|
||||
mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
|
||||
else:
|
||||
mqtt_status = "disabled"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Community MQTT status
|
||||
community_mqtt_status: str | None = None
|
||||
try:
|
||||
from app.community_mqtt import community_publisher
|
||||
|
||||
if community_publisher._is_configured():
|
||||
community_mqtt_status = "connected" if community_publisher.connected else "disconnected"
|
||||
else:
|
||||
community_mqtt_status = "disabled"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
@@ -66,7 +54,7 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"mqtt_status": mqtt_status,
|
||||
"community_mqtt_status": community_mqtt_status,
|
||||
"loopback_eligible": settings.loopback_eligible,
|
||||
}
|
||||
|
||||
|
||||
|
||||
116
app/routers/loopback.py
Normal file
116
app/routers/loopback.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""WebSocket endpoint for loopback transport (browser-bridged radio connection)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from app.config import settings
|
||||
from app.loopback import LoopbackTransport
|
||||
from app.radio import radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/transport")
|
||||
async def loopback_transport(websocket: WebSocket) -> None:
|
||||
"""Bridge a browser-side serial/BLE connection to the backend MeshCore stack.
|
||||
|
||||
Protocol:
|
||||
1. Client sends init JSON: {"type": "init", "mode": "serial"|"ble"}
|
||||
2. Binary frames flow bidirectionally (raw bytes for BLE, framed for serial)
|
||||
3. Either side can send {"type": "disconnect"} to tear down
|
||||
"""
|
||||
# Guard: reject if an explicit transport is configured via env vars
|
||||
if not settings.loopback_eligible:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4003, reason="Explicit transport configured")
|
||||
return
|
||||
|
||||
# Guard: reject if the radio is already connected (direct or another loopback)
|
||||
if radio_manager.is_connected:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4004, reason="Radio already connected")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
transport: LoopbackTransport | None = None
|
||||
setup_task: asyncio.Task | None = None
|
||||
|
||||
try:
|
||||
# Wait for init message
|
||||
init_raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
|
||||
init_msg = json.loads(init_raw)
|
||||
|
||||
if init_msg.get("type") != "init" or init_msg.get("mode") not in ("serial", "ble"):
|
||||
await websocket.close(code=4001, reason="Invalid init message")
|
||||
return
|
||||
|
||||
mode = init_msg["mode"]
|
||||
logger.info("Loopback init: mode=%s", mode)
|
||||
|
||||
# Create transport and MeshCore instance
|
||||
transport = LoopbackTransport(websocket, mode)
|
||||
|
||||
from meshcore import MeshCore
|
||||
|
||||
mc = MeshCore(transport, auto_reconnect=False, max_reconnect_attempts=0)
|
||||
await mc.connect()
|
||||
|
||||
if not mc.is_connected:
|
||||
logger.warning("Loopback MeshCore failed to connect")
|
||||
await websocket.close(code=4005, reason="MeshCore handshake failed")
|
||||
return
|
||||
|
||||
connection_info = f"Loopback ({mode})"
|
||||
radio_manager.connect_loopback(mc, connection_info)
|
||||
|
||||
# Run post-connect setup in background so the receive loop can run
|
||||
setup_task = asyncio.create_task(radio_manager.post_connect_setup())
|
||||
|
||||
# Main receive loop
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if "bytes" in message and message["bytes"]:
|
||||
transport.handle_rx(message["bytes"])
|
||||
elif "text" in message and message["text"]:
|
||||
try:
|
||||
text_msg = json.loads(message["text"])
|
||||
if text_msg.get("type") == "disconnect":
|
||||
logger.info("Loopback client requested disconnect")
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Loopback WebSocket disconnected")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Loopback init timeout")
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.close(code=4002, reason="Init timeout")
|
||||
except Exception as e:
|
||||
logger.exception("Loopback error: %s", e)
|
||||
finally:
|
||||
if setup_task is not None:
|
||||
setup_task.cancel()
|
||||
try:
|
||||
await setup_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
await radio_manager.disconnect_loopback()
|
||||
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -98,28 +97,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
community_mqtt_enabled: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish raw packets to the community MQTT broker",
|
||||
)
|
||||
community_mqtt_iata: str | None = Field(
|
||||
default=None,
|
||||
description="IATA region code for community MQTT topic routing (3 alpha chars)",
|
||||
)
|
||||
community_mqtt_broker_host: str | None = Field(
|
||||
default=None,
|
||||
description="Community MQTT broker hostname",
|
||||
)
|
||||
community_mqtt_broker_port: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="Community MQTT broker port",
|
||||
)
|
||||
community_mqtt_email: str | None = Field(
|
||||
default=None,
|
||||
description="Email address for node claiming on the community aggregator",
|
||||
)
|
||||
|
||||
|
||||
class FavoriteRequest(BaseModel):
|
||||
@@ -204,47 +181,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
kwargs[field] = value
|
||||
mqtt_changed = True
|
||||
|
||||
# Community MQTT fields
|
||||
community_mqtt_changed = False
|
||||
if update.community_mqtt_enabled is not None:
|
||||
kwargs["community_mqtt_enabled"] = update.community_mqtt_enabled
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_iata is not None:
|
||||
iata = update.community_mqtt_iata.upper().strip()
|
||||
if iata and not re.fullmatch(r"[A-Z]{3}", iata):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="IATA code must be exactly 3 uppercase alphabetic characters",
|
||||
)
|
||||
kwargs["community_mqtt_iata"] = iata
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_broker_host is not None:
|
||||
kwargs["community_mqtt_broker_host"] = update.community_mqtt_broker_host
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_broker_port is not None:
|
||||
kwargs["community_mqtt_broker_port"] = update.community_mqtt_broker_port
|
||||
community_mqtt_changed = True
|
||||
|
||||
if update.community_mqtt_email is not None:
|
||||
kwargs["community_mqtt_email"] = update.community_mqtt_email
|
||||
community_mqtt_changed = True
|
||||
|
||||
# Require IATA when enabling community MQTT
|
||||
if kwargs.get("community_mqtt_enabled", False):
|
||||
# Check the IATA value being set, or fall back to current settings
|
||||
iata_value = kwargs.get("community_mqtt_iata")
|
||||
if iata_value is None:
|
||||
current = await AppSettingsRepository.get()
|
||||
iata_value = current.community_mqtt_iata
|
||||
if not iata_value or not re.fullmatch(r"[A-Z]{3}", iata_value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A valid IATA region code is required to enable community sharing",
|
||||
)
|
||||
|
||||
if kwargs:
|
||||
result = await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
@@ -254,12 +190,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
|
||||
await mqtt_publisher.restart(result)
|
||||
|
||||
# Restart community MQTT publisher if any community settings changed
|
||||
if community_mqtt_changed:
|
||||
from app.community_mqtt import community_publisher
|
||||
|
||||
await community_publisher.restart(result)
|
||||
|
||||
return result
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@@ -104,10 +104,6 @@ def broadcast_event(event_type: str, data: dict) -> None:
|
||||
|
||||
mqtt_broadcast(event_type, data)
|
||||
|
||||
from app.community_mqtt import community_mqtt_broadcast
|
||||
|
||||
community_mqtt_broadcast(event_type, data)
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
"""Broadcast an error notification to all connected clients.
|
||||
|
||||
@@ -228,10 +228,8 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
|
||||
|
||||
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
|
||||
`HealthStatus` also includes `community_mqtt_status` with the same status values.
|
||||
|
||||
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useAppSettings,
|
||||
useConversationRouter,
|
||||
useContactsAndChannels,
|
||||
useLoopback,
|
||||
} from './hooks';
|
||||
import * as messageCache from './messageCache';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
@@ -186,6 +187,8 @@ export function App() {
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
|
||||
const loopback = useLoopback(handleHealthRefresh);
|
||||
|
||||
// Determine if active contact is a repeater (used for routing to dashboard)
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
@@ -481,7 +484,9 @@ export function App() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{SETTINGS_SECTION_ORDER.map((section) => (
|
||||
{SETTINGS_SECTION_ORDER.filter(
|
||||
(s) => s !== 'loopback' || (health?.loopback_eligible && !health?.radio_connected)
|
||||
).map((section) => (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
@@ -671,6 +676,7 @@ export function App() {
|
||||
config={config}
|
||||
health={health}
|
||||
appSettings={appSettings}
|
||||
loopback={loopback}
|
||||
onClose={handleCloseSettingsView}
|
||||
onSave={handleSaveConfig}
|
||||
onSaveAppSettings={handleSaveAppSettings}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
|
||||
import { SettingsBotSection } from './settings/SettingsBotSection';
|
||||
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
|
||||
import { SettingsAboutSection } from './settings/SettingsAboutSection';
|
||||
import { SettingsLoopbackSection } from './settings/SettingsLoopbackSection';
|
||||
import type { UseLoopbackReturn } from '../hooks/useLoopback';
|
||||
|
||||
interface SettingsModalBaseProps {
|
||||
open: boolean;
|
||||
@@ -24,6 +26,7 @@ interface SettingsModalBaseProps {
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
appSettings: AppSettings | null;
|
||||
loopback?: UseLoopbackReturn;
|
||||
onClose: () => void;
|
||||
onSave: (update: RadioConfigUpdate) => Promise<void>;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
@@ -57,6 +60,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onHealthRefresh,
|
||||
onRefreshAppSettings,
|
||||
onLocalLabelChange,
|
||||
loopback,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -74,6 +78,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
radio: !isMobile,
|
||||
identity: false,
|
||||
connectivity: false,
|
||||
loopback: false,
|
||||
mqtt: false,
|
||||
database: false,
|
||||
bot: false,
|
||||
@@ -217,6 +222,18 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('loopback') &&
|
||||
health?.loopback_eligible &&
|
||||
!health?.radio_connected &&
|
||||
loopback && (
|
||||
<div className={sectionWrapperClass}>
|
||||
{renderSectionHeader('loopback')}
|
||||
{isSectionVisible('loopback') && (
|
||||
<SettingsLoopbackSection loopback={loopback} className={sectionContentClass} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderSection('database') && (
|
||||
<div className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
|
||||
@@ -45,17 +45,6 @@ export function SettingsAboutSection({ className }: { className?: string }) {
|
||||
MIT License
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
This code is free, and ad-free, forever. If you love my work,{' '}
|
||||
<a
|
||||
href="https://ko-fi.com/jackkingsman"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
buy me a coffee!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
136
frontend/src/components/settings/SettingsLoopbackSection.tsx
Normal file
136
frontend/src/components/settings/SettingsLoopbackSection.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { UseLoopbackReturn } from '../../hooks/useLoopback';
|
||||
|
||||
export function SettingsLoopbackSection({
|
||||
loopback,
|
||||
className,
|
||||
}: {
|
||||
loopback: UseLoopbackReturn;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
transportType,
|
||||
serialAvailable,
|
||||
bluetoothAvailable,
|
||||
connectSerial,
|
||||
connectBluetooth,
|
||||
disconnect,
|
||||
} = loopback;
|
||||
|
||||
const [baudRate, setBaudRate] = useState('115200');
|
||||
const [selectedTransport, setSelectedTransport] = useState<'serial' | 'ble'>(
|
||||
serialAvailable ? 'serial' : 'ble'
|
||||
);
|
||||
|
||||
const isConnecting = status === 'connecting';
|
||||
const isConnected = status === 'connected';
|
||||
const busy = isConnecting || isConnected;
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (selectedTransport === 'serial') {
|
||||
await connectSerial(parseInt(baudRate, 10) || 115200);
|
||||
} else {
|
||||
await connectBluetooth();
|
||||
}
|
||||
};
|
||||
|
||||
const neitherAvailable = !serialAvailable && !bluetoothAvailable;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No direct radio connection detected. You can bridge a radio connected to{' '}
|
||||
<em>this browser's device</em> via Web Serial or Web Bluetooth.
|
||||
</p>
|
||||
|
||||
{neitherAvailable && (
|
||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/30 p-3 text-sm text-yellow-200">
|
||||
Your browser does not support Web Serial or Web Bluetooth. Use Chrome or Edge on a secure
|
||||
context (HTTPS or localhost).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!neitherAvailable && (
|
||||
<>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm">
|
||||
Connected via {transportType === 'serial' ? 'Serial' : 'Bluetooth'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={disconnect} className="w-full">
|
||||
Disconnect Loopback
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Transport selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Transport</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={selectedTransport === 'serial' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!serialAvailable || busy}
|
||||
onClick={() => setSelectedTransport('serial')}
|
||||
>
|
||||
Serial
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTransport === 'ble' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!bluetoothAvailable || busy}
|
||||
onClick={() => setSelectedTransport('ble')}
|
||||
>
|
||||
Bluetooth
|
||||
</Button>
|
||||
</div>
|
||||
{!serialAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web Serial not available in this browser
|
||||
</p>
|
||||
)}
|
||||
{!bluetoothAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web Bluetooth not available in this browser
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud rate (serial only) */}
|
||||
{selectedTransport === 'serial' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loopback-baud">Baud Rate</Label>
|
||||
<Input
|
||||
id="loopback-baud"
|
||||
type="number"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button onClick={handleConnect} disabled={busy} className="w-full">
|
||||
{isConnecting ? 'Connecting...' : 'Connect via Loopback'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,16 +27,6 @@ export function SettingsMqttSection({
|
||||
const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
|
||||
const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
|
||||
|
||||
// Community MQTT state
|
||||
const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false);
|
||||
const [communityMqttIata, setCommunityMqttIata] = useState('');
|
||||
const [communityMqttBrokerHost, setCommunityMqttBrokerHost] = useState('mqtt-us-v1.letsmesh.net');
|
||||
const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443');
|
||||
const [communityMqttEmail, setCommunityMqttEmail] = useState('');
|
||||
|
||||
const [privateExpanded, setPrivateExpanded] = useState(false);
|
||||
const [communityExpanded, setCommunityExpanded] = useState(false);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -50,11 +40,6 @@ export function SettingsMqttSection({
|
||||
setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
|
||||
setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
|
||||
setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
|
||||
setCommunityMqttEnabled(appSettings.community_mqtt_enabled ?? false);
|
||||
setCommunityMqttIata(appSettings.community_mqtt_iata ?? '');
|
||||
setCommunityMqttBrokerHost(appSettings.community_mqtt_broker_host ?? 'mqtt-us-v1.letsmesh.net');
|
||||
setCommunityMqttBrokerPort(String(appSettings.community_mqtt_broker_port ?? 443));
|
||||
setCommunityMqttEmail(appSettings.community_mqtt_email ?? '');
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -72,11 +57,6 @@ export function SettingsMqttSection({
|
||||
mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
|
||||
mqtt_publish_messages: mqttPublishMessages,
|
||||
mqtt_publish_raw_packets: mqttPublishRawPackets,
|
||||
community_mqtt_enabled: communityMqttEnabled,
|
||||
community_mqtt_iata: communityMqttIata,
|
||||
community_mqtt_broker_host: communityMqttBrokerHost || 'mqtt-us-v1.letsmesh.net',
|
||||
community_mqtt_broker_port: parseInt(communityMqttBrokerPort, 10) || 443,
|
||||
community_mqtt_email: communityMqttEmail,
|
||||
};
|
||||
await onSaveAppSettings(update);
|
||||
toast.success('MQTT settings saved');
|
||||
@@ -89,319 +69,143 @@ export function SettingsMqttSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="rounded-md border border-yellow-600/50 bg-yellow-950/30 px-4 py-3 text-sm text-yellow-200">
|
||||
MQTT support is an experimental feature in open beta. All publishing uses QoS 0
|
||||
(at-most-once delivery). Please report any bugs on the{' '}
|
||||
<a
|
||||
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-yellow-100"
|
||||
>
|
||||
GitHub issues page
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
{/* Private MQTT Broker */}
|
||||
<div className="border border-input rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
|
||||
onClick={() => setPrivateExpanded(!privateExpanded)}
|
||||
>
|
||||
<span className="text-muted-foreground">{privateExpanded ? '▼' : '▶'}</span>
|
||||
<h4 className="text-sm font-medium">Private MQTT Broker</h4>
|
||||
{health?.mqtt_status === 'connected' ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-green-400">Connected</span>
|
||||
</>
|
||||
) : health?.mqtt_status === 'disconnected' ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-xs text-red-400">Disconnected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span className="text-xs text-muted-foreground">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{privateExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-input">
|
||||
<p className="text-xs text-muted-foreground pt-3">
|
||||
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttPublishMessages}
|
||||
onChange={(e) => setMqttPublishMessages(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Publish Messages</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Forward decrypted DM and channel messages
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttPublishRawPackets}
|
||||
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Publish Raw Packets</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
|
||||
|
||||
{(mqttPublishMessages || mqttPublishRawPackets) && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-host">Broker Host</Label>
|
||||
<Input
|
||||
id="mqtt-host"
|
||||
type="text"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={mqttBrokerHost}
|
||||
onChange={(e) => setMqttBrokerHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-port">Broker Port</Label>
|
||||
<Input
|
||||
id="mqtt-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={mqttBrokerPort}
|
||||
onChange={(e) => setMqttBrokerPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-username">Username</Label>
|
||||
<Input
|
||||
id="mqtt-username"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={mqttUsername}
|
||||
onChange={(e) => setMqttUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-password">Password</Label>
|
||||
<Input
|
||||
id="mqtt-password"
|
||||
type="password"
|
||||
placeholder="Optional"
|
||||
value={mqttPassword}
|
||||
onChange={(e) => setMqttPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttUseTls}
|
||||
onChange={(e) => setMqttUseTls(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Use TLS</span>
|
||||
</label>
|
||||
|
||||
{mqttUseTls && (
|
||||
<>
|
||||
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttTlsInsecure}
|
||||
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Skip certificate verification</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Allow self-signed or untrusted broker certificates
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
|
||||
<Input
|
||||
id="mqtt-prefix"
|
||||
type="text"
|
||||
value={mqttTopicPrefix}
|
||||
onChange={(e) => setMqttTopicPrefix(e.target.value)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground space-y-2">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Decrypted messages{' '}
|
||||
<span className="font-mono font-normal opacity-75">
|
||||
{'{'}id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, outgoing, acked{'}'}
|
||||
</span>
|
||||
</p>
|
||||
<div className="font-mono ml-2 space-y-0.5">
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/dm:<contact_key></div>
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/gm:<channel_key></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Raw packets{' '}
|
||||
<span className="font-mono font-normal opacity-75">
|
||||
{'{'}id, observation_id, timestamp, data, payload_type, snr, rssi,
|
||||
decrypted, decrypted_info{'}'}
|
||||
</span>
|
||||
</p>
|
||||
<div className="font-mono ml-2 space-y-0.5">
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/raw/dm:<contact_key></div>
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/raw/gm:<channel_key></div>
|
||||
<div>{mqttTopicPrefix || 'meshcore'}/raw/unrouted</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
{health?.mqtt_status === 'connected' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-green-400">Connected</span>
|
||||
</div>
|
||||
) : health?.mqtt_status === 'disconnected' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-sm text-red-400">Disconnected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span className="text-sm text-muted-foreground">Disabled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Community Analytics */}
|
||||
<div className="border border-input rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
|
||||
onClick={() => setCommunityExpanded(!communityExpanded)}
|
||||
>
|
||||
<span className="text-muted-foreground">{communityExpanded ? '▼' : '▶'}</span>
|
||||
<h4 className="text-sm font-medium">Community Analytics</h4>
|
||||
{health?.community_mqtt_status === 'connected' ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-green-400">Connected</span>
|
||||
</>
|
||||
) : health?.community_mqtt_status === 'disconnected' ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-xs text-red-400">Disconnected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span className="text-xs text-muted-foreground">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Separator />
|
||||
|
||||
{communityExpanded && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-input">
|
||||
<p className="text-xs text-muted-foreground pt-3">
|
||||
Share raw packet data with the MeshCore community for coverage mapping and network
|
||||
analysis. Only raw RF packets are shared — never decrypted messages.
|
||||
</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={communityMqttEnabled}
|
||||
onChange={(e) => setCommunityMqttEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Enable Community Analytics</span>
|
||||
</label>
|
||||
|
||||
{communityMqttEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="community-broker-host">Broker Host</Label>
|
||||
<Input
|
||||
id="community-broker-host"
|
||||
type="text"
|
||||
placeholder="mqtt-us-v1.letsmesh.net"
|
||||
value={communityMqttBrokerHost}
|
||||
onChange={(e) => setCommunityMqttBrokerHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="community-broker-port">Broker Port</Label>
|
||||
<Input
|
||||
id="community-broker-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={communityMqttBrokerPort}
|
||||
onChange={(e) => setCommunityMqttBrokerPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="community-iata">Region Code (IATA)</Label>
|
||||
<Input
|
||||
id="community-iata"
|
||||
type="text"
|
||||
maxLength={3}
|
||||
placeholder="e.g. DEN, LAX, NYC"
|
||||
value={communityMqttIata}
|
||||
onChange={(e) => setCommunityMqttIata(e.target.value.toUpperCase())}
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your nearest airport's{' '}
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/List_of_airports_by_IATA_airport_code:_A"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
IATA code
|
||||
</a>{' '}
|
||||
(required)
|
||||
</p>
|
||||
{communityMqttIata && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Topic: meshcore/{communityMqttIata}/<pubkey>/packets
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="community-email">Owner Email (optional)</Label>
|
||||
<Input
|
||||
id="community-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={communityMqttEmail}
|
||||
onChange={(e) => setCommunityMqttEmail(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used to claim your node on the community aggregator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-host">Broker Host</Label>
|
||||
<Input
|
||||
id="mqtt-host"
|
||||
type="text"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={mqttBrokerHost}
|
||||
onChange={(e) => setMqttBrokerHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-port">Broker Port</Label>
|
||||
<Input
|
||||
id="mqtt-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={mqttBrokerPort}
|
||||
onChange={(e) => setMqttBrokerPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-username">Username</Label>
|
||||
<Input
|
||||
id="mqtt-username"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={mqttUsername}
|
||||
onChange={(e) => setMqttUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-password">Password</Label>
|
||||
<Input
|
||||
id="mqtt-password"
|
||||
type="password"
|
||||
placeholder="Optional"
|
||||
value={mqttPassword}
|
||||
onChange={(e) => setMqttPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttUseTls}
|
||||
onChange={(e) => setMqttUseTls(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Use TLS</span>
|
||||
</label>
|
||||
|
||||
{mqttUseTls && (
|
||||
<>
|
||||
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttTlsInsecure}
|
||||
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Skip certificate verification</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Allow self-signed or untrusted broker certificates
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
|
||||
<Input
|
||||
id="mqtt-prefix"
|
||||
type="text"
|
||||
value={mqttTopicPrefix}
|
||||
onChange={(e) => setMqttTopicPrefix(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Topics: {mqttTopicPrefix || 'meshcore'}/dm:<key>, {mqttTopicPrefix || 'meshcore'}
|
||||
/gm:<key>, {mqttTopicPrefix || 'meshcore'}
|
||||
/raw/...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttPublishMessages}
|
||||
onChange={(e) => setMqttPublishMessages(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Publish Messages</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Forward decrypted DM and channel messages
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttPublishRawPackets}
|
||||
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Publish Raw Packets</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save MQTT Settings'}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'identity'
|
||||
| 'connectivity'
|
||||
| 'loopback'
|
||||
| 'mqtt'
|
||||
| 'database'
|
||||
| 'bot'
|
||||
@@ -12,6 +13,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
'identity',
|
||||
'connectivity',
|
||||
'loopback',
|
||||
'database',
|
||||
'bot',
|
||||
'mqtt',
|
||||
@@ -23,6 +25,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: '📻 Radio',
|
||||
identity: '🪪 Identity',
|
||||
connectivity: '📡 Connectivity',
|
||||
loopback: '🔁 Loopback',
|
||||
database: '🗄️ Database & Interface',
|
||||
bot: '🤖 Bots',
|
||||
mqtt: '📤 MQTT',
|
||||
|
||||
@@ -5,3 +5,4 @@ export { useRepeaterDashboard } from './useRepeaterDashboard';
|
||||
export { useAppSettings } from './useAppSettings';
|
||||
export { useConversationRouter } from './useConversationRouter';
|
||||
export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useLoopback } from './useLoopback';
|
||||
|
||||
332
frontend/src/hooks/useLoopback.ts
Normal file
332
frontend/src/hooks/useLoopback.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export type LoopbackStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
export type LoopbackTransportType = 'serial' | 'ble';
|
||||
|
||||
// Nordic UART Service UUIDs (used by MeshCore BLE)
|
||||
const UART_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
|
||||
const UART_TX_CHAR_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; // notifications from radio
|
||||
const UART_RX_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; // write to radio
|
||||
|
||||
function getTransportWsUrl(): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const isDev = window.location.port === '5173';
|
||||
return isDev
|
||||
? `ws://localhost:8000/api/ws/transport`
|
||||
: `${protocol}//${window.location.host}/api/ws/transport`;
|
||||
}
|
||||
|
||||
export interface UseLoopbackReturn {
|
||||
status: LoopbackStatus;
|
||||
error: string | null;
|
||||
transportType: LoopbackTransportType | null;
|
||||
serialAvailable: boolean;
|
||||
bluetoothAvailable: boolean;
|
||||
connectSerial: (baudRate?: number) => Promise<void>;
|
||||
connectBluetooth: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
export function useLoopback(onConnected?: () => void): UseLoopbackReturn {
|
||||
const [status, setStatus] = useState<LoopbackStatus>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transportType, setTransportType] = useState<LoopbackTransportType | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const serialPortRef = useRef<SerialPort | null>(null);
|
||||
const serialReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null);
|
||||
const bleDeviceRef = useRef<BluetoothDevice | null>(null);
|
||||
const cleaningUpRef = useRef(false);
|
||||
|
||||
const serialAvailable = typeof navigator !== 'undefined' && 'serial' in navigator;
|
||||
const bluetoothAvailable = typeof navigator !== 'undefined' && 'bluetooth' in navigator;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (cleaningUpRef.current) return;
|
||||
cleaningUpRef.current = true;
|
||||
|
||||
// Close WebSocket
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState <= WebSocket.OPEN) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
wsRef.current = null;
|
||||
|
||||
// Close serial reader and port
|
||||
const reader = serialReaderRef.current;
|
||||
if (reader) {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
serialReaderRef.current = null;
|
||||
|
||||
const port = serialPortRef.current;
|
||||
if (port) {
|
||||
try {
|
||||
port.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
serialPortRef.current = null;
|
||||
|
||||
// Disconnect BLE
|
||||
const bleDevice = bleDeviceRef.current;
|
||||
if (bleDevice?.gatt?.connected) {
|
||||
try {
|
||||
bleDevice.gatt.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
bleDeviceRef.current = null;
|
||||
|
||||
setTransportType(null);
|
||||
setStatus('idle');
|
||||
cleaningUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const connectSerial = useCallback(
|
||||
async (baudRate = 115200) => {
|
||||
setError(null);
|
||||
setStatus('connecting');
|
||||
setTransportType('serial');
|
||||
|
||||
try {
|
||||
// Request serial port from user
|
||||
const port = await navigator.serial!.requestPort();
|
||||
await port.open({ baudRate, flowControl: 'none' });
|
||||
|
||||
// Match meshcore serial behaviour
|
||||
try {
|
||||
await port.setSignals({ requestToSend: false });
|
||||
} catch {
|
||||
// Not all adapters support setSignals
|
||||
}
|
||||
|
||||
serialPortRef.current = port;
|
||||
|
||||
// Open transport WebSocket
|
||||
const ws = new WebSocket(getTransportWsUrl());
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => resolve();
|
||||
ws.onerror = () => reject(new Error('Transport WebSocket failed to connect'));
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => reject(new Error('Transport WebSocket timeout')), 10000);
|
||||
ws.addEventListener('open', () => clearTimeout(timeout), { once: true });
|
||||
});
|
||||
|
||||
// Send init
|
||||
ws.send(JSON.stringify({ type: 'init', mode: 'serial' }));
|
||||
|
||||
// Start serial → WS read loop
|
||||
const reader = port.readable!.getReader();
|
||||
serialReaderRef.current = reader;
|
||||
|
||||
const readLoop = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Reader cancelled or port closed — expected during disconnect
|
||||
if (!cleaningUpRef.current) {
|
||||
console.debug('Serial read loop ended:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
readLoop();
|
||||
|
||||
// WS → serial write
|
||||
ws.onmessage = async (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const writer = port.writable!.getWriter();
|
||||
try {
|
||||
await writer.write(new Uint8Array(event.data));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
} else if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'disconnect') {
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
setError('Transport WebSocket error');
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
setStatus('connected');
|
||||
onConnected?.();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Serial connection failed';
|
||||
// Don't show error for user-cancelled port picker
|
||||
if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||
setStatus('idle');
|
||||
setTransportType(null);
|
||||
return;
|
||||
}
|
||||
setError(message);
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
},
|
||||
[cleanup, onConnected]
|
||||
);
|
||||
|
||||
const connectBluetooth = useCallback(async () => {
|
||||
setError(null);
|
||||
setStatus('connecting');
|
||||
setTransportType('ble');
|
||||
|
||||
try {
|
||||
const device = await navigator.bluetooth!.requestDevice({
|
||||
filters: [{ namePrefix: 'MeshCore' }],
|
||||
optionalServices: [UART_SERVICE_UUID],
|
||||
});
|
||||
|
||||
if (!device.gatt) {
|
||||
throw new Error('Bluetooth GATT not available');
|
||||
}
|
||||
|
||||
bleDeviceRef.current = device;
|
||||
|
||||
const server = await device.gatt.connect();
|
||||
const service = await server.getPrimaryService(UART_SERVICE_UUID);
|
||||
const txChar = await service.getCharacteristic(UART_TX_CHAR_UUID);
|
||||
const rxChar = await service.getCharacteristic(UART_RX_CHAR_UUID);
|
||||
|
||||
// Open transport WebSocket
|
||||
const ws = new WebSocket(getTransportWsUrl());
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => resolve();
|
||||
ws.onerror = () => reject(new Error('Transport WebSocket failed to connect'));
|
||||
const timeout = setTimeout(() => reject(new Error('Transport WebSocket timeout')), 10000);
|
||||
ws.addEventListener('open', () => clearTimeout(timeout), { once: true });
|
||||
});
|
||||
|
||||
// Send init
|
||||
ws.send(JSON.stringify({ type: 'init', mode: 'ble' }));
|
||||
|
||||
// BLE RX notifications → WS
|
||||
await txChar.startNotifications();
|
||||
txChar.addEventListener('characteristicvaluechanged', (event) => {
|
||||
const value = (event.target as BluetoothRemoteGATTCharacteristic).value;
|
||||
if (value && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(value.buffer);
|
||||
}
|
||||
});
|
||||
|
||||
// WS → BLE TX
|
||||
ws.onmessage = async (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
await rxChar.writeValueWithResponse(new Uint8Array(event.data));
|
||||
} else if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'disconnect') {
|
||||
cleanup();
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
setError('Transport WebSocket error');
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle BLE disconnect
|
||||
device.addEventListener('gattserverdisconnected', () => {
|
||||
if (!cleaningUpRef.current) {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
setStatus('connected');
|
||||
onConnected?.();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Bluetooth connection failed';
|
||||
// Don't show error for user-cancelled device picker
|
||||
if (err instanceof DOMException && err.name === 'NotFoundError') {
|
||||
setStatus('idle');
|
||||
setTransportType(null);
|
||||
return;
|
||||
}
|
||||
setError(message);
|
||||
cleanup();
|
||||
setStatus('error');
|
||||
}
|
||||
}, [cleanup, onConnected]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
// Send graceful disconnect before cleanup
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'disconnect' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
transportType,
|
||||
serialAvailable,
|
||||
bluetoothAvailable,
|
||||
connectSerial,
|
||||
connectBluetooth,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
139
frontend/src/test/loopback.test.tsx
Normal file
139
frontend/src/test/loopback.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { SettingsLoopbackSection } from '../components/settings/SettingsLoopbackSection';
|
||||
import type { UseLoopbackReturn } from '../hooks/useLoopback';
|
||||
|
||||
function makeLoopback(overrides?: Partial<UseLoopbackReturn>): UseLoopbackReturn {
|
||||
return {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
transportType: null,
|
||||
serialAvailable: true,
|
||||
bluetoothAvailable: true,
|
||||
connectSerial: vi.fn(async () => {}),
|
||||
connectBluetooth: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SettingsLoopbackSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders transport selector and connect button when idle', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback()} />);
|
||||
|
||||
expect(screen.getByText('Serial')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bluetooth')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect via Loopback' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows baud rate input when serial is selected', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback()} />);
|
||||
|
||||
expect(screen.getByLabelText('Baud Rate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides baud rate input when BLE is selected', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback()} />);
|
||||
|
||||
// Click BLE button
|
||||
fireEvent.click(screen.getByText('Bluetooth'));
|
||||
|
||||
expect(screen.queryByLabelText('Baud Rate')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls connectSerial with baud rate on connect', () => {
|
||||
const connectSerial = vi.fn(async () => {});
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ connectSerial })} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect via Loopback' }));
|
||||
|
||||
expect(connectSerial).toHaveBeenCalledWith(115200);
|
||||
});
|
||||
|
||||
it('calls connectBluetooth when BLE selected and connect clicked', () => {
|
||||
const connectBluetooth = vi.fn(async () => {});
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ connectBluetooth })} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Bluetooth'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect via Loopback' }));
|
||||
|
||||
expect(connectBluetooth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows connected state with disconnect button', () => {
|
||||
render(
|
||||
<SettingsLoopbackSection
|
||||
loopback={makeLoopback({ status: 'connected', transportType: 'serial' })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Connected via Serial/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Disconnect Loopback' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Connect via Loopback' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls disconnect on disconnect button click', () => {
|
||||
const disconnect = vi.fn();
|
||||
render(
|
||||
<SettingsLoopbackSection
|
||||
loopback={makeLoopback({ status: 'connected', transportType: 'serial', disconnect })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect Loopback' }));
|
||||
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows connecting state', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ status: 'connecting' })} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(
|
||||
<SettingsLoopbackSection loopback={makeLoopback({ status: 'error', error: 'Port failed' })} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Port failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning when neither serial nor bluetooth available', () => {
|
||||
render(
|
||||
<SettingsLoopbackSection
|
||||
loopback={makeLoopback({ serialAvailable: false, bluetoothAvailable: false })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/does not support Web Serial or Web Bluetooth/)).toBeInTheDocument();
|
||||
// Connect button should not appear
|
||||
expect(screen.queryByRole('button', { name: 'Connect via Loopback' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables serial button when serial not available', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ serialAvailable: false })} />);
|
||||
|
||||
expect(screen.getByText('Serial')).toBeDisabled();
|
||||
expect(screen.getByText(/Web Serial not available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables bluetooth button when bluetooth not available', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ bluetoothAvailable: false })} />);
|
||||
|
||||
expect(screen.getByText('Bluetooth')).toBeDisabled();
|
||||
expect(screen.getByText(/Web Bluetooth not available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to BLE when serial is not available', () => {
|
||||
render(<SettingsLoopbackSection loopback={makeLoopback({ serialAvailable: false })} />);
|
||||
|
||||
// BLE should be selected, so baud rate should NOT be visible
|
||||
expect(screen.queryByLabelText('Baud Rate')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ const baseHealth: HealthStatus = {
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
mqtt_status: null,
|
||||
community_mqtt_status: null,
|
||||
loopback_eligible: false,
|
||||
};
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
@@ -61,11 +61,6 @@ const baseSettings: AppSettings = {
|
||||
mqtt_topic_prefix: 'meshcore',
|
||||
mqtt_publish_messages: false,
|
||||
mqtt_publish_raw_packets: false,
|
||||
community_mqtt_enabled: false,
|
||||
community_mqtt_iata: '',
|
||||
community_mqtt_broker_host: 'mqtt-us-v1.letsmesh.net',
|
||||
community_mqtt_broker_port: 443,
|
||||
community_mqtt_email: '',
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
@@ -155,14 +150,6 @@ function openMqttSection() {
|
||||
fireEvent.click(mqttToggle);
|
||||
}
|
||||
|
||||
function expandPrivateMqtt() {
|
||||
fireEvent.click(screen.getByText('Private MQTT Broker'));
|
||||
}
|
||||
|
||||
function expandCommunityMqtt() {
|
||||
fireEvent.click(screen.getByText('Community Analytics'));
|
||||
}
|
||||
|
||||
function openDatabaseSection() {
|
||||
const databaseToggle = screen.getByRole('button', { name: /Database/i });
|
||||
fireEvent.click(databaseToggle);
|
||||
@@ -422,30 +409,19 @@ describe('SettingsModal', () => {
|
||||
it('renders MQTT section with form inputs', () => {
|
||||
renderModal();
|
||||
openMqttSection();
|
||||
expandPrivateMqtt();
|
||||
|
||||
// Publish checkboxes always visible
|
||||
expect(screen.getByText('Publish Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument();
|
||||
|
||||
// Broker config hidden until a publish option is enabled
|
||||
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
|
||||
|
||||
// Enable one publish option to reveal broker config
|
||||
fireEvent.click(screen.getByText('Publish Messages'));
|
||||
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Broker Port')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Topic Prefix')).toBeInTheDocument();
|
||||
expect(screen.getByText('Publish Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves MQTT settings through onSaveAppSettings', async () => {
|
||||
const { onSaveAppSettings } = renderModal({
|
||||
appSettings: { ...baseSettings, mqtt_publish_messages: true },
|
||||
});
|
||||
const { onSaveAppSettings } = renderModal();
|
||||
openMqttSection();
|
||||
expandPrivateMqtt();
|
||||
|
||||
const hostInput = screen.getByLabelText('Broker Host');
|
||||
fireEvent.change(hostInput, { target: { value: 'mqtt.example.com' } });
|
||||
@@ -471,9 +447,7 @@ describe('SettingsModal', () => {
|
||||
});
|
||||
openMqttSection();
|
||||
|
||||
// Both MQTT and community MQTT show "Disabled" when null status
|
||||
const disabledElements = screen.getAllByText('Disabled');
|
||||
expect(disabledElements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows MQTT connected status badge', () => {
|
||||
@@ -492,75 +466,6 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders community sharing section in MQTT tab', () => {
|
||||
renderModal();
|
||||
openMqttSection();
|
||||
expandCommunityMqtt();
|
||||
|
||||
expect(screen.getByText('Community Analytics')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enable Community Analytics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows IATA input only when community sharing is enabled', () => {
|
||||
renderModal({
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
community_mqtt_enabled: false,
|
||||
},
|
||||
});
|
||||
openMqttSection();
|
||||
expandCommunityMqtt();
|
||||
|
||||
expect(screen.queryByLabelText('Region Code (IATA)')).not.toBeInTheDocument();
|
||||
|
||||
// Enable community sharing
|
||||
fireEvent.click(screen.getByText('Enable Community Analytics'));
|
||||
expect(screen.getByLabelText('Region Code (IATA)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes community MQTT fields in save payload', async () => {
|
||||
const { onSaveAppSettings } = renderModal({
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
community_mqtt_enabled: true,
|
||||
community_mqtt_iata: 'DEN',
|
||||
},
|
||||
});
|
||||
openMqttSection();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaveAppSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
community_mqtt_enabled: true,
|
||||
community_mqtt_iata: 'DEN',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows community MQTT connected status badge', () => {
|
||||
renderModal({
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
community_mqtt_enabled: true,
|
||||
},
|
||||
health: {
|
||||
...baseHealth,
|
||||
community_mqtt_status: 'connected',
|
||||
},
|
||||
});
|
||||
openMqttSection();
|
||||
|
||||
// Community Analytics sub-section should show Connected
|
||||
const communitySection = screen.getByText('Community Analytics').closest('div');
|
||||
expect(communitySection).not.toBeNull();
|
||||
// Both MQTT and community could show "Connected" — check count
|
||||
const connectedElements = screen.getAllByText('Connected');
|
||||
expect(connectedElements.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [],
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface HealthStatus {
|
||||
database_size_mb: number;
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
mqtt_status: string | null;
|
||||
community_mqtt_status: string | null;
|
||||
loopback_eligible: boolean;
|
||||
}
|
||||
|
||||
export interface MaintenanceResult {
|
||||
@@ -193,11 +193,6 @@ export interface AppSettings {
|
||||
mqtt_topic_prefix: string;
|
||||
mqtt_publish_messages: boolean;
|
||||
mqtt_publish_raw_packets: boolean;
|
||||
community_mqtt_enabled: boolean;
|
||||
community_mqtt_iata: string;
|
||||
community_mqtt_broker_host: string;
|
||||
community_mqtt_broker_port: number;
|
||||
community_mqtt_email: string;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -215,11 +210,6 @@ export interface AppSettingsUpdate {
|
||||
mqtt_topic_prefix?: string;
|
||||
mqtt_publish_messages?: boolean;
|
||||
mqtt_publish_raw_packets?: boolean;
|
||||
community_mqtt_enabled?: boolean;
|
||||
community_mqtt_iata?: string;
|
||||
community_mqtt_broker_host?: string;
|
||||
community_mqtt_broker_port?: number;
|
||||
community_mqtt_email?: string;
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
|
||||
81
frontend/src/types/web-serial-bluetooth.d.ts
vendored
Normal file
81
frontend/src/types/web-serial-bluetooth.d.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
// Type declarations for Web Serial API and Web Bluetooth API
|
||||
// These APIs are only available in Chrome/Edge and require secure context.
|
||||
|
||||
// --- Web Serial API ---
|
||||
|
||||
interface SerialPortRequestOptions {
|
||||
filters?: SerialPortFilter[];
|
||||
}
|
||||
|
||||
interface SerialPortFilter {
|
||||
usbVendorId?: number;
|
||||
usbProductId?: number;
|
||||
}
|
||||
|
||||
interface SerialOptions {
|
||||
baudRate: number;
|
||||
dataBits?: number;
|
||||
stopBits?: number;
|
||||
parity?: 'none' | 'even' | 'odd';
|
||||
bufferSize?: number;
|
||||
flowControl?: 'none' | 'hardware';
|
||||
}
|
||||
|
||||
interface SerialPort {
|
||||
readable: ReadableStream<Uint8Array> | null;
|
||||
writable: WritableStream<Uint8Array> | null;
|
||||
open(options: SerialOptions): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
setSignals(signals: { requestToSend?: boolean; dataTerminalReady?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
interface Serial {
|
||||
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>;
|
||||
}
|
||||
|
||||
// --- Web Bluetooth API ---
|
||||
|
||||
interface BluetoothRequestDeviceFilter {
|
||||
namePrefix?: string;
|
||||
name?: string;
|
||||
services?: BluetoothServiceUUID[];
|
||||
}
|
||||
|
||||
type BluetoothServiceUUID = string | number;
|
||||
|
||||
interface RequestDeviceOptions {
|
||||
filters?: BluetoothRequestDeviceFilter[];
|
||||
optionalServices?: BluetoothServiceUUID[];
|
||||
acceptAllDevices?: boolean;
|
||||
}
|
||||
|
||||
interface BluetoothRemoteGATTCharacteristic extends EventTarget {
|
||||
value: DataView | null;
|
||||
startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||
writeValueWithResponse(value: BufferSource): Promise<void>;
|
||||
}
|
||||
|
||||
interface BluetoothRemoteGATTService {
|
||||
getCharacteristic(uuid: string): Promise<BluetoothRemoteGATTCharacteristic>;
|
||||
}
|
||||
|
||||
interface BluetoothRemoteGATTServer {
|
||||
connected: boolean;
|
||||
connect(): Promise<BluetoothRemoteGATTServer>;
|
||||
disconnect(): void;
|
||||
getPrimaryService(uuid: string): Promise<BluetoothRemoteGATTService>;
|
||||
}
|
||||
|
||||
interface BluetoothDevice extends EventTarget {
|
||||
gatt?: BluetoothRemoteGATTServer;
|
||||
}
|
||||
|
||||
interface Bluetooth {
|
||||
requestDevice(options: RequestDeviceOptions): Promise<BluetoothDevice>;
|
||||
}
|
||||
|
||||
// Extend Navigator interface
|
||||
interface Navigator {
|
||||
serial?: Serial;
|
||||
bluetooth?: Bluetooth;
|
||||
}
|
||||
@@ -69,7 +69,6 @@ reportAttributeAccessIssue = "warning"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pip-licenses>=5.0.0",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-xdist>=3.0",
|
||||
|
||||
@@ -35,24 +35,9 @@ PID_BACKEND_LINT=$!
|
||||
) &
|
||||
PID_FRONTEND_LINT=$!
|
||||
|
||||
(
|
||||
echo -e "${BLUE}[licenses]${NC} Checking LICENSES.md freshness..."
|
||||
cd "$SCRIPT_DIR"
|
||||
TMPLIC=$(mktemp)
|
||||
trap "rm -f \$TMPLIC" EXIT
|
||||
bash scripts/collect_licenses.sh "$TMPLIC"
|
||||
if ! diff -q "$TMPLIC" LICENSES.md > /dev/null 2>&1; then
|
||||
echo -e "${RED}[licenses]${NC} LICENSES.md is stale — run scripts/collect_licenses.sh"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[licenses]${NC} Passed!"
|
||||
) &
|
||||
PID_LICENSES=$!
|
||||
|
||||
FAIL=0
|
||||
wait $PID_BACKEND_LINT || FAIL=1
|
||||
wait $PID_FRONTEND_LINT || FAIL=1
|
||||
wait $PID_LICENSES || FAIL=1
|
||||
if [ $FAIL -ne 0 ]; then
|
||||
echo -e "${RED}Phase 1 failed — aborting.${NC}"
|
||||
exit 1
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Collect third-party license texts into LICENSES.md
|
||||
# Usage: scripts/collect_licenses.sh [output-path]
|
||||
# output-path defaults to LICENSES.md at the repo root
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="${1:-$REPO_ROOT/LICENSES.md}"
|
||||
|
||||
# ── Backend (Python) — uses pip-licenses ─────────────────────────────
|
||||
backend_licenses() {
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Extract direct dependency names from pyproject.toml
|
||||
local packages
|
||||
packages=$(uv run python3 -c "
|
||||
import re, tomllib
|
||||
with open('pyproject.toml', 'rb') as f:
|
||||
deps = tomllib.load(f)['project']['dependencies']
|
||||
names = [re.split(r'[\[><=!;]', d)[0].strip() for d in deps]
|
||||
print(' '.join(names))
|
||||
")
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
uv run pip-licenses \
|
||||
--packages $packages \
|
||||
--with-license-file \
|
||||
--no-license-path \
|
||||
--format=json \
|
||||
| uv run python3 -c "
|
||||
import json, sys
|
||||
|
||||
data = sorted(json.load(sys.stdin), key=lambda d: d['Name'].lower())
|
||||
for d in data:
|
||||
name = d['Name']
|
||||
version = d['Version']
|
||||
lic = d.get('License', 'Unknown')
|
||||
text = d.get('LicenseText', '').strip()
|
||||
|
||||
print(f'### {name} ({version}) — {lic}\n')
|
||||
if text and text != 'UNKNOWN':
|
||||
print('<details>')
|
||||
print('<summary>Full license text</summary>')
|
||||
print()
|
||||
print('\`\`\`')
|
||||
print(text)
|
||||
print('\`\`\`')
|
||||
print()
|
||||
print('</details>')
|
||||
else:
|
||||
print('*License file not found in package metadata.*')
|
||||
print()
|
||||
"
|
||||
}
|
||||
|
||||
# ── Frontend (npm) ───────────────────────────────────────────────────
|
||||
frontend_licenses() {
|
||||
cd "$REPO_ROOT/frontend"
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const depNames = Object.keys(pkg.dependencies || {}).sort((a, b) =>
|
||||
a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
);
|
||||
|
||||
for (const name of depNames) {
|
||||
const pkgDir = path.join('node_modules', name);
|
||||
let version = 'unknown';
|
||||
let licenseType = 'Unknown';
|
||||
let licenseText = null;
|
||||
|
||||
// Read package.json for version + license type
|
||||
try {
|
||||
const depPkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
||||
version = depPkg.version || version;
|
||||
licenseType = depPkg.license || licenseType;
|
||||
} catch {}
|
||||
|
||||
// Find license file (case-insensitive search)
|
||||
try {
|
||||
const files = fs.readdirSync(pkgDir);
|
||||
const licFile = files.find(f => /^(licen[sc]e|copying)/i.test(f));
|
||||
if (licFile) {
|
||||
licenseText = fs.readFileSync(path.join(pkgDir, licFile), 'utf8').trim();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
console.log('### ' + name + ' (' + version + ') — ' + licenseType + '\n');
|
||||
if (licenseText) {
|
||||
console.log('<details>');
|
||||
console.log('<summary>Full license text</summary>');
|
||||
console.log();
|
||||
console.log('\`\`\`');
|
||||
console.log(licenseText);
|
||||
console.log('\`\`\`');
|
||||
console.log();
|
||||
console.log('</details>');
|
||||
} else {
|
||||
console.log('*License file not found in package.*');
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# ── Assemble ─────────────────────────────────────────────────────────
|
||||
{
|
||||
echo "# Third-Party Licenses"
|
||||
echo
|
||||
echo "Auto-generated by \`scripts/collect_licenses.sh\` — do not edit by hand."
|
||||
echo
|
||||
echo "## Backend (Python) Dependencies"
|
||||
echo
|
||||
backend_licenses
|
||||
echo "## Frontend (npm) Dependencies"
|
||||
echo
|
||||
frontend_licenses
|
||||
} > "$OUT"
|
||||
|
||||
echo "Wrote $OUT" >&2
|
||||
@@ -3,6 +3,6 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "Starting E2E tests (kicks off a build; this may take a few minutes..."
|
||||
echo "Starting E2E tests..."
|
||||
cd "$SCRIPT_DIR/tests/e2e"
|
||||
npx playwright test "$@"
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Extended Playwright test fixture for tests that depend on receiving
|
||||
* messages from other nodes on the mesh network.
|
||||
*
|
||||
* Usage:
|
||||
* import { test, expect } from '../helpers/meshTrafficTest';
|
||||
* test('my test', { tag: '@mesh-traffic' }, async ({ page }) => { ... });
|
||||
*
|
||||
* When a @mesh-traffic-tagged test fails, an advisory annotation is added
|
||||
* to the HTML report and a console message is printed, letting the user
|
||||
* know the failure may be due to low mesh traffic rather than a real bug.
|
||||
*/
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
|
||||
export { expect };
|
||||
|
||||
const TRAFFIC_ADVISORY =
|
||||
'This test depends on receiving messages from other nodes on the mesh ' +
|
||||
'network. Failure may indicate insufficient mesh traffic rather than a bug.';
|
||||
|
||||
export const test = base.extend<{ _meshTrafficAdvisory: void }>({
|
||||
_meshTrafficAdvisory: [
|
||||
async ({}, use, testInfo) => {
|
||||
await use();
|
||||
if (testInfo.status !== 'passed' && testInfo.tags.includes('@mesh-traffic')) {
|
||||
testInfo.annotations.push({ type: 'notice', description: TRAFFIC_ADVISORY });
|
||||
// Also print to console so it's visible in terminal output
|
||||
console.log(`\n⚠️ ${TRAFFIC_ADVISORY}\n`);
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../helpers/meshTrafficTest';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createChannel, getChannels, getMessages } from '../helpers/api';
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
test('receive an incoming message in any room', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
@@ -102,7 +102,7 @@ test.describe('Incoming mesh messages', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('incoming message with path shows hop badge and path modal', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
test('incoming message with path shows hop badge and path modal', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '../helpers/meshTrafficTest';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Packet Feed page', () => {
|
||||
test('packet feed page loads and shows header', async ({ page }) => {
|
||||
@@ -7,7 +7,7 @@ test.describe('Packet Feed page', () => {
|
||||
await expect(page.getByText('Raw Packet Feed')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('a packet appears in the raw packet feed', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
test('a packet appears in the raw packet feed', async ({ page }) => {
|
||||
// This test waits for real RF traffic — needs 180s timeout
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
"""Tests for community MQTT publisher."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import nacl.bindings
|
||||
import pytest
|
||||
|
||||
from app.community_mqtt import (
|
||||
_CLIENT_ID,
|
||||
_DEFAULT_BROKER,
|
||||
CommunityMqttPublisher,
|
||||
_base64url_encode,
|
||||
_calculate_packet_hash,
|
||||
_ed25519_sign_expanded,
|
||||
_format_raw_packet,
|
||||
_generate_jwt_token,
|
||||
community_mqtt_broadcast,
|
||||
)
|
||||
from app.models import AppSettings
|
||||
|
||||
|
||||
def _make_test_keys() -> tuple[bytes, bytes]:
|
||||
"""Generate a test MeshCore-format key pair.
|
||||
|
||||
Returns (private_key_64_bytes, public_key_32_bytes).
|
||||
MeshCore format: scalar(32) || prefix(32), where scalar is already clamped.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
seed = os.urandom(32)
|
||||
expanded = hashlib.sha512(seed).digest()
|
||||
scalar = bytearray(expanded[:32])
|
||||
# Clamp scalar (standard Ed25519 clamping)
|
||||
scalar[0] &= 248
|
||||
scalar[31] &= 127
|
||||
scalar[31] |= 64
|
||||
scalar = bytes(scalar)
|
||||
prefix = expanded[32:]
|
||||
|
||||
private_key = scalar + prefix
|
||||
public_key = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(scalar)
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
class TestBase64UrlEncode:
|
||||
def test_encodes_without_padding(self):
|
||||
result = _base64url_encode(b"\x00\x01\x02")
|
||||
assert "=" not in result
|
||||
|
||||
def test_uses_url_safe_chars(self):
|
||||
# Bytes that would produce + and / in standard base64
|
||||
result = _base64url_encode(b"\xfb\xff\xfe")
|
||||
assert "+" not in result
|
||||
assert "/" not in result
|
||||
|
||||
|
||||
class TestJwtGeneration:
|
||||
def test_token_has_three_parts(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
|
||||
def test_header_contains_ed25519_alg(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
header_b64 = token.split(".")[0]
|
||||
# Add padding for base64 decoding
|
||||
import base64
|
||||
|
||||
padded = header_b64 + "=" * (4 - len(header_b64) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(padded))
|
||||
assert header["alg"] == "Ed25519"
|
||||
assert header["typ"] == "JWT"
|
||||
|
||||
def test_payload_contains_required_fields(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
payload_b64 = token.split(".")[1]
|
||||
import base64
|
||||
|
||||
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
assert payload["publicKey"] == public_key.hex().upper()
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
assert payload["exp"] - payload["iat"] == 86400
|
||||
assert payload["aud"] == _DEFAULT_BROKER
|
||||
assert payload["owner"] == public_key.hex().upper()
|
||||
assert payload["client"] == _CLIENT_ID
|
||||
assert "email" not in payload # omitted when empty
|
||||
|
||||
def test_payload_includes_email_when_provided(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key, email="test@example.com")
|
||||
payload_b64 = token.split(".")[1]
|
||||
import base64
|
||||
|
||||
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
assert payload["email"] == "test@example.com"
|
||||
|
||||
def test_payload_uses_custom_audience(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key, audience="custom.broker.net")
|
||||
payload_b64 = token.split(".")[1]
|
||||
import base64
|
||||
|
||||
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
assert payload["aud"] == "custom.broker.net"
|
||||
|
||||
def test_signature_is_valid_hex(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
sig_hex = token.split(".")[2]
|
||||
sig_bytes = bytes.fromhex(sig_hex)
|
||||
assert len(sig_bytes) == 64
|
||||
|
||||
def test_signature_verifies(self):
|
||||
"""Verify the JWT signature using nacl.bindings.crypto_sign_open."""
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
parts = token.split(".")
|
||||
signing_input = f"{parts[0]}.{parts[1]}".encode()
|
||||
signature = bytes.fromhex(parts[2])
|
||||
|
||||
# crypto_sign_open expects signature + message concatenated
|
||||
signed_message = signature + signing_input
|
||||
# This will raise if the signature is invalid
|
||||
verified = nacl.bindings.crypto_sign_open(signed_message, public_key)
|
||||
assert verified == signing_input
|
||||
|
||||
|
||||
class TestEddsaSignExpanded:
|
||||
def test_produces_64_byte_signature(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
message = b"test message"
|
||||
sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
assert len(sig) == 64
|
||||
|
||||
def test_signature_verifies_with_nacl(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
message = b"hello world"
|
||||
sig = _ed25519_sign_expanded(message, private_key[:32], private_key[32:], public_key)
|
||||
|
||||
signed_message = sig + message
|
||||
verified = nacl.bindings.crypto_sign_open(signed_message, public_key)
|
||||
assert verified == message
|
||||
|
||||
def test_different_messages_produce_different_signatures(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
sig1 = _ed25519_sign_expanded(b"msg1", private_key[:32], private_key[32:], public_key)
|
||||
sig2 = _ed25519_sign_expanded(b"msg2", private_key[:32], private_key[32:], public_key)
|
||||
assert sig1 != sig2
|
||||
|
||||
|
||||
class TestPacketFormatConversion:
|
||||
def test_basic_field_mapping(self):
|
||||
data = {
|
||||
"id": 1,
|
||||
"observation_id": 100,
|
||||
"timestamp": 1700000000,
|
||||
"data": "0a1b2c3d",
|
||||
"payload_type": "ADVERT",
|
||||
"snr": 5.5,
|
||||
"rssi": -90,
|
||||
"decrypted": False,
|
||||
"decrypted_info": None,
|
||||
}
|
||||
result = _format_raw_packet(data, "TestNode", "AABBCCDD" * 8)
|
||||
|
||||
assert result["origin"] == "TestNode"
|
||||
assert result["origin_id"] == "AABBCCDD" * 8
|
||||
assert result["raw"] == "0A1B2C3D"
|
||||
assert result["SNR"] == "5.5"
|
||||
assert result["RSSI"] == "-90"
|
||||
assert result["type"] == "PACKET"
|
||||
assert result["direction"] == "rx"
|
||||
assert result["len"] == "4"
|
||||
|
||||
def test_timestamp_is_iso8601(self):
|
||||
data = {"timestamp": 1700000000, "data": "00", "snr": None, "rssi": None}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["timestamp"]
|
||||
assert "T" in result["timestamp"]
|
||||
|
||||
def test_snr_rssi_unknown_when_none(self):
|
||||
data = {"timestamp": 0, "data": "00", "snr": None, "rssi": None}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["SNR"] == "Unknown"
|
||||
assert result["RSSI"] == "Unknown"
|
||||
|
||||
def test_packet_type_extraction(self):
|
||||
# Header 0x14 = type 5, route 0 (TRANSPORT_FLOOD): header + 4 transport + path_len.
|
||||
data = {"timestamp": 0, "data": "140102030400", "snr": None, "rssi": None}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["packet_type"] == "5"
|
||||
assert result["route"] == "F"
|
||||
|
||||
def test_route_mapping(self):
|
||||
# Test all 4 route types (matches meshcore-packet-capture)
|
||||
# TRANSPORT_FLOOD=0 -> "F", FLOOD=1 -> "F", DIRECT=2 -> "D", TRANSPORT_DIRECT=3 -> "T"
|
||||
samples = [
|
||||
("000102030400", "F"), # TRANSPORT_FLOOD: header + transport + path_len
|
||||
("0100", "F"), # FLOOD: header + path_len
|
||||
("0200", "D"), # DIRECT: header + path_len
|
||||
("030102030400", "T"), # TRANSPORT_DIRECT: header + transport + path_len
|
||||
]
|
||||
for raw_hex, expected in samples:
|
||||
data = {"timestamp": 0, "data": raw_hex, "snr": None, "rssi": None}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["route"] == expected
|
||||
|
||||
def test_hash_is_16_uppercase_hex_chars(self):
|
||||
data = {"timestamp": 0, "data": "aabb", "snr": None, "rssi": None}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert len(result["hash"]) == 16
|
||||
assert result["hash"] == result["hash"].upper()
|
||||
|
||||
def test_empty_data_handled(self):
|
||||
data = {"timestamp": 0, "data": "", "snr": None, "rssi": None}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["raw"] == ""
|
||||
assert result["len"] == "0"
|
||||
assert result["packet_type"] == "0"
|
||||
assert result["route"] == "U"
|
||||
|
||||
def test_includes_reference_time_fields(self):
|
||||
data = {"timestamp": 0, "data": "0100aabb", "snr": 1.0, "rssi": -70}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["time"]
|
||||
assert result["date"]
|
||||
assert result["payload_len"] == "2"
|
||||
|
||||
def test_adds_path_for_direct_route(self):
|
||||
# route=2 (DIRECT), path_len=2, path=aa bb, payload=cc
|
||||
data = {"timestamp": 0, "data": "0202AABBCC", "snr": 1.0, "rssi": -70}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["route"] == "D"
|
||||
assert result["path"] == "aa,bb"
|
||||
|
||||
def test_direct_route_includes_empty_path_field(self):
|
||||
data = {"timestamp": 0, "data": "0200", "snr": 1.0, "rssi": -70}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["route"] == "D"
|
||||
assert "path" in result
|
||||
assert result["path"] == ""
|
||||
|
||||
def test_unknown_version_uses_defaults(self):
|
||||
# version=1 in high bits, type=5, route=1
|
||||
header = (1 << 6) | (5 << 2) | 1
|
||||
data = {"timestamp": 0, "data": f"{header:02x}00", "snr": 1.0, "rssi": -70}
|
||||
result = _format_raw_packet(data, "Node", "AA" * 32)
|
||||
assert result["packet_type"] == "0"
|
||||
assert result["route"] == "U"
|
||||
assert result["payload_len"] == "0"
|
||||
|
||||
|
||||
class TestCalculatePacketHash:
|
||||
def test_empty_bytes_returns_zeroes(self):
|
||||
result = _calculate_packet_hash(b"")
|
||||
assert result == "0" * 16
|
||||
|
||||
def test_returns_16_uppercase_hex_chars(self):
|
||||
# Simple flood packet: header(1) + path_len(1) + payload
|
||||
raw = bytes([0x01, 0x00, 0xAA, 0xBB]) # FLOOD, no path, payload=0xAABB
|
||||
result = _calculate_packet_hash(raw)
|
||||
assert len(result) == 16
|
||||
assert result == result.upper()
|
||||
|
||||
def test_flood_packet_hash(self):
|
||||
"""FLOOD route (0x01): no transport codes, header + path_len + payload."""
|
||||
import hashlib
|
||||
|
||||
# Header 0x11 = route=FLOOD(1), payload_type=4(ADVERT): (4<<2)|1 = 0x11
|
||||
payload = b"\xde\xad"
|
||||
raw = bytes([0x11, 0x00]) + payload # header, path_len=0, payload
|
||||
result = _calculate_packet_hash(raw)
|
||||
|
||||
# Expected: sha256(payload_type_byte + payload_data)[:16].upper()
|
||||
expected = hashlib.sha256(bytes([4]) + payload).hexdigest()[:16].upper()
|
||||
assert result == expected
|
||||
|
||||
def test_transport_flood_skips_transport_codes(self):
|
||||
"""TRANSPORT_FLOOD (0x00): has 4 bytes of transport codes after header."""
|
||||
import hashlib
|
||||
|
||||
# Header 0x10 = route=TRANSPORT_FLOOD(0), payload_type=4: (4<<2)|0 = 0x10
|
||||
transport_codes = b"\x01\x02\x03\x04"
|
||||
payload = b"\xca\xfe"
|
||||
raw = bytes([0x10]) + transport_codes + bytes([0x00]) + payload
|
||||
result = _calculate_packet_hash(raw)
|
||||
|
||||
expected = hashlib.sha256(bytes([4]) + payload).hexdigest()[:16].upper()
|
||||
assert result == expected
|
||||
|
||||
def test_transport_direct_skips_transport_codes(self):
|
||||
"""TRANSPORT_DIRECT (0x03): also has 4 bytes of transport codes."""
|
||||
import hashlib
|
||||
|
||||
# Header 0x13 = route=TRANSPORT_DIRECT(3), payload_type=4: (4<<2)|3 = 0x13
|
||||
transport_codes = b"\x05\x06\x07\x08"
|
||||
payload = b"\xbe\xef"
|
||||
raw = bytes([0x13]) + transport_codes + bytes([0x00]) + payload
|
||||
result = _calculate_packet_hash(raw)
|
||||
|
||||
expected = hashlib.sha256(bytes([4]) + payload).hexdigest()[:16].upper()
|
||||
assert result == expected
|
||||
|
||||
def test_trace_packet_includes_path_len_in_hash(self):
|
||||
"""TRACE packets (type 9) include path_len as uint16_t LE in the hash."""
|
||||
import hashlib
|
||||
|
||||
# Header for TRACE with FLOOD route: (9<<2)|1 = 0x25
|
||||
path_len = 3
|
||||
path_data = b"\xaa\xbb\xcc"
|
||||
payload = b"\x01\x02"
|
||||
raw = bytes([0x25, path_len]) + path_data + payload
|
||||
result = _calculate_packet_hash(raw)
|
||||
|
||||
expected_hash = (
|
||||
hashlib.sha256(bytes([9]) + path_len.to_bytes(2, byteorder="little") + payload)
|
||||
.hexdigest()[:16]
|
||||
.upper()
|
||||
)
|
||||
assert result == expected_hash
|
||||
|
||||
def test_with_path_data(self):
|
||||
"""Packet with non-zero path_len should skip path bytes to reach payload."""
|
||||
import hashlib
|
||||
|
||||
# FLOOD route, payload_type=2 (TXT_MSG): (2<<2)|1 = 0x09
|
||||
path_data = b"\xaa\xbb" # 2 bytes of path
|
||||
payload = b"\x48\x65\x6c\x6c\x6f" # "Hello"
|
||||
raw = bytes([0x09, 0x02]) + path_data + payload
|
||||
result = _calculate_packet_hash(raw)
|
||||
|
||||
expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper()
|
||||
assert result == expected
|
||||
|
||||
def test_truncated_packet_returns_zeroes(self):
|
||||
# Header says TRANSPORT_FLOOD, but missing path_len at required offset.
|
||||
raw = bytes([0x10, 0x01, 0x02])
|
||||
assert _calculate_packet_hash(raw) == "0" * 16
|
||||
|
||||
|
||||
class TestCommunityMqttPublisher:
|
||||
def test_initial_state(self):
|
||||
pub = CommunityMqttPublisher()
|
||||
assert pub.connected is False
|
||||
assert pub._client is None
|
||||
assert pub._task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_drops_when_disconnected(self):
|
||||
pub = CommunityMqttPublisher()
|
||||
# Should not raise
|
||||
await pub.publish("topic", {"key": "value"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_resets_state(self):
|
||||
pub = CommunityMqttPublisher()
|
||||
pub.connected = True
|
||||
pub._client = MagicMock()
|
||||
await pub.stop()
|
||||
assert pub.connected is False
|
||||
assert pub._client is None
|
||||
|
||||
def test_is_configured_false_when_disabled(self):
|
||||
pub = CommunityMqttPublisher()
|
||||
pub._settings = AppSettings(community_mqtt_enabled=False)
|
||||
with patch("app.keystore.has_private_key", return_value=True):
|
||||
assert pub._is_configured() is False
|
||||
|
||||
def test_is_configured_false_when_no_private_key(self):
|
||||
pub = CommunityMqttPublisher()
|
||||
pub._settings = AppSettings(community_mqtt_enabled=True)
|
||||
with patch("app.keystore.has_private_key", return_value=False):
|
||||
assert pub._is_configured() is False
|
||||
|
||||
def test_is_configured_true_when_enabled_with_key(self):
|
||||
pub = CommunityMqttPublisher()
|
||||
pub._settings = AppSettings(community_mqtt_enabled=True)
|
||||
with patch("app.keystore.has_private_key", return_value=True):
|
||||
assert pub._is_configured() is True
|
||||
|
||||
|
||||
class TestCommunityMqttBroadcast:
|
||||
def test_filters_non_raw_packet(self):
|
||||
"""Non-raw_packet events should be ignored."""
|
||||
with patch("app.community_mqtt.community_publisher") as mock_pub:
|
||||
mock_pub.connected = True
|
||||
mock_pub._settings = AppSettings(community_mqtt_enabled=True)
|
||||
community_mqtt_broadcast("message", {"text": "hello"})
|
||||
# No asyncio.create_task should be called for non-raw_packet events
|
||||
# Since we're filtering, we just verify no exception
|
||||
|
||||
def test_skips_when_disconnected(self):
|
||||
"""Should not publish when disconnected."""
|
||||
with (
|
||||
patch("app.community_mqtt.community_publisher") as mock_pub,
|
||||
patch("app.community_mqtt.asyncio.create_task") as mock_task,
|
||||
):
|
||||
mock_pub.connected = False
|
||||
mock_pub._settings = AppSettings(community_mqtt_enabled=True)
|
||||
community_mqtt_broadcast("raw_packet", {"data": "00"})
|
||||
mock_task.assert_not_called()
|
||||
|
||||
def test_skips_when_settings_none(self):
|
||||
"""Should not publish when settings are None."""
|
||||
with (
|
||||
patch("app.community_mqtt.community_publisher") as mock_pub,
|
||||
patch("app.community_mqtt.asyncio.create_task") as mock_task,
|
||||
):
|
||||
mock_pub.connected = True
|
||||
mock_pub._settings = None
|
||||
community_mqtt_broadcast("raw_packet", {"data": "00"})
|
||||
mock_task.assert_not_called()
|
||||
|
||||
|
||||
class TestPublishFailureSetsDisconnected:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_error_sets_connected_false(self):
|
||||
"""A publish error should set connected=False so the loop can detect it."""
|
||||
pub = CommunityMqttPublisher()
|
||||
pub.connected = True
|
||||
mock_client = MagicMock()
|
||||
mock_client.publish = MagicMock(side_effect=Exception("broker gone"))
|
||||
pub._client = mock_client
|
||||
await pub.publish("topic", {"data": "test"})
|
||||
assert pub.connected is False
|
||||
@@ -34,30 +34,6 @@ class TestHealthMqttStatus:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_disabled_when_nothing_to_publish(self, test_db):
|
||||
"""MQTT status is 'disabled' when broker host is set but no publish options enabled."""
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
original_settings = mqtt_publisher._settings
|
||||
original_connected = mqtt_publisher.connected
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(
|
||||
mqtt_broker_host="broker.local",
|
||||
mqtt_publish_messages=False,
|
||||
mqtt_publish_raw_packets=False,
|
||||
)
|
||||
mqtt_publisher.connected = False
|
||||
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["mqtt_status"] == "disabled"
|
||||
finally:
|
||||
mqtt_publisher._settings = original_settings
|
||||
mqtt_publisher.connected = original_connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_connected_when_publisher_connected(self, test_db):
|
||||
"""MQTT status is 'connected' when publisher is connected."""
|
||||
@@ -68,9 +44,7 @@ class TestHealthMqttStatus:
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(
|
||||
mqtt_broker_host="broker.local", mqtt_publish_messages=True
|
||||
)
|
||||
mqtt_publisher._settings = AppSettings(mqtt_broker_host="broker.local")
|
||||
mqtt_publisher.connected = True
|
||||
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
@@ -90,9 +64,7 @@ class TestHealthMqttStatus:
|
||||
try:
|
||||
from app.models import AppSettings
|
||||
|
||||
mqtt_publisher._settings = AppSettings(
|
||||
mqtt_broker_host="broker.local", mqtt_publish_raw_packets=True
|
||||
)
|
||||
mqtt_publisher._settings = AppSettings(mqtt_broker_host="broker.local")
|
||||
mqtt_publisher.connected = False
|
||||
|
||||
data = await build_health_data(False, None)
|
||||
|
||||
426
tests/test_loopback.py
Normal file
426
tests/test_loopback.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""Tests for loopback transport and WebSocket endpoint."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LoopbackTransport unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoopbackTransportFraming:
|
||||
"""Serial framing round-trip tests (0x3c + 2-byte LE size)."""
|
||||
|
||||
def _make_transport(self, mode="serial"):
|
||||
from app.loopback import LoopbackTransport
|
||||
|
||||
ws = MagicMock()
|
||||
ws.client_state = WebSocketState.CONNECTED
|
||||
ws.send_bytes = AsyncMock()
|
||||
ws.send_json = AsyncMock()
|
||||
return LoopbackTransport(ws, mode), ws
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_serial_adds_framing(self):
|
||||
"""send() in serial mode prepends 0x3c + 2-byte LE size."""
|
||||
transport, ws = self._make_transport("serial")
|
||||
payload = b"\x01\x02\x03\x04\x05"
|
||||
|
||||
await transport.send(payload)
|
||||
|
||||
expected = b"\x3c\x05\x00\x01\x02\x03\x04\x05"
|
||||
ws.send_bytes.assert_awaited_once_with(expected)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_ble_raw(self):
|
||||
"""send() in BLE mode sends raw bytes (no framing)."""
|
||||
transport, ws = self._make_transport("ble")
|
||||
payload = b"\x01\x02\x03"
|
||||
|
||||
await transport.send(payload)
|
||||
|
||||
ws.send_bytes.assert_awaited_once_with(payload)
|
||||
|
||||
def test_handle_rx_serial_strips_framing(self):
|
||||
"""handle_rx in serial mode strips 0x3c header and delivers payload."""
|
||||
transport, _ = self._make_transport("serial")
|
||||
reader = MagicMock()
|
||||
reader.handle_rx = AsyncMock()
|
||||
transport.set_reader(reader)
|
||||
|
||||
payload = b"\xaa\xbb\xcc"
|
||||
# Build framed data: 0x3c + 2-byte LE size + payload
|
||||
framed = b"\x3c" + len(payload).to_bytes(2, "little") + payload
|
||||
|
||||
with patch("app.loopback.asyncio.create_task") as mock_task:
|
||||
transport.handle_rx(framed)
|
||||
|
||||
# reader.handle_rx should be called with the payload only
|
||||
mock_task.assert_called_once()
|
||||
assert reader.handle_rx.call_count == 1
|
||||
assert reader.handle_rx.call_args[0][0] == payload
|
||||
|
||||
def test_handle_rx_serial_incremental(self):
|
||||
"""handle_rx in serial mode handles data arriving byte by byte."""
|
||||
transport, _ = self._make_transport("serial")
|
||||
reader = MagicMock()
|
||||
reader.handle_rx = AsyncMock()
|
||||
transport.set_reader(reader)
|
||||
|
||||
payload = b"\x01\x02"
|
||||
framed = b"\x3c" + len(payload).to_bytes(2, "little") + payload
|
||||
|
||||
with patch("app.loopback.asyncio.create_task") as mock_task:
|
||||
# Feed one byte at a time
|
||||
for byte in framed:
|
||||
transport.handle_rx(bytes([byte]))
|
||||
|
||||
mock_task.assert_called_once()
|
||||
assert reader.handle_rx.call_args[0][0] == payload
|
||||
|
||||
def test_handle_rx_serial_multiple_frames(self):
|
||||
"""handle_rx handles two frames concatenated in one chunk."""
|
||||
transport, _ = self._make_transport("serial")
|
||||
reader = MagicMock()
|
||||
reader.handle_rx = AsyncMock()
|
||||
transport.set_reader(reader)
|
||||
|
||||
p1 = b"\x01\x02"
|
||||
p2 = b"\x03\x04\x05"
|
||||
framed = (
|
||||
b"\x3c"
|
||||
+ len(p1).to_bytes(2, "little")
|
||||
+ p1
|
||||
+ b"\x3c"
|
||||
+ len(p2).to_bytes(2, "little")
|
||||
+ p2
|
||||
)
|
||||
|
||||
with patch("app.loopback.asyncio.create_task") as mock_task:
|
||||
transport.handle_rx(framed)
|
||||
|
||||
assert mock_task.call_count == 2
|
||||
assert reader.handle_rx.call_args_list[0][0][0] == p1
|
||||
assert reader.handle_rx.call_args_list[1][0][0] == p2
|
||||
|
||||
def test_handle_rx_ble_passthrough(self):
|
||||
"""handle_rx in BLE mode passes raw bytes to reader directly."""
|
||||
transport, _ = self._make_transport("ble")
|
||||
reader = MagicMock()
|
||||
reader.handle_rx = AsyncMock()
|
||||
transport.set_reader(reader)
|
||||
|
||||
data = b"\xde\xad\xbe\xef"
|
||||
|
||||
with patch("app.loopback.asyncio.create_task") as mock_task:
|
||||
transport.handle_rx(data)
|
||||
|
||||
mock_task.assert_called_once()
|
||||
assert reader.handle_rx.call_args[0][0] == data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_returns_info_string(self):
|
||||
"""connect() returns a descriptive string."""
|
||||
transport, _ = self._make_transport("serial")
|
||||
result = await transport.connect()
|
||||
assert "Loopback" in result
|
||||
assert "serial" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_sends_json(self):
|
||||
"""disconnect() sends a disconnect JSON message."""
|
||||
transport, ws = self._make_transport("serial")
|
||||
await transport.disconnect()
|
||||
ws.send_json.assert_awaited_once_with({"type": "disconnect"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_handles_closed_ws(self):
|
||||
"""disconnect() does not raise if WS is already closed."""
|
||||
transport, ws = self._make_transport("serial")
|
||||
ws.client_state = WebSocketState.DISCONNECTED
|
||||
# Should not raise
|
||||
await transport.disconnect()
|
||||
ws.send_json.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_noop_when_ws_closed(self):
|
||||
"""send() does nothing when WebSocket is not connected."""
|
||||
transport, ws = self._make_transport("serial")
|
||||
ws.client_state = WebSocketState.DISCONNECTED
|
||||
await transport.send(b"\x01\x02")
|
||||
ws.send_bytes.assert_not_awaited()
|
||||
|
||||
def test_set_reader_and_callback(self):
|
||||
"""set_reader and set_disconnect_callback store references."""
|
||||
transport, _ = self._make_transport("serial")
|
||||
reader = MagicMock()
|
||||
callback = MagicMock()
|
||||
transport.set_reader(reader)
|
||||
transport.set_disconnect_callback(callback)
|
||||
assert transport._reader is reader
|
||||
assert transport._disconnect_callback is callback
|
||||
|
||||
def test_reset_framing(self):
|
||||
"""reset_framing clears the state machine."""
|
||||
transport, _ = self._make_transport("serial")
|
||||
transport._frame_started = True
|
||||
transport._header = b"\x3c\x05"
|
||||
transport._inframe = b"\x01\x02"
|
||||
transport._frame_size = 5
|
||||
|
||||
transport.reset_framing()
|
||||
|
||||
assert transport._frame_started is False
|
||||
assert transport._header == b""
|
||||
assert transport._inframe == b""
|
||||
assert transport._frame_size == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RadioManager loopback methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRadioManagerLoopback:
|
||||
"""Tests for connect_loopback / disconnect_loopback state transitions."""
|
||||
|
||||
def test_connect_loopback_sets_state(self):
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mc = MagicMock()
|
||||
rm.connect_loopback(mc, "Loopback (serial)")
|
||||
|
||||
assert rm.meshcore is mc
|
||||
assert rm.connection_info == "Loopback (serial)"
|
||||
assert rm.loopback_active is True
|
||||
assert rm._last_connected is True
|
||||
assert rm._setup_complete is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_loopback_clears_state(self):
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mc = MagicMock()
|
||||
mc.disconnect = AsyncMock()
|
||||
rm.connect_loopback(mc, "Loopback (serial)")
|
||||
|
||||
with patch("app.websocket.broadcast_health"):
|
||||
await rm.disconnect_loopback()
|
||||
|
||||
assert rm.meshcore is None
|
||||
assert rm.connection_info is None
|
||||
assert rm.loopback_active is False
|
||||
mc.disconnect.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_loopback_handles_mc_disconnect_error(self):
|
||||
"""disconnect_loopback doesn't raise if mc.disconnect() fails."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mc = MagicMock()
|
||||
mc.disconnect = AsyncMock(side_effect=OSError("transport closed"))
|
||||
rm.connect_loopback(mc, "Loopback (ble)")
|
||||
|
||||
with patch("app.websocket.broadcast_health"):
|
||||
# Should not raise
|
||||
await rm.disconnect_loopback()
|
||||
|
||||
assert rm.loopback_active is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_loopback_broadcasts_health_false(self):
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mc = MagicMock()
|
||||
mc.disconnect = AsyncMock()
|
||||
rm.connect_loopback(mc, "Loopback (serial)")
|
||||
|
||||
with patch("app.websocket.broadcast_health") as mock_bh:
|
||||
await rm.disconnect_loopback()
|
||||
|
||||
mock_bh.assert_called_once_with(False, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_skips_reconnect_during_loopback(self):
|
||||
"""Connection monitor skips auto-detect when _loopback_active is True."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mc = MagicMock()
|
||||
mc.is_connected = True
|
||||
rm.connect_loopback(mc, "Loopback (serial)")
|
||||
|
||||
rm.reconnect = AsyncMock()
|
||||
|
||||
sleep_count = 0
|
||||
|
||||
async def _sleep(_seconds: float):
|
||||
nonlocal sleep_count
|
||||
sleep_count += 1
|
||||
if sleep_count >= 3:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch("app.radio.asyncio.sleep", side_effect=_sleep):
|
||||
await rm.start_connection_monitor()
|
||||
try:
|
||||
await rm._reconnect_task
|
||||
finally:
|
||||
await rm.stop_connection_monitor()
|
||||
|
||||
# reconnect should never be called while loopback is active
|
||||
rm.reconnect.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoopbackEndpointGuards:
|
||||
"""Tests for the /ws/transport WebSocket endpoint guard conditions."""
|
||||
|
||||
def test_rejects_when_explicit_transport_configured(self):
|
||||
"""Endpoint closes when explicit transport env is set."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
with (
|
||||
patch("app.routers.loopback.settings") as mock_settings,
|
||||
patch("app.routers.loopback.radio_manager"),
|
||||
):
|
||||
mock_settings.loopback_eligible = False
|
||||
|
||||
client = TestClient(app)
|
||||
# Endpoint accepts then immediately closes — verify by catching the close
|
||||
with client.websocket_connect("/api/ws/transport") as ws:
|
||||
closed = False
|
||||
try:
|
||||
ws.receive_text()
|
||||
except Exception: # noqa: BLE001
|
||||
closed = True
|
||||
assert closed
|
||||
|
||||
def test_rejects_when_radio_already_connected(self):
|
||||
"""Endpoint closes when radio is already connected."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
with (
|
||||
patch("app.routers.loopback.settings") as mock_settings,
|
||||
patch("app.routers.loopback.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_settings.loopback_eligible = True
|
||||
mock_rm.is_connected = True
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/api/ws/transport") as ws:
|
||||
closed = False
|
||||
try:
|
||||
ws.receive_text()
|
||||
except Exception: # noqa: BLE001
|
||||
closed = True
|
||||
assert closed
|
||||
|
||||
|
||||
class TestLoopbackEndpointInit:
|
||||
"""Tests for the init handshake and basic operation."""
|
||||
|
||||
def test_rejects_invalid_init_message(self):
|
||||
"""Endpoint closes on invalid init JSON."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
with (
|
||||
patch("app.routers.loopback.settings") as mock_settings,
|
||||
patch("app.routers.loopback.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_settings.loopback_eligible = True
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.disconnect_loopback = AsyncMock()
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/api/ws/transport") as ws:
|
||||
ws.send_text(json.dumps({"type": "init", "mode": "invalid"}))
|
||||
closed = False
|
||||
try:
|
||||
ws.receive_text()
|
||||
except Exception: # noqa: BLE001
|
||||
closed = True
|
||||
assert closed
|
||||
|
||||
def test_accepts_valid_serial_init(self):
|
||||
"""Endpoint accepts valid serial init and proceeds to MeshCore creation."""
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.connect = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("app.routers.loopback.settings") as mock_settings,
|
||||
patch("app.routers.loopback.radio_manager") as mock_rm,
|
||||
patch("meshcore.MeshCore", return_value=mock_mc) as mock_mc_cls,
|
||||
):
|
||||
mock_settings.loopback_eligible = True
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.connect_loopback = MagicMock()
|
||||
mock_rm.post_connect_setup = AsyncMock()
|
||||
mock_rm.disconnect_loopback = AsyncMock()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/api/ws/transport") as ws:
|
||||
ws.send_text(json.dumps({"type": "init", "mode": "serial"}))
|
||||
# Send disconnect to close cleanly
|
||||
ws.send_text(json.dumps({"type": "disconnect"}))
|
||||
|
||||
# Verify MeshCore was created and connect_loopback called
|
||||
mock_mc_cls.assert_called_once()
|
||||
mock_mc.connect.assert_awaited_once()
|
||||
mock_rm.connect_loopback.assert_called_once()
|
||||
mock_rm.disconnect_loopback.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loopback_eligible property
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigLoopbackEligible:
|
||||
"""Tests for the loopback_eligible property."""
|
||||
|
||||
def test_eligible_when_no_transport_set(self):
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="")
|
||||
assert s.loopback_eligible is True
|
||||
|
||||
def test_not_eligible_with_serial_port(self):
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(serial_port="/dev/ttyUSB0")
|
||||
assert s.loopback_eligible is False
|
||||
|
||||
def test_not_eligible_with_tcp_host(self):
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(tcp_host="192.168.1.1")
|
||||
assert s.loopback_eligible is False
|
||||
|
||||
def test_not_eligible_with_ble(self):
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="1234")
|
||||
assert s.loopback_eligible is False
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 32 # All migrations run
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 31 # All migrations run
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify columns exist by inserting and selecting
|
||||
await conn.execute(
|
||||
@@ -183,9 +183,9 @@ class TestMigration001:
|
||||
applied1 = await run_migrations(conn)
|
||||
applied2 = await run_migrations(conn)
|
||||
|
||||
assert applied1 == 32 # All migrations run
|
||||
assert applied1 == 31 # All migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 32
|
||||
assert await get_version(conn) == 31
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All migrations applied (version incremented) but no error
|
||||
assert applied == 32
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 31
|
||||
assert await get_version(conn) == 31
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration 13 (plus 14-33 which also run)
|
||||
# Run migration 13 (plus 14-27 which also run)
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 20
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 19
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
@@ -497,7 +497,7 @@ class TestMigration018:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 32
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -575,8 +575,8 @@ class TestMigration018:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 15 # Migrations 18-32 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 14 # Migrations 18-31 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 31
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -648,7 +648,7 @@ class TestMigration019:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 32
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -714,8 +714,8 @@ class TestMigration020:
|
||||
assert (await cursor.fetchone())[0] == "delete"
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 13 # Migrations 20-32
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 12 # Migrations 20-31
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify WAL mode
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -745,7 +745,7 @@ class TestMigration020:
|
||||
await set_version(conn, 20)
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 12 # Migrations 21-32 still run
|
||||
assert applied == 11 # Migrations 21-31 still run
|
||||
|
||||
# Still WAL + INCREMENTAL
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -803,8 +803,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify payload_hash column is now BLOB
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
@@ -873,8 +873,8 @@ class TestMigration028:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 5 # Version still bumped
|
||||
assert await get_version(conn) == 32
|
||||
assert applied == 4 # Version still bumped
|
||||
assert await get_version(conn) == 31
|
||||
|
||||
# Verify data unchanged
|
||||
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
|
||||
@@ -882,62 +882,3 @@ class TestMigration028:
|
||||
assert bytes(row["payload_hash"]) == b"\xab" * 32
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigration032:
|
||||
"""Test migration 032: add community MQTT columns to app_settings."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_adds_all_community_mqtt_columns(self):
|
||||
"""Migration adds enabled, iata, broker, and email columns."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
await set_version(conn, 31)
|
||||
|
||||
# Create app_settings without community columns (pre-migration schema)
|
||||
await conn.execute("""
|
||||
CREATE TABLE app_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0,
|
||||
advert_interval INTEGER DEFAULT 0,
|
||||
last_advert_time INTEGER DEFAULT 0,
|
||||
bots TEXT DEFAULT '[]',
|
||||
mqtt_broker_host TEXT DEFAULT '',
|
||||
mqtt_broker_port INTEGER DEFAULT 1883,
|
||||
mqtt_username TEXT DEFAULT '',
|
||||
mqtt_password TEXT DEFAULT '',
|
||||
mqtt_use_tls INTEGER DEFAULT 0,
|
||||
mqtt_tls_insecure INTEGER DEFAULT 0,
|
||||
mqtt_topic_prefix TEXT DEFAULT 'meshcore',
|
||||
mqtt_publish_messages INTEGER DEFAULT 0,
|
||||
mqtt_publish_raw_packets INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
await conn.execute("INSERT INTO app_settings (id) VALUES (1)")
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 32
|
||||
|
||||
# Verify all columns exist with correct defaults
|
||||
cursor = await conn.execute(
|
||||
"""SELECT community_mqtt_enabled, community_mqtt_iata,
|
||||
community_mqtt_broker_host, community_mqtt_broker_port,
|
||||
community_mqtt_email
|
||||
FROM app_settings WHERE id = 1"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row["community_mqtt_enabled"] == 0
|
||||
assert row["community_mqtt_iata"] == ""
|
||||
assert row["community_mqtt_broker_host"] == "mqtt-us-v1.letsmesh.net"
|
||||
assert row["community_mqtt_broker_port"] == 443
|
||||
assert row["community_mqtt_email"] == ""
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -81,12 +81,12 @@ class TestMqttPublisher:
|
||||
def test_not_configured_when_host_empty(self):
|
||||
pub = MqttPublisher()
|
||||
pub._settings = _make_settings(mqtt_broker_host="")
|
||||
assert pub._is_configured() is False
|
||||
assert pub._mqtt_configured() is False
|
||||
|
||||
def test_configured_when_host_set(self):
|
||||
pub = MqttPublisher()
|
||||
pub._settings = _make_settings(mqtt_broker_host="broker.local")
|
||||
assert pub._is_configured() is True
|
||||
assert pub._mqtt_configured() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_drops_silently_when_disconnected(self):
|
||||
@@ -300,8 +300,8 @@ class TestConnectionLoop:
|
||||
mock_client.__aenter__ = AsyncMock(side_effect=side_effect_aenter)
|
||||
|
||||
with (
|
||||
patch("app.mqtt_base.aiomqtt.Client", return_value=mock_client),
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.mqtt.aiomqtt.Client", return_value=mock_client),
|
||||
patch("app.mqtt._broadcast_mqtt_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
@@ -321,7 +321,7 @@ class TestConnectionLoop:
|
||||
"""Connection loop should retry after a connection error with backoff."""
|
||||
import asyncio
|
||||
|
||||
from app.mqtt_base import _BACKOFF_MIN
|
||||
from app.mqtt import _BACKOFF_MIN
|
||||
|
||||
pub = MqttPublisher()
|
||||
settings = _make_settings()
|
||||
@@ -354,12 +354,12 @@ class TestConnectionLoop:
|
||||
return factory
|
||||
|
||||
with (
|
||||
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_client_factory()),
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.mqtt.aiomqtt.Client", side_effect=make_client_factory()),
|
||||
patch("app.mqtt._broadcast_mqtt_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
patch("app.mqtt_base.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
|
||||
patch("app.mqtt.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
|
||||
):
|
||||
await pub.start(settings)
|
||||
|
||||
@@ -375,10 +375,10 @@ class TestConnectionLoop:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_increases_on_repeated_failures(self):
|
||||
"""Backoff should double after each failure, capped at _backoff_max."""
|
||||
"""Backoff should double after each failure, capped at _BACKOFF_MAX."""
|
||||
import asyncio
|
||||
|
||||
from app.mqtt_base import _BACKOFF_MIN
|
||||
from app.mqtt import _BACKOFF_MAX, _BACKOFF_MIN
|
||||
|
||||
pub = MqttPublisher()
|
||||
settings = _make_settings()
|
||||
@@ -408,11 +408,11 @@ class TestConnectionLoop:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with (
|
||||
patch("app.mqtt_base.aiomqtt.Client", side_effect=factory),
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.mqtt.aiomqtt.Client", side_effect=factory),
|
||||
patch("app.mqtt._broadcast_mqtt_health"),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
patch("app.mqtt_base.asyncio.sleep", side_effect=capture_sleep),
|
||||
patch("app.mqtt.asyncio.sleep", side_effect=capture_sleep),
|
||||
):
|
||||
await pub.start(settings)
|
||||
try:
|
||||
@@ -423,8 +423,8 @@ class TestConnectionLoop:
|
||||
assert sleep_args[0] == _BACKOFF_MIN
|
||||
assert sleep_args[1] == _BACKOFF_MIN * 2
|
||||
assert sleep_args[2] == _BACKOFF_MIN * 4
|
||||
# Fourth should be capped at _backoff_max (5*8=40 > 30)
|
||||
assert sleep_args[3] == MqttPublisher._backoff_max
|
||||
# Fourth should be capped at _BACKOFF_MAX (5*8=40 > 30)
|
||||
assert sleep_args[3] == _BACKOFF_MAX
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waits_for_settings_when_unconfigured(self):
|
||||
@@ -449,8 +449,8 @@ class TestConnectionLoop:
|
||||
return mock
|
||||
|
||||
with (
|
||||
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_success_client),
|
||||
patch("app.mqtt_base._broadcast_health"),
|
||||
patch("app.mqtt.aiomqtt.Client", side_effect=make_success_client),
|
||||
patch("app.mqtt._broadcast_mqtt_health"),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
@@ -472,7 +472,7 @@ class TestConnectionLoop:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_broadcast_on_connect_and_failure(self):
|
||||
"""_broadcast_health should be called on connect and on failure."""
|
||||
"""_broadcast_mqtt_health should be called on connect and on failure."""
|
||||
import asyncio
|
||||
|
||||
pub = MqttPublisher()
|
||||
@@ -497,8 +497,8 @@ class TestConnectionLoop:
|
||||
return mock
|
||||
|
||||
with (
|
||||
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_client),
|
||||
patch("app.mqtt_base._broadcast_health", side_effect=track_health),
|
||||
patch("app.mqtt.aiomqtt.Client", side_effect=make_client),
|
||||
patch("app.mqtt._broadcast_mqtt_health", side_effect=track_health),
|
||||
patch("app.websocket.broadcast_success"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
):
|
||||
@@ -512,7 +512,7 @@ class TestConnectionLoop:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_broadcast_on_connection_error(self):
|
||||
"""_broadcast_health should be called when connection fails."""
|
||||
"""_broadcast_mqtt_health should be called when connection fails."""
|
||||
import asyncio
|
||||
|
||||
pub = MqttPublisher()
|
||||
@@ -534,11 +534,11 @@ class TestConnectionLoop:
|
||||
return mock
|
||||
|
||||
with (
|
||||
patch("app.mqtt_base.aiomqtt.Client", side_effect=make_failing_client),
|
||||
patch("app.mqtt_base._broadcast_health", side_effect=track_health),
|
||||
patch("app.mqtt.aiomqtt.Client", side_effect=make_failing_client),
|
||||
patch("app.mqtt._broadcast_mqtt_health", side_effect=track_health),
|
||||
patch("app.websocket.broadcast_error"),
|
||||
patch("app.websocket.broadcast_health"),
|
||||
patch("app.mqtt_base.asyncio.sleep", side_effect=cancel_on_sleep),
|
||||
patch("app.mqtt.asyncio.sleep", side_effect=cancel_on_sleep),
|
||||
):
|
||||
await pub.start(settings)
|
||||
try:
|
||||
|
||||
@@ -502,11 +502,6 @@ class TestAppSettingsRepository:
|
||||
"mqtt_topic_prefix": "meshcore",
|
||||
"mqtt_publish_messages": 0,
|
||||
"mqtt_publish_raw_packets": 0,
|
||||
"community_mqtt_enabled": 0,
|
||||
"community_mqtt_iata": "",
|
||||
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
|
||||
"community_mqtt_broker_port": 443,
|
||||
"community_mqtt_email": "",
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
@@ -117,81 +117,6 @@ class TestUpdateSettings:
|
||||
assert settings.mqtt_publish_messages is False
|
||||
assert settings.mqtt_publish_raw_packets is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_fields_round_trip(self, test_db):
|
||||
"""Community MQTT settings should be saved and retrieved correctly."""
|
||||
mock_community = type("MockCommunity", (), {"restart": AsyncMock()})()
|
||||
with patch("app.community_mqtt.community_publisher", mock_community):
|
||||
result = await update_settings(
|
||||
AppSettingsUpdate(
|
||||
community_mqtt_enabled=True,
|
||||
community_mqtt_iata="DEN",
|
||||
community_mqtt_broker_host="custom-broker.example.com",
|
||||
community_mqtt_broker_port=8883,
|
||||
community_mqtt_email="test@example.com",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.community_mqtt_enabled is True
|
||||
assert result.community_mqtt_iata == "DEN"
|
||||
assert result.community_mqtt_broker_host == "custom-broker.example.com"
|
||||
assert result.community_mqtt_broker_port == 8883
|
||||
assert result.community_mqtt_email == "test@example.com"
|
||||
|
||||
# Verify persistence
|
||||
fresh = await AppSettingsRepository.get()
|
||||
assert fresh.community_mqtt_enabled is True
|
||||
assert fresh.community_mqtt_iata == "DEN"
|
||||
assert fresh.community_mqtt_broker_host == "custom-broker.example.com"
|
||||
assert fresh.community_mqtt_broker_port == 8883
|
||||
assert fresh.community_mqtt_email == "test@example.com"
|
||||
|
||||
# Verify restart was called
|
||||
mock_community.restart.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_iata_validation_rejects_invalid(self, test_db):
|
||||
"""Invalid IATA codes should be rejected."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="A"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="ABCDE"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="12"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_iata="ABCD"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_enable_requires_iata(self, test_db):
|
||||
"""Enabling community MQTT without a valid IATA code should be rejected."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_settings(AppSettingsUpdate(community_mqtt_enabled=True))
|
||||
assert exc.value.status_code == 400
|
||||
assert "IATA" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_iata_uppercased(self, test_db):
|
||||
"""IATA codes should be uppercased."""
|
||||
mock_community = type("MockCommunity", (), {"restart": AsyncMock()})()
|
||||
with patch("app.community_mqtt.community_publisher", mock_community):
|
||||
result = await update_settings(AppSettingsUpdate(community_mqtt_iata="den"))
|
||||
assert result.community_mqtt_iata == "DEN"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_community_mqtt_defaults_on_fresh_db(self, test_db):
|
||||
"""Community MQTT fields should have correct defaults on a fresh database."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
assert settings.community_mqtt_enabled is False
|
||||
assert settings.community_mqtt_iata == ""
|
||||
assert settings.community_mqtt_email == ""
|
||||
|
||||
|
||||
class TestToggleFavorite:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
36
uv.lock
generated
36
uv.lock
generated
@@ -420,19 +420,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pip-licenses"
|
||||
version = "5.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prettytable" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/4c/b4be9024dae3b5b3c0a6c58cc1d4a35fffe51c3adb835350cb7dcd43b5cd/pip_licenses-5.5.1.tar.gz", hash = "sha256:7df370e6e5024a3f7449abf8e4321ef868ba9a795698ad24ab6851f3e7fc65a7", size = 49108 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/a3/0b369cdffef3746157712804f1ded9856c75aa060217ee206f742c74e753/pip_licenses-5.5.1-py3-none-any.whl", hash = "sha256:ed5e229a93760e529cfa7edaec6630b5a2cd3874c1bddb8019e5f18a723fdead", size = 22108 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -442,18 +429,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettytable"
|
||||
version = "3.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycayennelpp"
|
||||
version = "2.4.0"
|
||||
@@ -947,7 +922,6 @@ test = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pip-licenses" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -975,7 +949,6 @@ provides-extras = ["test"]
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pip-licenses", specifier = ">=5.0.0" },
|
||||
{ name = "pyright", specifier = ">=1.1.390" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
@@ -1264,15 +1237,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
|
||||
Reference in New Issue
Block a user