Initial commit

This commit is contained in:
Jack Kingsman
2026-01-06 19:59:51 -08:00
commit 557cb12879
82 changed files with 387739 additions and 0 deletions
+391
View File
@@ -0,0 +1,391 @@
# Backend CLAUDE.md
This document provides context for AI assistants and developers working on the FastAPI backend.
## Technology Stack
- **FastAPI** - Async web framework with automatic OpenAPI docs
- **aiosqlite** - Async SQLite driver
- **meshcore** - MeshCore radio library (local dependency at `../meshcore_py`)
- **Pydantic** - Data validation and settings management
- **PyCryptodome** - AES-128 encryption for packet decryption
- **PyNaCl** - Ed25519/X25519 key exchange for direct message decryption
- **UV** - Python package manager
## Directory Structure
```
app/
├── main.py # FastAPI app, lifespan, router registration, static file serving
├── config.py # Pydantic settings (env vars: MESHCORE_*)
├── database.py # SQLite schema, connection management
├── models.py # Pydantic models for API request/response
├── repository.py # Database CRUD (ContactRepository, ChannelRepository, etc.)
├── radio.py # RadioManager - serial connection to MeshCore device
├── radio_sync.py # Periodic sync, contact auto-loading to radio
├── decoder.py # Packet decryption (channel + direct messages)
├── packet_processor.py # Raw packet processing, advertisement handling
├── keystore.py # Ephemeral key store (private key in memory only)
├── event_handlers.py # Radio event subscriptions, ACK tracking, repeat detection
├── websocket.py # WebSocketManager for real-time client updates
└── routers/ # All routes prefixed with /api
├── health.py # GET /api/health
├── radio.py # Radio config, advertise, private key, reboot
├── contacts.py # Contact CRUD and radio sync
├── channels.py # Channel CRUD and radio sync
├── messages.py # Message list and send (direct/channel)
├── packets.py # Raw packet endpoints, historical decryption
├── settings.py # App settings (max_radio_contacts)
└── ws.py # WebSocket endpoint at /api/ws
```
## Key Architectural Patterns
### Repository Pattern
All database operations go through repository classes in `repository.py`:
```python
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository
# Examples
contact = await ContactRepository.get_by_key_prefix("abc123")
await MessageRepository.create(msg_type="PRIV", text="Hello", received_at=timestamp)
await RawPacketRepository.mark_decrypted(packet_id, message_id)
```
### Radio Connection
`RadioManager` in `radio.py` handles serial connection:
```python
from app.radio import radio_manager
# Access meshcore instance
if radio_manager.meshcore:
await radio_manager.meshcore.commands.send_msg(dst, msg)
```
Auto-detection scans common serial ports when `MESHCORE_SERIAL_PORT` is not set.
### Event-Driven Architecture
Radio events flow through `event_handlers.py`:
| Event | Handler | Actions |
|-------|---------|---------|
| `CONTACT_MSG_RECV` | `on_contact_message` | Store message, update contact last_seen, broadcast via WS |
| `CHANNEL_MSG_RECV` | `on_channel_message` | Store message, broadcast via WS |
| `RAW_DATA` | `on_raw_data` | Store packet, try decrypt with all channel keys, detect repeats |
| `ADVERTISEMENT` | `on_advertisement` | Upsert contact with location |
| `ACK` | `on_ack` | Match pending ACKs, mark message acked, broadcast |
### WebSocket Broadcasting
Real-time updates use `ws_manager` singleton:
```python
from app.websocket import ws_manager
# Broadcast to all connected clients
await ws_manager.broadcast("message", {"id": 1, "text": "Hello"})
```
Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
Helper functions for common broadcasts:
```python
from app.websocket import broadcast_error, broadcast_health
# Notify clients of errors (shows toast in frontend)
broadcast_error("Operation failed", "Additional details")
# Notify clients of connection status change
broadcast_health(radio_connected=True, serial_port="/dev/ttyUSB0")
```
### Connection Monitoring
`RadioManager` includes a background task that monitors connection status:
- Checks connection every 5 seconds
- Broadcasts `health` event on status change
- Attempts automatic reconnection when connection lost
- Supports manual reconnection via `POST /api/radio/reconnect`
```python
from app.radio import radio_manager
# Manual reconnection
success = await radio_manager.reconnect()
# Background monitor (started automatically in app lifespan)
await radio_manager.start_connection_monitor()
await radio_manager.stop_connection_monitor()
```
## Database Schema
```sql
contacts (
public_key TEXT PRIMARY KEY, -- 64-char hex
name TEXT,
type INTEGER DEFAULT 0, -- 0=unknown, 1=client, 2=repeater, 3=room
flags INTEGER DEFAULT 0,
last_path TEXT, -- Routing path hex
last_path_len INTEGER DEFAULT -1,
last_advert INTEGER, -- Unix timestamp of last advertisement
lat REAL, lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0, -- Boolean: contact loaded on radio
last_contacted INTEGER -- Unix timestamp of last message sent/received
)
channels (
key TEXT PRIMARY KEY, -- 32-char hex channel key
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0, -- Key derived from SHA256(name)[:16]
on_radio INTEGER DEFAULT 0
)
messages (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL, -- 'PRIV' or 'CHAN'
conversation_key TEXT NOT NULL, -- User pubkey for PRIV, channel key for CHAN
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
path_len INTEGER,
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
acked INTEGER DEFAULT 0,
UNIQUE(type, conversation_key, text, sender_timestamp) -- Deduplication
)
raw_packets (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL, -- Raw packet bytes
decrypted INTEGER DEFAULT 0,
message_id INTEGER, -- FK to messages if decrypted
decrypt_attempts INTEGER DEFAULT 0,
last_attempt INTEGER,
FOREIGN KEY (message_id) REFERENCES messages(id)
)
```
## Packet Decryption (`decoder.py`)
The decoder handles MeshCore packet decryption for historical packet analysis:
### Packet Types
```python
class PayloadType(IntEnum):
GROUP_TEXT = 0x05 # Channel messages (decryptable)
TEXT_MESSAGE = 0x02 # Direct messages
ACK = 0x03
ADVERT = 0x04
# ... see decoder.py for full list
```
### Channel Key Derivation
Hashtag channels derive keys from name:
```python
channel_key = hashlib.sha256(b"#channelname").digest()[:16]
```
### Decryption Flow
1. Parse packet header to get payload type
2. For `GROUP_TEXT`: extract channel_hash (1 byte), cipher_mac (2 bytes), ciphertext
3. Verify HMAC-SHA256 using 32-byte secret (key + 16 zero bytes)
4. Decrypt with AES-128 ECB
5. Parse decrypted content: timestamp (4 bytes), flags (1 byte), "sender: message" text
```python
from app.decoder import try_decrypt_packet_with_channel_key
result = try_decrypt_packet_with_channel_key(raw_bytes, channel_key)
if result:
print(f"{result.sender}: {result.message}")
```
### Direct Message Decryption
Direct messages use ECDH key exchange (Ed25519 → X25519) with the sender's public key
and recipient's private key:
```python
from app.decoder import try_decrypt_packet_with_contact_key
result = try_decrypt_packet_with_contact_key(
raw_bytes, sender_pub_key, recipient_prv_key
)
if result:
print(f"Message: {result.message}")
```
**Requirements:**
- Sender's Ed25519 public key (32 bytes)
- Recipient's Ed25519 private key (64 bytes) - from ephemeral KeyStore
### Ephemeral Key Store (`keystore.py`)
Private keys are stored **only in memory** for security:
```python
from app.keystore import KeyStore
# Set private key (exported from radio)
KeyStore.set_private_key(private_key_bytes)
# Check if available
if KeyStore.has_private_key():
key = KeyStore.get_private_key()
# Clear from memory
KeyStore.clear_private_key()
```
**Security guarantees:**
- Never written to disk
- Never logged
- Lost on server restart (must re-export from radio)
## ACK and Repeat Detection
### Direct Message ACKs
When sending a direct message, an expected ACK code is tracked:
```python
from app.event_handlers import track_pending_ack
track_pending_ack(expected_ack="abc123", message_id=42, timeout_ms=30000)
```
When ACK event arrives, the message is marked as acked.
### Channel Message Repeats
Flood messages echo back through repeaters. Detection uses:
- Channel key
- Text hash
- Timestamp (±5 second window)
When a repeat is detected, the original outgoing message is marked as "acked".
### Auto-Contact Sync to Radio
To enable the radio to auto-ACK incoming DMs, recent non-repeater contacts are
automatically loaded to the radio. Configured via `max_radio_contacts` setting (default 200).
- Triggered on each advertisement from a non-repeater contact
- Loads most recently contacted non-repeaters (by `last_contacted` timestamp)
- Throttled to at most once per 30 seconds
- `last_contacted` updated on message send/receive
```python
from app.radio_sync import sync_recent_contacts_to_radio
result = await sync_recent_contacts_to_radio(force=True)
# Returns: {"loaded": 5, "already_on_radio": 195, "failed": 0}
```
## API Endpoints
All endpoints are prefixed with `/api`.
### Health
- `GET /api/health` - Connection status, serial port
### Radio
- `GET /api/radio/config` - Read config (public key, name, radio params)
- `PATCH /api/radio/config` - Update name, lat/lon, tx_power, radio params
- `PUT /api/radio/private-key` - Import private key (write-only)
- `POST /api/radio/advertise?flood=true` - Send advertisement
- `POST /api/radio/reboot` - Reboot radio
- `POST /api/radio/reconnect` - Manual reconnection attempt
- `POST /api/radio/enable-server-decryption` - Export private key from radio, enable server-side decryption
- `GET /api/radio/decryption-status` - Check if server-side decryption is enabled
- `POST /api/radio/disable-server-decryption` - Clear private key from memory
### Contacts
- `GET /api/contacts` - List from database
- `GET /api/contacts/{key}` - Get by public key or prefix
- `POST /api/contacts/sync` - Pull from radio to database
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
### Channels
- `GET /api/channels` - List from database
- `GET /api/channels/{key}` - Get by channel key
- `POST /api/channels` - Create (hashtag if name starts with # or no key provided)
- `POST /api/channels/sync` - Pull from radio
- `DELETE /api/channels/{key}` - Delete channel
### Messages
- `GET /api/messages?type=&conversation_key=&limit=&offset=` - List with filters
- `POST /api/messages/direct` - Send direct message
- `POST /api/messages/channel` - Send channel message
### Packets
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
### Settings
- `GET /api/settings` - Get app settings (max_radio_contacts)
- `PATCH /api/settings` - Update app settings
### WebSocket
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
### Static Files (Production)
In production, the backend also serves the frontend:
- `/` - Serves `frontend/dist/index.html`
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
- `/*` - Falls back to `index.html` for SPA routing
## Testing
Run tests with:
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange, real-world test vectors
- `tests/test_keystore.py` - Ephemeral key store operations
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_api.py` - API endpoint tests
## Common Tasks
### Adding a New Endpoint
1. Create or update router in `app/routers/`
2. Define Pydantic models in `app/models.py` if needed
3. Add repository methods in `app/repository.py` for database operations
4. Register router in `app/main.py` if new file
5. Add tests in `tests/`
### Adding a New Event Handler
1. Define handler in `app/event_handlers.py`
2. Register in `register_event_handlers()` function
3. Broadcast updates via `ws_manager` as needed
### Working with Radio Commands
```python
# Available via radio_manager.meshcore.commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(chan, msg)
await mc.commands.get_contacts()
await mc.commands.add_contact(contact_dict)
await mc.commands.set_channel(idx, name, key)
await mc.commands.send_advert(flood=True)
```
View File
+27
View File
@@ -0,0 +1,27 @@
import logging
from typing import Literal
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
max_radio_contacts: int = 200 # Max non-repeater contacts to keep on radio for DM ACKs
class Config:
env_prefix = "MESHCORE_"
settings = Settings()
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
level=settings.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
+93
View File
@@ -0,0 +1,93 @@
import logging
from pathlib import Path
import aiosqlite
from app.config import settings
logger = logging.getLogger(__name__)
SCHEMA = """
CREATE TABLE IF NOT EXISTS contacts (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER
);
CREATE TABLE IF NOT EXISTS channels (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
conversation_key TEXT NOT NULL,
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
path_len INTEGER,
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
acked INTEGER DEFAULT 0,
UNIQUE(type, conversation_key, text, sender_timestamp)
);
CREATE TABLE IF NOT EXISTS raw_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL,
decrypted INTEGER DEFAULT 0,
message_id INTEGER,
decrypt_attempts INTEGER DEFAULT 0,
last_attempt INTEGER,
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE INDEX IF NOT EXISTS idx_raw_packets_decrypted ON raw_packets(decrypted);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
"""
class Database:
def __init__(self, db_path: str):
self.db_path = db_path
self._connection: aiosqlite.Connection | None = None
async def connect(self) -> None:
logger.info("Connecting to database at %s", self.db_path)
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._connection = await aiosqlite.connect(self.db_path)
self._connection.row_factory = aiosqlite.Row
await self._connection.executescript(SCHEMA)
await self._connection.commit()
logger.debug("Database schema initialized")
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
self._connection = None
logger.debug("Database connection closed")
@property
def conn(self) -> aiosqlite.Connection:
if not self._connection:
raise RuntimeError("Database not connected")
return self._connection
db = Database(settings.database_path)
+364
View File
@@ -0,0 +1,364 @@
"""
MeshCore packet decoder for historical packet decryption.
Based on https://github.com/michaelhart/meshcore-decoder
"""
import hmac
import hashlib
import logging
from dataclasses import dataclass
from enum import IntEnum
from Crypto.Cipher import AES
logger = logging.getLogger(__name__)
class PayloadType(IntEnum):
REQUEST = 0x00
RESPONSE = 0x01
TEXT_MESSAGE = 0x02
ACK = 0x03
ADVERT = 0x04
GROUP_TEXT = 0x05
GROUP_DATA = 0x06
ANON_REQUEST = 0x07
PATH = 0x08
TRACE = 0x09
MULTIPART = 0x0A
CONTROL = 0x0B
RAW_CUSTOM = 0x0F
class RouteType(IntEnum):
TRANSPORT_FLOOD = 0x00
FLOOD = 0x01
DIRECT = 0x02
TRANSPORT_DIRECT = 0x03
@dataclass
class DecryptedGroupText:
"""Result of decrypting a GroupText (channel) message."""
timestamp: int
flags: int
sender: str | None
message: str
channel_hash: str
@dataclass
class ParsedAdvertisement:
"""Result of parsing an advertisement packet."""
public_key: str # 64-char hex
name: str | None
lat: float | None
lon: float | None
@dataclass
class PacketInfo:
"""Basic packet header info."""
route_type: RouteType
payload_type: PayloadType
payload_version: int
path_length: int
payload: bytes
def calculate_channel_hash(channel_key: bytes) -> str:
"""
Calculate the channel hash from a 16-byte channel key.
Returns the first byte of SHA256(key) as hex.
"""
hash_bytes = hashlib.sha256(channel_key).digest()
return format(hash_bytes[0], "02x")
def extract_payload(raw_packet: bytes) -> bytes | None:
"""
Extract just the payload from a raw packet, skipping header and path.
Packet structure:
- Byte 0: header (route_type, payload_type, version)
- For TRANSPORT routes: bytes 1-4 are transport codes
- Next byte: path_length
- Next path_length bytes: path data
- Remaining: payload
Returns the payload bytes, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload
return raw_packet[offset:]
except (ValueError, IndexError):
return None
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
"""Parse a raw packet and extract basic info."""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = RouteType(header & 0x03)
payload_type = PayloadType((header >> 2) & 0x0F)
payload_version = (header >> 6) & 0x03
offset = 1
# Skip transport codes if present
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload
payload = raw_packet[offset:]
return PacketInfo(
route_type=route_type,
payload_type=payload_type,
payload_version=payload_version,
path_length=path_length,
payload=payload,
)
except (ValueError, IndexError):
return None
def decrypt_group_text(
payload: bytes, channel_key: bytes
) -> DecryptedGroupText | None:
"""
Decrypt a GroupText payload using the channel key.
GroupText structure:
- channel_hash (1 byte): First byte of SHA256 of channel key
- cipher_mac (2 bytes): First 2 bytes of HMAC-SHA256
- ciphertext (rest): AES-128 ECB encrypted content
Decrypted content structure:
- timestamp (4 bytes, little-endian)
- flags (1 byte)
- message text (null-terminated string, format: "sender: message")
"""
if len(payload) < 3:
return None
channel_hash = format(payload[0], "02x")
cipher_mac = payload[1:3]
ciphertext = payload[3:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
# AES requires 16-byte blocks
return None
# Create the 32-byte channel secret (key + 16 zero bytes)
channel_secret = channel_key + bytes(16)
# Verify MAC: HMAC-SHA256 of ciphertext using full 32-byte secret
calculated_mac = hmac.new(channel_secret, ciphertext, hashlib.sha256).digest()
if calculated_mac[:2] != cipher_mac:
return None
# Decrypt using AES-128 ECB with the 16-byte key
try:
cipher = AES.new(channel_key, AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed: %s", e)
return None
if len(decrypted) < 5:
return None
# Parse decrypted content
timestamp = int.from_bytes(decrypted[0:4], "little")
flags = decrypted[4]
# Extract message text (UTF-8, null-terminated)
message_bytes = decrypted[5:]
try:
message_text = message_bytes.decode("utf-8")
# Remove null terminator and any padding
null_idx = message_text.find("\x00")
if null_idx >= 0:
message_text = message_text[:null_idx]
except UnicodeDecodeError:
return None
# Parse "sender: message" format
sender = None
content = message_text
colon_idx = message_text.find(": ")
if 0 < colon_idx < 50:
potential_sender = message_text[:colon_idx]
# Check for invalid characters in sender name
if not any(c in potential_sender for c in ":[]\x00"):
sender = potential_sender
content = message_text[colon_idx + 2 :]
return DecryptedGroupText(
timestamp=timestamp,
flags=flags,
sender=sender,
message=content,
channel_hash=channel_hash,
)
def try_decrypt_packet_with_channel_key(
raw_packet: bytes, channel_key: bytes
) -> DecryptedGroupText | None:
"""
Try to decrypt a raw packet using a channel key.
Returns decrypted content if successful, None otherwise.
"""
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
# Only GroupText packets can be decrypted with channel keys
if packet_info.payload_type != PayloadType.GROUP_TEXT:
return None
# Check if channel hash matches
if len(packet_info.payload) < 1:
return None
packet_channel_hash = format(packet_info.payload[0], "02x")
expected_hash = calculate_channel_hash(channel_key)
if packet_channel_hash != expected_hash:
return None
return decrypt_group_text(packet_info.payload, channel_key)
def get_packet_payload_type(raw_packet: bytes) -> PayloadType | None:
"""Get the payload type of a raw packet without full parsing."""
if len(raw_packet) < 1:
return None
header = raw_packet[0]
try:
return PayloadType((header >> 2) & 0x0F)
except ValueError:
return None
def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
"""
Parse an advertisement payload.
Advertisement structure:
- public_key (32 bytes): Ed25519 public key
- signature (64 bytes): Ed25519 signature
- advert_data (variable): Contains name and possibly lat/lon
The name is typically at the end of the payload as a UTF-8 string.
"""
# Minimum: 32 (pubkey) + 64 (sig) + at least 1 byte for flags/data
if len(payload) < 97:
return None
public_key = payload[:32].hex()
# signature = payload[32:96] # Not currently verified
advert_data = payload[96:]
if len(advert_data) == 0:
return ParsedAdvertisement(
public_key=public_key,
name=None,
lat=None,
lon=None,
)
# Try to extract name from the advert data
# The structure varies, but the name is typically near the end
name = None
lat = None
lon = None
# Try to decode the entire advert_data as UTF-8 to find the name
# Names are typically at the end after any binary data
try:
# Find the last valid UTF-8 string
for start in range(len(advert_data)):
try:
text = advert_data[start:].decode("utf-8")
# Filter out control characters and check if it looks like a name
null_idx = text.find("\x00")
if null_idx >= 0:
text = text[:null_idx]
text = text.strip()
if text and len(text) >= 1 and len(text) <= 40:
# Check if it contains printable characters
if any(c.isalnum() for c in text):
name = text
break
except UnicodeDecodeError:
continue
except Exception:
pass
return ParsedAdvertisement(
public_key=public_key,
name=name,
lat=lat,
lon=lon,
)
def try_parse_advertisement(raw_packet: bytes) -> ParsedAdvertisement | None:
"""
Try to parse a raw packet as an advertisement.
Returns parsed advertisement if successful, None otherwise.
"""
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
if packet_info.payload_type != PayloadType.ADVERT:
return None
return parse_advertisement(packet_info.payload)
+15
View File
@@ -0,0 +1,15 @@
"""Shared dependencies for FastAPI routers."""
from fastapi import HTTPException
from app.radio import radio_manager
def require_connected():
"""Dependency that ensures radio is connected and returns meshcore instance.
Raises HTTPException 503 if radio is not connected.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
raise HTTPException(status_code=503, detail="Radio not connected")
return radio_manager.meshcore
+193
View File
@@ -0,0 +1,193 @@
import logging
import time
from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import Contact
from app.packet_processor import process_raw_packet, track_pending_repeat
from app.repository import ContactRepository, MessageRepository
from app.websocket import broadcast_event
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
_pending_acks: dict[str, tuple[int, float, int]] = {}
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
"""Track a pending ACK for a direct message."""
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
logger.debug("Tracking pending ACK %s for message %d (timeout %dms)", expected_ack, message_id, timeout_ms)
def _cleanup_expired_acks() -> None:
"""Remove expired pending ACKs."""
now = time.time()
expired = []
for code, (msg_id, created_at, timeout_ms) in _pending_acks.items():
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
expired.append(code)
for code in expired:
del _pending_acks[code]
logger.debug("Expired pending ACK %s", code)
async def on_contact_message(event: "Event") -> None:
"""Handle incoming direct messages.
Direct messages are decrypted by MeshCore library using ECDH key exchange.
The packet processor cannot decrypt these without the node's private key.
"""
payload = event.payload
logger.debug("Received direct message from %s", payload.get("pubkey_prefix"))
# Get full public key if available, otherwise use prefix
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
received_at = int(time.time())
# Look up full public key from contact database if we only have prefix
if len(sender_pubkey) < 64:
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
if contact:
sender_pubkey = contact.public_key
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=payload.get("text", ""),
conversation_key=sender_pubkey,
sender_timestamp=payload.get("sender_timestamp"),
received_at=received_at,
path_len=payload.get("path_len"),
txt_type=payload.get("txt_type", 0),
signature=payload.get("signature"),
)
if msg_id is None:
# Duplicate message (same content from same sender) - skip broadcast
logger.debug("Duplicate direct message from %s ignored", sender_pubkey[:12])
return
# Broadcast only genuinely new messages
broadcast_event("message", {
"id": msg_id,
"type": "PRIV",
"conversation_key": sender_pubkey,
"text": payload.get("text", ""),
"sender_timestamp": payload.get("sender_timestamp"),
"received_at": received_at,
"path_len": payload.get("path_len"),
"txt_type": payload.get("txt_type", 0),
"signature": payload.get("signature"),
"outgoing": False,
"acked": False,
})
# Update contact last_seen and last_contacted
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
if contact:
await ContactRepository.update_last_contacted(contact.public_key, received_at)
async def on_rx_log_data(event: "Event") -> None:
"""Store raw RF packet data and process via centralized packet processor.
This is the unified entry point for all RF packets. The packet processor
handles channel messages (GROUP_TEXT) and advertisements (ADVERT).
"""
payload = event.payload
logger.debug("Received RX log data packet")
if "payload" not in payload:
logger.warning("RX_LOG_DATA event missing 'payload' field")
return
raw_hex = payload["payload"]
raw_bytes = bytes.fromhex(raw_hex)
await process_raw_packet(
raw_bytes=raw_bytes,
snr=payload.get("snr"),
rssi=payload.get("rssi"),
)
async def on_path_update(event: "Event") -> None:
"""Handle path update events."""
payload = event.payload
logger.debug("Path update for %s", payload.get("pubkey_prefix"))
pubkey_prefix = payload.get("pubkey_prefix", "")
path = payload.get("path", "")
path_len = payload.get("path_len", -1)
existing = await ContactRepository.get_by_key_prefix(pubkey_prefix)
if existing:
await ContactRepository.update_path(existing.public_key, path, path_len)
async def on_new_contact(event: "Event") -> None:
"""Handle new contact from radio's internal contact database.
This is different from RF advertisements - these are contacts synced
from the radio's stored contact list.
"""
payload = event.payload
public_key = payload.get("public_key", "")
if not public_key:
logger.warning("Received new contact event with no public_key, skipping")
return
logger.debug("New contact: %s", public_key[:12])
contact_data = {
**Contact.from_radio_dict(public_key, payload, on_radio=True),
"last_seen": int(time.time()),
}
await ContactRepository.upsert(contact_data)
broadcast_event("contact", contact_data)
async def on_ack(event: "Event") -> None:
"""Handle ACK events for direct messages."""
payload = event.payload
ack_code = payload.get("code", "")
if not ack_code:
logger.debug("Received ACK with no code")
return
logger.debug("Received ACK with code %s", ack_code)
_cleanup_expired_acks()
if ack_code in _pending_acks:
message_id, _, _ = _pending_acks.pop(ack_code)
logger.info("ACK received for message %d", message_id)
await MessageRepository.mark_acked(message_id)
broadcast_event("message_acked", {"message_id": message_id})
else:
logger.debug("ACK code %s does not match any pending messages", ack_code)
def register_event_handlers(meshcore) -> None:
"""Register event handlers with the MeshCore instance.
Note: CHANNEL_MSG_RECV and ADVERTISEMENT events are NOT subscribed.
These are handled by the packet processor via RX_LOG_DATA to avoid
duplicate processing and ensure consistent handling.
"""
meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_contact_message)
meshcore.subscribe(EventType.RX_LOG_DATA, on_rx_log_data)
meshcore.subscribe(EventType.PATH_UPDATE, on_path_update)
meshcore.subscribe(EventType.NEW_CONTACT, on_new_contact)
meshcore.subscribe(EventType.ACK, on_ack)
logger.info("Event handlers registered")
+114
View File
@@ -0,0 +1,114 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import setup_logging
from app.database import db
from app.event_handlers import register_event_handlers
from app.radio import radio_manager
from app.radio_sync import (
start_periodic_sync,
stop_periodic_sync,
sync_and_offload_all,
)
from app.routers import channels, contacts, health, messages, packets, radio, settings, ws
setup_logging()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage database and radio connection lifecycle."""
await db.connect()
logger.info("Database connected")
try:
await radio_manager.connect()
logger.info("Connected to radio")
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all()
logger.info("Sync complete: %s", result)
# Start periodic sync
start_periodic_sync()
# Send advertisement to announce our presence
logger.info("Sending startup advertisement...")
advert_result = await radio_manager.meshcore.commands.send_advert(flood=True)
logger.info("Advertisement sent: %s", advert_result.type)
await radio_manager.meshcore.start_auto_message_fetching()
logger.info("Auto message fetching started")
except Exception as e:
logger.warning("Failed to connect to radio on startup: %s", e)
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
yield
logger.info("Shutting down")
await radio_manager.stop_connection_monitor()
stop_periodic_sync()
if radio_manager.meshcore:
await radio_manager.meshcore.stop_auto_message_fetching()
await radio_manager.disconnect()
await db.disconnect()
app = FastAPI(
title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(channels.router, prefix="/api")
app.include_router(messages.router, prefix="/api")
app.include_router(packets.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(ws.router, prefix="/api")
# Serve frontend static files in production
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if FRONTEND_DIR.exists():
# Serve static assets (JS, CSS, etc.)
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
# Serve other static files from frontend/dist (like wordlist)
@app.get("/{path:path}")
async def serve_frontend(path: str):
"""Serve frontend files, falling back to index.html for SPA routing."""
file_path = FRONTEND_DIR / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Fall back to index.html for SPA routing
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(FRONTEND_DIR / "index.html")
+124
View File
@@ -0,0 +1,124 @@
from pydantic import BaseModel, Field
class Contact(BaseModel):
public_key: str = Field(description="Public key (64-char hex)")
name: str | None = None
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
last_seen: int | None = None
on_radio: bool = False
last_contacted: int | None = None # Last time we sent/received a message
def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
"""
return {
"public_key": self.public_key,
"adv_name": self.name or "",
"type": self.type,
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
"adv_lat": self.lat or 0.0,
"adv_lon": self.lon or 0.0,
"last_advert": self.last_advert or 0,
}
@staticmethod
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
"""Convert radio contact data to database format dict.
This is the inverse of to_radio_dict(), used when syncing contacts
from radio to database.
"""
return {
"public_key": public_key,
"name": radio_data.get("adv_name"),
"type": radio_data.get("type", 0),
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
"on_radio": on_radio,
}
# Contact type constants
CONTACT_TYPE_REPEATER = 2
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str
is_hashtag: bool = False
on_radio: bool = False
class Message(BaseModel):
id: int
type: str = Field(description="PRIV or CHAN")
conversation_key: str = Field(description="User pubkey for PRIV, channel key for CHAN")
text: str
sender_timestamp: int | None = None
received_at: int
path_len: int | None = None
txt_type: int = 0
signature: str | None = None
outgoing: bool = False
acked: bool = False
class RawPacket(BaseModel):
"""Raw packet as stored in the database."""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
decrypted: bool = False
message_id: int | None = None
decrypt_attempts: int = 0
last_attempt: int | None = None
class RawPacketDecryptedInfo(BaseModel):
"""Decryption info for a raw packet (when successfully decrypted)."""
channel_name: str | None = None
sender: str | None = None
class RawPacketBroadcast(BaseModel):
"""Raw packet payload broadcast via WebSocket.
This extends the database model with runtime-computed fields
like payload_type, snr, rssi, and decryption info.
"""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
payload_type: str = Field(description="Packet type name (e.g., GROUP_TEXT, ADVERT)")
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB")
rssi: int | None = Field(default=None, description="Received signal strength in dBm")
decrypted: bool = False
decrypted_info: RawPacketDecryptedInfo | None = None
class SendMessageRequest(BaseModel):
text: str = Field(min_length=1)
class SendDirectMessageRequest(SendMessageRequest):
destination: str = Field(description="Public key or prefix of recipient")
class SendChannelMessageRequest(SendMessageRequest):
channel_key: str = Field(description="Channel key (32-char hex)")
+351
View File
@@ -0,0 +1,351 @@
"""
Centralized packet processing for MeshCore messages.
This module handles:
- Storing raw packets
- Decrypting channel messages (GroupText) with stored channel keys
- Decrypting direct messages with stored contact keys (if private key available)
- Creating message entries for successfully decrypted packets
- Broadcasting updates via WebSocket
This is the primary path for message processing when channel/contact keys
are offloaded from the radio to the server.
"""
import asyncio
import logging
import time
from app.decoder import (
PayloadType,
parse_packet,
try_decrypt_packet_with_channel_key,
try_parse_advertisement,
)
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
from app.repository import ChannelRepository, ContactRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
# Pending repeats for outgoing message ACK detection
# Key: (channel_key, text_hash, timestamp) -> message_id
_pending_repeats: dict[tuple[str, str, int], int] = {}
_pending_repeat_expiry: dict[tuple[str, str, int], float] = {}
REPEAT_EXPIRY_SECONDS = 30
async def create_message_from_decrypted(
packet_id: int,
channel_key: str,
sender: str | None,
message_text: str,
timestamp: int,
received_at: int | None = None,
) -> int | None:
"""Create a message record from decrypted channel packet content.
This is the shared logic for storing decrypted channel messages,
used by both real-time packet processing and historical decryption.
Returns the message ID if created, None if duplicate.
"""
import time as time_module
received = received_at or int(time_module.time())
# Format the message text
text = f"{sender}: {message_text}" if sender else message_text
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel_key.upper(),
sender_timestamp=timestamp,
received_at=received,
)
if msg_id is None:
# Duplicate detected - find existing message ID for packet linkage
existing_id = await MessageRepository.find_duplicate(
conversation_key=channel_key.upper(),
text=text,
sender_timestamp=timestamp,
)
if existing_id:
await RawPacketRepository.mark_decrypted(packet_id, existing_id)
return None
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
return msg_id
def track_pending_repeat(channel_key: str, text: str, timestamp: int, message_id: int) -> None:
"""Track an outgoing channel message for repeat detection."""
text_hash = str(hash(text))
key = (channel_key.upper(), text_hash, timestamp)
_pending_repeats[key] = message_id
_pending_repeat_expiry[key] = time.time() + REPEAT_EXPIRY_SECONDS
logger.debug("Tracking repeat for channel %s, message %d", channel_key[:8], message_id)
def _cleanup_expired_repeats() -> None:
"""Remove expired pending repeats."""
now = time.time()
expired = [k for k, exp in _pending_repeat_expiry.items() if exp < now]
for k in expired:
_pending_repeats.pop(k, None)
_pending_repeat_expiry.pop(k, None)
async def process_raw_packet(
raw_bytes: bytes,
timestamp: int | None = None,
snr: float | None = None,
rssi: int | None = None,
) -> dict:
"""
Process an incoming raw packet.
This is the main entry point for all incoming RF packets.
"""
ts = timestamp or int(time.time())
packet_id = await RawPacketRepository.create(raw_bytes, ts)
raw_hex = raw_bytes.hex()
# Parse packet to get type
packet_info = parse_packet(raw_bytes)
payload_type = packet_info.payload_type if packet_info else None
payload_type_name = payload_type.name if payload_type else "Unknown"
result = {
"packet_id": packet_id,
"timestamp": ts,
"raw_hex": raw_hex,
"payload_type": payload_type_name,
"snr": snr,
"rssi": rssi,
"decrypted": False,
"message_id": None,
"channel_name": None,
"sender": None,
}
# Try to decrypt/parse based on payload type
if payload_type == PayloadType.GROUP_TEXT:
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
if decrypt_result:
result.update(decrypt_result)
elif payload_type == PayloadType.ADVERT:
await _process_advertisement(raw_bytes, ts)
# TODO: Add TEXT_MESSAGE (direct message) decryption when private key is available
# elif payload_type == PayloadType.TEXT_MESSAGE:
# decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
# if decrypt_result:
# result.update(decrypt_result)
# Broadcast raw packet for the packet feed UI
broadcast_payload = RawPacketBroadcast(
id=packet_id,
timestamp=ts,
data=raw_hex,
payload_type=payload_type_name,
snr=snr,
rssi=rssi,
decrypted=result["decrypted"],
decrypted_info=RawPacketDecryptedInfo(
channel_name=result["channel_name"],
sender=result["sender"],
) if result["decrypted"] else None,
)
broadcast_event("raw_packet", broadcast_payload.model_dump())
return result
async def _process_group_text(
raw_bytes: bytes,
packet_id: int,
timestamp: int,
packet_info,
) -> dict | None:
"""
Process a GroupText (channel message) packet.
Tries all known channel keys to decrypt.
Creates a message entry if successful.
Handles repeat detection for outgoing message ACKs.
"""
# Try to decrypt with all known channel keys
channels = await ChannelRepository.get_all()
for channel in channels:
# Convert hex key to bytes for decryption
try:
channel_key_bytes = bytes.fromhex(channel.key)
except ValueError:
continue
decrypted = try_decrypt_packet_with_channel_key(raw_bytes, channel_key_bytes)
if not decrypted:
continue
# Successfully decrypted!
logger.debug(
"Decrypted GroupText for channel %s: %s",
channel.name, decrypted.message[:50]
)
# Check for repeat detection (our own message echoed back)
is_repeat = False
_cleanup_expired_repeats()
text_hash = str(hash(decrypted.message))
for ts_offset in range(-5, 6):
key = (channel.key, text_hash, decrypted.timestamp + ts_offset)
if key in _pending_repeats:
message_id = _pending_repeats[key]
# Don't pop - let it expire naturally so subsequent repeats via
# different radio paths are also caught as duplicates
logger.info("Repeat detected for channel message %d", message_id)
await MessageRepository.mark_acked(message_id)
broadcast_event("message_acked", {"message_id": message_id})
is_repeat = True
break
if is_repeat:
# Mark packet as decrypted but don't create new message
await RawPacketRepository.mark_decrypted(packet_id, message_id)
return {
"decrypted": True,
"channel_name": channel.name,
"sender": decrypted.sender,
"message_id": message_id,
}
# Format the message text
if decrypted.sender:
text = f"{decrypted.sender}: {decrypted.message}"
else:
text = decrypted.message
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel.key,
sender_timestamp=decrypted.timestamp,
received_at=timestamp,
)
if msg_id is None:
# Duplicate detected by database constraint (same message via different RF path)
# Find existing message ID for packet linkage
existing_id = await MessageRepository.find_duplicate(
conversation_key=channel.key,
text=text,
sender_timestamp=decrypted.timestamp,
)
logger.debug(
"Duplicate message detected for channel %s (existing id=%s)",
channel.name, existing_id
)
if existing_id:
await RawPacketRepository.mark_decrypted(packet_id, existing_id)
return {
"decrypted": True,
"channel_name": channel.name,
"sender": decrypted.sender,
"message_id": existing_id,
}
logger.info("Stored channel message %d for %s", msg_id, channel.name)
# Broadcast new message (only for genuinely new messages)
broadcast_event("message", {
"id": msg_id,
"type": "CHAN",
"conversation_key": channel.key,
"text": text,
"sender_timestamp": decrypted.timestamp,
"received_at": timestamp,
"path_len": packet_info.path_length if packet_info else None,
"txt_type": 0,
"signature": None,
"outgoing": False,
"acked": False,
})
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
return {
"decrypted": True,
"channel_name": channel.name,
"sender": decrypted.sender,
"message_id": msg_id,
}
# Couldn't decrypt with any known key
return None
async def _process_advertisement(
raw_bytes: bytes,
timestamp: int,
) -> None:
"""
Process an advertisement packet.
Extracts contact info and updates the database/broadcasts to clients.
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
"""
advert = try_parse_advertisement(raw_bytes)
if not advert:
logger.debug("Failed to parse advertisement packet")
return
logger.debug("Parsed advertisement from %s: %s", advert.public_key[:12], advert.name)
# Try to find existing contact
existing = await ContactRepository.get_by_key(advert.public_key)
contact_data = {
"public_key": advert.public_key,
"name": advert.name,
"lat": advert.lat,
"lon": advert.lon,
"last_advert": timestamp,
"last_seen": timestamp,
}
await ContactRepository.upsert(contact_data)
# Broadcast contact update to connected clients
contact_type = existing.type if existing else 0
broadcast_event("contact", {
"public_key": advert.public_key,
"name": advert.name,
"type": contact_type,
"flags": existing.flags if existing else 0,
"last_path": existing.last_path if existing else None,
"last_path_len": existing.last_path_len if existing else -1,
"last_advert": timestamp,
"lat": advert.lat,
"lon": advert.lon,
"last_seen": timestamp,
"on_radio": existing.on_radio if existing else False,
})
# If this is not a repeater, trigger recent contacts sync to radio
# This ensures we can auto-ACK DMs from recent contacts
if contact_type != CONTACT_TYPE_REPEATER:
# Import here to avoid circular import
from app.radio_sync import sync_recent_contacts_to_radio
asyncio.create_task(sync_recent_contacts_to_radio())
+249
View File
@@ -0,0 +1,249 @@
import asyncio
import glob
import logging
import platform
from pathlib import Path
from meshcore import MeshCore
from app.config import settings
logger = logging.getLogger(__name__)
def detect_serial_devices() -> list[str]:
"""Detect available serial devices based on platform."""
devices: list[str] = []
system = platform.system()
if system == "Darwin":
# macOS: Use /dev/cu.* devices (callout devices, preferred over tty.*)
patterns = [
"/dev/cu.usb*",
"/dev/cu.wchusbserial*",
"/dev/cu.SLAB_USBtoUART*",
]
for pattern in patterns:
devices.extend(glob.glob(pattern))
devices.sort()
else:
# Linux: Prefer /dev/serial/by-id/ for persistent naming
by_id_path = Path("/dev/serial/by-id")
if by_id_path.is_dir():
devices.extend(str(p) for p in by_id_path.iterdir())
# Also check /dev/ttyACM* and /dev/ttyUSB* as fallback
resolved_paths = set()
for dev in devices:
try:
resolved_paths.add(str(Path(dev).resolve()))
except OSError:
pass
for pattern in ["/dev/ttyACM*", "/dev/ttyUSB*"]:
for dev in glob.glob(pattern):
try:
if str(Path(dev).resolve()) not in resolved_paths:
devices.append(dev)
except OSError:
devices.append(dev)
devices.sort()
return devices
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
"""Test if a MeshCore radio responds on the given serial port."""
try:
logger.debug("Testing serial device %s", port)
mc = await asyncio.wait_for(
MeshCore.create_serial(port=port, baudrate=baudrate),
timeout=timeout,
)
# Check if we got valid self_info (indicates successful communication)
if mc.is_connected and mc.self_info:
logger.debug("Device %s responded with valid self_info", port)
await mc.disconnect()
return True
await mc.disconnect()
return False
except asyncio.TimeoutError:
logger.debug("Device %s timed out", port)
return False
except Exception as e:
logger.debug("Device %s failed: %s", port, e)
return False
async def find_radio_port(baudrate: int) -> str | None:
"""Find the first serial port with a responding MeshCore radio."""
devices = detect_serial_devices()
if not devices:
logger.warning("No serial devices found")
return None
logger.info("Found %d serial device(s), testing for MeshCore radio...", len(devices))
for device in devices:
if await test_serial_device(device, baudrate):
logger.info("Found MeshCore radio at %s", device)
return device
logger.warning("No MeshCore radio found on any serial device")
return None
class RadioManager:
"""Manages the MeshCore radio connection."""
def __init__(self):
self._meshcore: MeshCore | None = None
self._port: str | None = None
self._reconnect_task: asyncio.Task | None = None
self._last_connected: bool = False
self._reconnecting: bool = False
@property
def meshcore(self) -> MeshCore | None:
return self._meshcore
@property
def port(self) -> str | None:
return self._port
@property
def is_connected(self) -> bool:
return self._meshcore is not None and self._meshcore.is_connected
@property
def is_reconnecting(self) -> bool:
return self._reconnecting
async def connect(self) -> None:
"""Connect to the radio over serial."""
if self._meshcore is not None:
await self.disconnect()
port = settings.serial_port
# Auto-detect if no port specified
if not port:
logger.info("No serial port specified, auto-detecting...")
port = await find_radio_port(settings.serial_baudrate)
if not port:
raise RuntimeError("No MeshCore radio found. Please specify MESHCORE_SERIAL_PORT.")
logger.debug(
"Connecting to radio at %s (baud %d)",
port,
settings.serial_baudrate,
)
self._meshcore = await MeshCore.create_serial(
port=port,
baudrate=settings.serial_baudrate,
auto_reconnect=True,
max_reconnect_attempts=10,
)
self._port = port
self._last_connected = True
logger.debug("Serial connection established")
async def disconnect(self) -> None:
"""Disconnect from the radio."""
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
await self._meshcore.disconnect()
self._meshcore = None
logger.debug("Radio disconnected")
async def reconnect(self) -> bool:
"""Attempt to reconnect to the radio.
Returns True if reconnection was successful, False otherwise.
"""
from app.websocket import broadcast_error, broadcast_health
if self._reconnecting:
logger.debug("Reconnection already in progress")
return False
self._reconnecting = True
logger.info("Attempting to reconnect to radio...")
try:
# Disconnect if we have a stale connection
if self._meshcore is not None:
try:
await self._meshcore.disconnect()
except Exception:
pass
self._meshcore = None
# Try to connect (will auto-detect if no port specified)
await self.connect()
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._port)
broadcast_health(True, self._port)
return True
else:
logger.warning("Reconnection failed: not connected after connect()")
return False
except Exception as e:
logger.warning("Reconnection failed: %s", e)
broadcast_error("Reconnection failed", str(e))
return False
finally:
self._reconnecting = False
async def start_connection_monitor(self) -> None:
"""Start background task to monitor connection and auto-reconnect."""
if self._reconnect_task is not None:
return
async def monitor_loop():
from app.websocket import broadcast_health
while True:
await asyncio.sleep(5) # Check every 5 seconds
current_connected = self.is_connected
# Detect status change
if self._last_connected and not current_connected:
# Connection lost
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._port)
self._last_connected = False
# Attempt reconnection
await asyncio.sleep(3) # Wait a bit before trying
await self.reconnect()
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically)
logger.info("Radio connection restored")
broadcast_health(True, self._port)
self._last_connected = True
self._reconnect_task = asyncio.create_task(monitor_loop())
logger.info("Radio connection monitor started")
async def stop_connection_monitor(self) -> None:
"""Stop the connection monitor task."""
if self._reconnect_task is not None:
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
self._reconnect_task = None
logger.info("Radio connection monitor stopped")
radio_manager = RadioManager()
+299
View File
@@ -0,0 +1,299 @@
"""
Radio sync and offload management.
This module handles syncing contacts and channels from the radio to the database,
then removing them from the radio to free up space for new discoveries.
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
"""
import asyncio
import logging
import time
from meshcore import EventType
from app.config import settings
from app.models import CONTACT_TYPE_REPEATER, Contact
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository
logger = logging.getLogger(__name__)
# Background task handle
_sync_task: asyncio.Task | None = None
# Sync interval in seconds (5 minutes)
SYNC_INTERVAL = 300
async def sync_and_offload_contacts() -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.warning("Cannot sync contacts: radio not connected")
return {"synced": 0, "removed": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
synced = 0
removed = 0
try:
# Get all contacts from radio
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error("Failed to get contacts from radio: %s", result)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
logger.info("Found %d contacts on radio", len(contacts))
# Sync each contact to database, then remove from radio
for public_key, contact_data in contacts.items():
# Save to database
await ContactRepository.upsert(
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
)
synced += 1
# Remove from radio
try:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
else:
logger.warning(
"Failed to remove contact %s: %s",
public_key[:12], remove_result.payload
)
except Exception as e:
logger.warning("Error removing contact %s: %s", public_key[:12], e)
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
except Exception as e:
logger.error("Error during contact sync: %s", e)
return {"synced": synced, "removed": removed, "error": str(e)}
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels() -> dict:
"""
Sync channels from radio to database, then clear them from radio.
Returns counts of synced and cleared channels.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.warning("Cannot sync channels: radio not connected")
return {"synced": 0, "cleared": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
synced = 0
cleared = 0
try:
# Check all 40 channel slots
for idx in range(40):
result = await mc.commands.get_channel(idx)
if result.type != EventType.CHANNEL_INFO:
continue
payload = result.payload
name = payload.get("channel_name", "")
secret = payload.get("channel_secret", b"")
# Skip empty channels
if not name or name == "\x00" * len(name) or all(b == 0 for b in secret):
continue
is_hashtag = name.startswith("#")
# Convert key bytes to hex string
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
key_hex = key_bytes.hex().upper()
# Save to database
await ChannelRepository.upsert(
key=key_hex,
name=name,
is_hashtag=is_hashtag,
on_radio=False, # We're about to clear it
)
synced += 1
logger.debug("Synced channel %s: %s", key_hex[:8], name)
# Clear from radio (set empty name and zero key)
try:
clear_result = await mc.commands.set_channel(
channel_idx=idx,
channel_name="",
channel_secret=bytes(16),
)
if clear_result.type == EventType.OK:
cleared += 1
else:
logger.warning(
"Failed to clear channel %d: %s",
idx, clear_result.payload
)
except Exception as e:
logger.warning("Error clearing channel %d: %s", idx, e)
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
except Exception as e:
logger.error("Error during channel sync: %s", e)
return {"synced": synced, "cleared": cleared, "error": str(e)}
return {"synced": synced, "cleared": cleared}
async def ensure_default_channels() -> None:
"""
Ensure default channels exist in the database.
These will be configured on the radio when needed for sending.
"""
# Public channel - no hashtag, specific key
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
existing = await ChannelRepository.get_by_name("Public")
if not existing:
logger.info("Creating default Public channel")
await ChannelRepository.upsert(
key=PUBLIC_CHANNEL_KEY_HEX,
name="Public",
is_hashtag=False,
on_radio=False,
)
async def sync_and_offload_all() -> dict:
"""Sync and offload both contacts and channels, then ensure defaults exist."""
logger.info("Starting full radio sync and offload")
contacts_result = await sync_and_offload_contacts()
channels_result = await sync_and_offload_channels()
# Ensure default channels exist
await ensure_default_channels()
return {
"contacts": contacts_result,
"channels": channels_result,
}
async def _periodic_sync_loop():
"""Background task that periodically syncs and offloads."""
while True:
try:
await asyncio.sleep(SYNC_INTERVAL)
logger.debug("Running periodic radio sync")
await sync_and_offload_all()
except asyncio.CancelledError:
logger.info("Periodic sync task cancelled")
break
except Exception as e:
logger.error("Error in periodic sync: %s", e)
def start_periodic_sync():
"""Start the periodic sync background task."""
global _sync_task
if _sync_task is None or _sync_task.done():
_sync_task = asyncio.create_task(_periodic_sync_loop())
logger.info("Started periodic radio sync (interval: %ds)", SYNC_INTERVAL)
def stop_periodic_sync():
"""Stop the periodic sync background task."""
global _sync_task
if _sync_task and not _sync_task.done():
_sync_task.cancel()
logger.info("Stopped periodic radio sync")
# Throttling for contact sync to radio
_last_contact_sync: float = 0.0
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
"""
Load recent non-repeater contacts to the radio for DM ACK support.
This ensures the radio can auto-ACK incoming DMs from recent contacts.
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
Returns counts of contacts loaded.
"""
global _last_contact_sync
# Throttle unless forced
now = time.time()
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
logger.debug("Contact sync throttled (last sync %ds ago)", int(now - _last_contact_sync))
return {"loaded": 0, "throttled": True}
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.debug("Cannot sync contacts to radio: not connected")
return {"loaded": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
_last_contact_sync = now
try:
# Get recent non-repeater contacts from database
max_contacts = settings.max_radio_contacts
contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
logger.debug("Found %d recent non-repeater contacts to sync", len(contacts))
loaded = 0
already_on_radio = 0
failed = 0
for contact in contacts:
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
already_on_radio += 1
# Update DB if not marked as on_radio
if not contact.on_radio:
await ContactRepository.set_on_radio(contact.public_key, True)
continue
try:
result = await mc.commands.add_contact(contact.to_radio_dict())
if result.type == EventType.OK:
loaded += 1
await ContactRepository.set_on_radio(contact.public_key, True)
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
else:
failed += 1
logger.warning(
"Failed to load contact %s: %s",
contact.public_key[:12], result.payload
)
except Exception as e:
failed += 1
logger.warning("Error loading contact %s: %s", contact.public_key[:12], e)
if loaded > 0 or failed > 0:
logger.info(
"Contact sync: loaded %d, already on radio %d, failed %d",
loaded, already_on_radio, failed
)
return {
"loaded": loaded,
"already_on_radio": already_on_radio,
"failed": failed,
}
except Exception as e:
logger.error("Error syncing contacts to radio: %s", e)
return {"loaded": 0, "error": str(e)}
+483
View File
@@ -0,0 +1,483 @@
import time
from typing import Any
from app.database import db
from app.models import Channel, Contact, Message, RawPacket
class ContactRepository:
@staticmethod
async def upsert(contact: dict[str, Any]) -> None:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
last_advert, lat, lon, last_seen, on_radio, last_contacted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = excluded.type,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon),
last_seen = excluded.last_seen,
on_radio = excluded.on_radio,
last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted)
""",
(
contact.get("public_key"),
contact.get("name") or contact.get("adv_name"),
contact.get("type", 0),
contact.get("flags", 0),
contact.get("last_path") or contact.get("out_path"),
contact.get("last_path_len") if "last_path_len" in contact else contact.get("out_path_len", -1),
contact.get("last_advert"),
contact.get("lat") or contact.get("adv_lat"),
contact.get("lon") or contact.get("adv_lon"),
contact.get("last_seen", int(time.time())),
contact.get("on_radio", False),
contact.get("last_contacted"),
),
)
await db.conn.commit()
@staticmethod
def _row_to_contact(row) -> Contact:
"""Convert a database row to a Contact model."""
return Contact(
public_key=row["public_key"],
name=row["name"],
type=row["type"],
flags=row["flags"],
last_path=row["last_path"],
last_path_len=row["last_path_len"],
last_advert=row["last_advert"],
lat=row["lat"],
lon=row["lon"],
last_seen=row["last_seen"],
on_radio=bool(row["on_radio"]),
last_contacted=row["last_contacted"],
)
@staticmethod
async def get_by_key(public_key: str) -> Contact | None:
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key = ?", (public_key,)
)
row = await cursor.fetchone()
return ContactRepository._row_to_contact(row) if row else None
@staticmethod
async def get_by_key_prefix(prefix: str) -> Contact | None:
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key LIKE ? LIMIT 1",
(f"{prefix}%",),
)
row = await cursor.fetchone()
return ContactRepository._row_to_contact(row) if row else None
@staticmethod
async def get_by_key_or_prefix(key_or_prefix: str) -> Contact | None:
"""Get a contact by exact key match, falling back to prefix match.
Useful when the input might be a full 64-char public key or a shorter prefix.
"""
contact = await ContactRepository.get_by_key(key_or_prefix)
if not contact:
contact = await ContactRepository.get_by_key_prefix(key_or_prefix)
return contact
@staticmethod
async def get_all(limit: int = 100, offset: int = 0) -> list[Contact]:
cursor = await db.conn.execute(
"SELECT * FROM contacts ORDER BY COALESCE(name, public_key) LIMIT ? OFFSET ?",
(limit, offset),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get the most recently active non-repeater contacts.
Orders by most recent activity (last_contacted or last_advert),
excluding repeaters (type=2).
"""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(public_key: str, path: str, path_len: int) -> None:
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key),
)
await db.conn.commit()
@staticmethod
async def set_on_radio(public_key: str, on_radio: bool) -> None:
await db.conn.execute(
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
(on_radio, public_key),
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
await db.conn.execute(
"DELETE FROM contacts WHERE public_key = ?",
(public_key,),
)
await db.conn.commit()
@staticmethod
async def update_last_contacted(public_key: str, timestamp: int | None = None) -> None:
"""Update the last_contacted timestamp for a contact."""
ts = timestamp or int(time.time())
await db.conn.execute(
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
(ts, ts, public_key),
)
await db.conn.commit()
@staticmethod
async def clear_all_on_radio() -> None:
"""Clear the on_radio flag for all contacts."""
await db.conn.execute("UPDATE contacts SET on_radio = 0")
await db.conn.commit()
@staticmethod
async def set_multiple_on_radio(public_keys: list[str], on_radio: bool = True) -> None:
"""Set on_radio flag for multiple contacts."""
if not public_keys:
return
placeholders = ",".join("?" * len(public_keys))
await db.conn.execute(
f"UPDATE contacts SET on_radio = ? WHERE public_key IN ({placeholders})",
[on_radio] + public_keys,
)
await db.conn.commit()
class ChannelRepository:
@staticmethod
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
"""Upsert a channel. Key is 32-char hex string."""
await db.conn.execute(
"""
INSERT INTO channels (key, name, is_hashtag, on_radio)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
name = excluded.name,
is_hashtag = excluded.is_hashtag,
on_radio = excluded.on_radio
""",
(key.upper(), name, is_hashtag, on_radio),
)
await db.conn.commit()
@staticmethod
async def get_by_key(key: str) -> Channel | None:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
(key.upper(),)
)
row = await cursor.fetchone()
if row:
return Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
)
return None
@staticmethod
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels ORDER BY name"
)
rows = await cursor.fetchall()
return [
Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
)
for row in rows
]
@staticmethod
async def get_by_name(name: str) -> Channel | None:
"""Get a channel by name."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE name = ?", (name,)
)
row = await cursor.fetchone()
if row:
return Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
)
return None
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
await db.conn.execute(
"DELETE FROM channels WHERE key = ?",
(key.upper(),),
)
await db.conn.commit()
class MessageRepository:
@staticmethod
async def create(
msg_type: str,
text: str,
received_at: int,
conversation_key: str,
sender_timestamp: int | None = None,
path_len: int | None = None,
txt_type: int = 0,
signature: str | None = None,
outgoing: bool = False,
) -> int | None:
"""Create a message, returning the ID or None if duplicate.
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
(type, conversation_key, text, sender_timestamp). This prevents
duplicate messages when the same message arrives via multiple RF paths.
"""
cursor = await db.conn.execute(
"""
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
received_at, path_len, txt_type, signature, outgoing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(msg_type, conversation_key, text, sender_timestamp, received_at,
path_len, txt_type, signature, outgoing),
)
await db.conn.commit()
# lastrowid is 0 if no row was inserted (duplicate)
return cursor.lastrowid if cursor.lastrowid else None
@staticmethod
async def get_all(
limit: int = 100,
offset: int = 0,
msg_type: str | None = None,
conversation_key: str | None = None,
) -> list[Message]:
query = "SELECT * FROM messages WHERE 1=1"
params: list[Any] = []
if msg_type:
query += " AND type = ?"
params.append(msg_type)
if conversation_key:
# Support both exact match and prefix match for DMs
query += " AND conversation_key LIKE ?"
params.append(f"{conversation_key}%")
query += " ORDER BY received_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor = await db.conn.execute(query, params)
rows = await cursor.fetchall()
return [
Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
path_len=row["path_len"],
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=bool(row["acked"]),
)
for row in rows
]
@staticmethod
async def mark_acked(message_id: int) -> None:
await db.conn.execute(
"UPDATE messages SET acked = 1 WHERE id = ?", (message_id,)
)
await db.conn.commit()
@staticmethod
async def find_duplicate(
conversation_key: str,
text: str,
sender_timestamp: int | None,
) -> int | None:
"""Find existing message with same content (for deduplication).
Returns message ID if found, None otherwise.
Used to detect the same message arriving via multiple RF paths.
"""
if sender_timestamp is None:
return None
cursor = await db.conn.execute(
"""
SELECT id FROM messages
WHERE conversation_key = ?
AND text = ?
AND sender_timestamp = ?
LIMIT 1
""",
(conversation_key, text, sender_timestamp),
)
row = await cursor.fetchone()
return row["id"] if row else None
@staticmethod
async def get_bulk(
conversations: list[dict],
limit_per_conversation: int = 100,
) -> dict[str, list["Message"]]:
"""Fetch messages for multiple conversations in one query per conversation.
Args:
conversations: List of {type: 'PRIV'|'CHAN', conversation_key: string}
limit_per_conversation: Max messages to return per conversation
Returns:
Dict mapping 'type:conversation_key' to list of messages
"""
result: dict[str, list[Message]] = {}
for conv in conversations:
msg_type = conv.get("type")
conv_key = conv.get("conversation_key")
if not msg_type or not conv_key:
continue
key = f"{msg_type}:{conv_key}"
cursor = await db.conn.execute(
"""
SELECT * FROM messages
WHERE type = ? AND conversation_key LIKE ?
ORDER BY received_at DESC
LIMIT ?
""",
(msg_type, f"{conv_key}%", limit_per_conversation),
)
rows = await cursor.fetchall()
result[key] = [
Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
path_len=row["path_len"],
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=bool(row["acked"]),
)
for row in rows
]
return result
class RawPacketRepository:
@staticmethod
async def create(data: bytes, timestamp: int | None = None) -> int:
"""Create a raw packet. Always stores (no deduplication at this level)."""
ts = timestamp or int(time.time())
cursor = await db.conn.execute(
"INSERT INTO raw_packets (timestamp, data) VALUES (?, ?)",
(ts, data),
)
await db.conn.commit()
return cursor.lastrowid or 0
@staticmethod
async def get_undecrypted_count() -> int:
"""Get count of undecrypted packets."""
cursor = await db.conn.execute(
"SELECT COUNT(*) as count FROM raw_packets WHERE decrypted = 0"
)
row = await cursor.fetchone()
return row["count"] if row else 0
@staticmethod
async def get_all_undecrypted() -> list[tuple[int, bytes]]:
"""Get all undecrypted packets as (id, data) tuples."""
cursor = await db.conn.execute(
"SELECT id, data FROM raw_packets WHERE decrypted = 0 ORDER BY timestamp ASC"
)
rows = await cursor.fetchall()
return [(row["id"], bytes(row["data"])) for row in rows]
@staticmethod
async def mark_decrypted(packet_id: int, message_id: int) -> None:
await db.conn.execute(
"UPDATE raw_packets SET decrypted = 1, message_id = ? WHERE id = ?",
(message_id, packet_id),
)
await db.conn.commit()
@staticmethod
async def get_undecrypted(limit: int = 100) -> list[RawPacket]:
cursor = await db.conn.execute(
"""
SELECT * FROM raw_packets
WHERE decrypted = 0
ORDER BY timestamp DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [
RawPacket(
id=row["id"],
timestamp=row["timestamp"],
data=row["data"].hex(),
decrypted=bool(row["decrypted"]),
message_id=row["message_id"],
decrypt_attempts=row["decrypt_attempts"],
last_attempt=row["last_attempt"],
)
for row in rows
]
@staticmethod
async def increment_attempts(packet_id: int) -> None:
await db.conn.execute(
"""
UPDATE raw_packets
SET decrypt_attempts = decrypt_attempts + 1, last_attempt = ?
WHERE id = ?
""",
(int(time.time()), packet_id),
)
await db.conn.commit()
View File
+130
View File
@@ -0,0 +1,130 @@
import logging
from hashlib import sha256
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import Channel
from app.repository import ChannelRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
class CreateChannelRequest(BaseModel):
name: str = Field(min_length=1, max_length=32)
key: str | None = Field(
default=None,
description="Channel key as hex string (32 chars = 16 bytes). If omitted or name starts with #, key is derived from name hash."
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
return await ChannelRepository.get_all()
@router.get("/{key}", response_model=Channel)
async def get_channel(key: str) -> Channel:
"""Get a specific channel by key (32-char hex string)."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
return channel
@router.post("", response_model=Channel)
async def create_channel(request: CreateChannelRequest) -> Channel:
"""Create a channel in the database.
Channels are NOT pushed to radio on creation. They are loaded to the radio
automatically when sending a message (see messages.py send_channel_message).
"""
is_hashtag = request.name.startswith("#")
# Determine the channel secret
if request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key")
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(request.name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, request.name, is_hashtag)
# Store in database only - radio sync happens at send time
await ChannelRepository.upsert(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
)
return Channel(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
)
@router.post("/sync")
async def sync_channels_from_radio(
max_channels: int = Query(default=40, ge=1, le=40)
) -> dict:
"""Sync channels from the radio to the database."""
mc = require_connected()
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
count = 0
for idx in range(max_channels):
result = await mc.commands.get_channel(idx)
if result.type == EventType.CHANNEL_INFO:
payload = result.payload
name = payload.get("channel_name", "")
secret = payload.get("channel_secret", b"")
# Skip empty channels
if not name or name == "\x00" * len(name):
continue
is_hashtag = name.startswith("#")
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
key_hex = key_bytes.hex().upper()
await ChannelRepository.upsert(
key=key_hex,
name=name,
is_hashtag=is_hashtag,
on_radio=True,
)
count += 1
logger.debug("Synced channel %s: %s", key_hex, name)
logger.info("Synced %d channels from radio", count)
return {"synced": count}
@router.delete("/{key}")
async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key.
Note: This does not clear the channel from the radio. The radio's channel
slots are managed separately (channels are loaded temporarily when sending).
"""
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)
return {"status": "ok"}
+138
View File
@@ -0,0 +1,138 @@
import logging
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.models import Contact
from app.radio import radio_manager
from app.repository import ContactRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contacts", tags=["contacts"])
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> list[Contact]:
"""List contacts from the database."""
return await ContactRepository.get_all(limit=limit, offset=offset)
@router.get("/{public_key}", response_model=Contact)
async def get_contact(public_key: str) -> Contact:
"""Get a specific contact by public key or prefix."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
return contact
@router.post("/sync")
async def sync_contacts_from_radio() -> dict:
"""Sync contacts from the radio to the database."""
mc = require_connected()
logger.info("Syncing contacts from radio")
result = await mc.commands.get_contacts()
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to get contacts: {result.payload}"
)
contacts = result.payload
count = 0
for public_key, contact_data in contacts.items():
await ContactRepository.upsert(
Contact.from_radio_dict(public_key, contact_data, on_radio=True)
)
count += 1
logger.info("Synced %d contacts from radio", count)
return {"synced": count}
@router.post("/{public_key}/remove-from-radio")
async def remove_contact_from_radio(public_key: str) -> dict:
"""Remove a contact from the radio (keeps it in database)."""
mc = require_connected()
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Get the contact from radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not radio_contact:
# Already not on radio
await ContactRepository.set_on_radio(contact.public_key, False)
return {"status": "ok", "message": "Contact was not on radio"}
logger.info("Removing contact %s from radio", contact.public_key[:12])
result = await mc.commands.remove_contact(radio_contact)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to remove contact: {result.payload}"
)
await ContactRepository.set_on_radio(contact.public_key, False)
return {"status": "ok"}
@router.post("/{public_key}/add-to-radio")
async def add_contact_to_radio(public_key: str) -> dict:
"""Add a contact from the database to the radio."""
mc = require_connected()
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found in database")
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
return {"status": "ok", "message": "Contact already on radio"}
logger.info("Adding contact %s to radio", contact.public_key[:12])
result = await mc.commands.add_contact(contact.to_radio_dict())
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to add contact: {result.payload}"
)
await ContactRepository.set_on_radio(contact.public_key, True)
return {"status": "ok"}
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Remove from radio if connected and contact is on radio
if radio_manager.is_connected and radio_manager.meshcore:
mc = radio_manager.meshcore
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
logger.info("Removing contact %s from radio before deletion", contact.public_key[:12])
await mc.commands.remove_contact(radio_contact)
# Delete from database
await ContactRepository.delete(contact.public_key)
logger.info("Deleted contact %s", contact.public_key[:12])
return {"status": "ok"}
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter
from pydantic import BaseModel
from app.radio import radio_manager
router = APIRouter(tags=["health"])
class HealthResponse(BaseModel):
status: str
radio_connected: bool
serial_port: str | None
@router.get("/health", response_model=HealthResponse)
async def healthcheck() -> HealthResponse:
"""Check if the API is running and if the radio is connected."""
return HealthResponse(
status="ok" if radio_manager.is_connected else "degraded",
radio_connected=radio_manager.is_connected,
serial_port=radio_manager.port,
)
+206
View File
@@ -0,0 +1,206 @@
import logging
import time
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.event_handlers import track_pending_ack, track_pending_repeat
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
from app.repository import MessageRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["messages"])
@router.get("", response_model=list[Message])
async def list_messages(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
conversation_key: str | None = Query(default=None, description="Filter by conversation key (channel key or contact pubkey)"),
) -> list[Message]:
"""List messages from the database."""
return await MessageRepository.get_all(
limit=limit,
offset=offset,
msg_type=type,
conversation_key=conversation_key,
)
@router.post("/bulk", response_model=dict[str, list[Message]])
async def get_messages_bulk(
conversations: list[dict],
limit_per_conversation: int = Query(default=100, ge=1, le=1000),
) -> dict[str, list[Message]]:
"""Fetch messages for multiple conversations in one request.
Body should be a list of {type: 'PRIV'|'CHAN', conversation_key: string}.
Returns a dict mapping 'type:conversation_key' to list of messages.
"""
return await MessageRepository.get_bulk(conversations, limit_per_conversation)
@router.post("/direct", response_model=Message)
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
"""Send a direct message to a contact."""
mc = require_connected()
# First check our database for the contact
from app.repository import ContactRepository
db_contact = await ContactRepository.get_by_key_or_prefix(request.destination)
if not db_contact:
raise HTTPException(
status_code=404,
detail=f"Contact not found in database: {request.destination}"
)
# Check if contact is on radio, if not add it
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
if not contact:
logger.info("Adding contact %s to radio before sending", db_contact.public_key[:12])
contact_data = db_contact.to_radio_dict()
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
# Continue anyway - might still work
# Get the contact from radio again
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
if not contact:
# Use the contact_data we built as fallback
contact = contact_data
logger.info("Sending direct message to %s", db_contact.public_key[:12])
result = await mc.commands.send_msg(
dst=contact,
msg=request.text,
)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send message: {result.payload}"
)
# Store outgoing message
now = int(time.time())
message_id = await MessageRepository.create(
msg_type="PRIV",
text=request.text,
conversation_key=db_contact.public_key,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
# Update last_contacted for the contact
await ContactRepository.update_last_contacted(db_contact.public_key, now)
# Track the expected ACK for this message
expected_ack = result.payload.get("expected_ack")
suggested_timeout = result.payload.get("suggested_timeout", 10000) # default 10s
if expected_ack:
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
track_pending_ack(ack_code, message_id, suggested_timeout)
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
return Message(
id=message_id,
type="PRIV",
conversation_key=db_contact.public_key,
text=request.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=False,
)
# Temporary radio slot used for sending channel messages
TEMP_RADIO_SLOT = 0
@router.post("/channel", response_model=Message)
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
"""Send a message to a channel."""
mc = require_connected()
# Get channel info from our database
from app.repository import ChannelRepository
from app.decoder import calculate_channel_hash
db_channel = await ChannelRepository.get_by_key(request.channel_key)
if not db_channel:
raise HTTPException(
status_code=404,
detail=f"Channel {request.channel_key} not found in database"
)
# Convert channel key hex to bytes
try:
key_bytes = bytes.fromhex(request.channel_key)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid channel key format: {request.channel_key}"
)
expected_hash = calculate_channel_hash(key_bytes)
logger.info(
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
request.channel_key, db_channel.name, TEMP_RADIO_SLOT, expected_hash
)
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
logger.warning(
"Failed to set channel on radio slot %d before sending: %s",
TEMP_RADIO_SLOT, set_result.payload
)
# Continue anyway - the channel might already be correctly configured
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send message: {result.payload}"
)
# Store outgoing message
now = int(time.time())
channel_key_upper = request.channel_key.upper()
message_id = await MessageRepository.create(
msg_type="CHAN",
text=request.text,
conversation_key=channel_key_upper,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
# Track for repeat detection (flood messages get confirmed by hearing repeats)
track_pending_repeat(channel_key_upper, request.text, now, message_id)
return Message(
id=message_id,
type="CHAN",
conversation_key=channel_key_upper,
text=request.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=False,
)
+175
View File
@@ -0,0 +1,175 @@
import logging
from hashlib import sha256
from fastapi import APIRouter, BackgroundTasks
from pydantic import BaseModel, Field
from app.decoder import try_decrypt_packet_with_channel_key
from app.packet_processor import create_message_from_decrypted
from app.repository import RawPacketRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/packets", tags=["packets"])
class DecryptRequest(BaseModel):
key_type: str = Field(description="Type of key: 'channel' or 'contact'")
channel_key: str | None = Field(default=None, description="Channel key as hex (16 bytes = 32 chars)")
channel_name: str | None = Field(default=None, description="Channel name (for hashtag channels, key derived from name)")
class DecryptResult(BaseModel):
started: bool
total_packets: int
message: str
class DecryptProgress(BaseModel):
total: int
processed: int
decrypted: int
in_progress: bool
# Global state for tracking decryption progress
_decrypt_progress: DecryptProgress | None = None
async def _run_historical_decryption(channel_key_bytes: bytes, channel_key_hex: str) -> None:
"""Background task to decrypt historical packets with a channel key."""
global _decrypt_progress
packets = await RawPacketRepository.get_all_undecrypted()
total = len(packets)
processed = 0
decrypted_count = 0
_decrypt_progress = DecryptProgress(
total=total, processed=0, decrypted=0, in_progress=True
)
logger.info("Starting historical decryption of %d packets", total)
for packet_id, packet_data in packets:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is not None:
# Successfully decrypted - use shared logic to store message
logger.debug(
"Decrypted packet %d: sender=%s, message=%s",
packet_id,
result.sender,
result.message[:50] if result.message else "",
)
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
)
if msg_id is not None:
decrypted_count += 1
processed += 1
_decrypt_progress = DecryptProgress(
total=total, processed=processed, decrypted=decrypted_count, in_progress=True
)
_decrypt_progress = DecryptProgress(
total=total, processed=processed, decrypted=decrypted_count, in_progress=False
)
logger.info(
"Historical decryption complete: %d/%d packets decrypted", decrypted_count, total
)
@router.get("/undecrypted/count")
async def get_undecrypted_count() -> dict:
"""Get the count of undecrypted packets."""
count = await RawPacketRepository.get_undecrypted_count()
return {"count": count}
@router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks
) -> DecryptResult:
"""
Attempt to decrypt historical packets with the provided key.
Runs in the background to avoid blocking.
"""
global _decrypt_progress
# Check if decryption is already in progress
if _decrypt_progress and _decrypt_progress.in_progress:
return DecryptResult(
started=False,
total_packets=_decrypt_progress.total,
message=f"Decryption already in progress: {_decrypt_progress.processed}/{_decrypt_progress.total}",
)
# Determine the channel key
channel_key_bytes: bytes | None = None
channel_key_hex: str | None = None
if request.key_type == "channel":
if request.channel_key:
# Direct key provided
try:
channel_key_bytes = bytes.fromhex(request.channel_key)
if len(channel_key_bytes) != 16:
return DecryptResult(
started=False,
total_packets=0,
message="Channel key must be 16 bytes (32 hex chars)",
)
channel_key_hex = request.channel_key.upper()
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for channel key",
)
elif request.channel_name:
# Derive key from channel name (hashtag channel)
channel_key_bytes = sha256(request.channel_name.encode("utf-8")).digest()[:16]
channel_key_hex = channel_key_bytes.hex().upper()
else:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide channel_key or channel_name",
)
else:
# Contact decryption not yet supported (requires Ed25519 shared secret)
return DecryptResult(
started=False,
total_packets=0,
message="Contact key decryption not yet supported",
)
# Get count of undecrypted packets
count = await RawPacketRepository.get_undecrypted_count()
if count == 0:
return DecryptResult(
started=False, total_packets=0, message="No undecrypted packets to process"
)
# Start background decryption
background_tasks.add_task(_run_historical_decryption, channel_key_bytes, channel_key_hex)
return DecryptResult(
started=True,
total_packets=count,
message=f"Started decryption of {count} packets in background",
)
@router.get("/decrypt/progress", response_model=DecryptProgress | None)
async def get_decrypt_progress() -> DecryptProgress | None:
"""Get the current progress of historical decryption."""
return _decrypt_progress
+188
View File
@@ -0,0 +1,188 @@
import logging
import time
from fastapi import APIRouter, HTTPException
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
class RadioSettings(BaseModel):
freq: float = Field(description="Frequency in MHz")
bw: float = Field(description="Bandwidth in kHz")
sf: int = Field(description="Spreading factor (7-12)")
cr: int = Field(description="Coding rate (1-4)")
class RadioConfigResponse(BaseModel):
public_key: str = Field(description="Public key (64-char hex)")
name: str
lat: float
lon: float
tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum transmit power in dBm")
radio: RadioSettings
class RadioConfigUpdate(BaseModel):
name: str | None = None
lat: float | None = None
lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
radio: RadioSettings | None = None
class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
mc = require_connected()
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
lat=info.get("adv_lat", 0.0),
lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_tx_power", 0),
radio=RadioSettings(
freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 0.0),
sf=info.get("radio_sf", 0),
cr=info.get("radio_cr", 0),
),
)
@router.patch("/config", response_model=RadioConfigResponse)
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
"""Update radio configuration. Only provided fields will be updated."""
mc = require_connected()
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
if update.lat is not None or update.lon is not None:
current_info = mc.self_info
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
logger.info("Setting radio coordinates to %f, %f", lat, lon)
await mc.commands.set_coords(lat=lat, lon=lon)
if update.tx_power is not None:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
update.radio.freq,
update.radio.bw,
update.radio.sf,
update.radio.cr,
)
await mc.commands.set_radio(
freq=update.radio.freq,
bw=update.radio.bw,
sf=update.radio.sf,
cr=update.radio.cr,
)
# Sync time with system clock
now = int(time.time())
logger.debug("Syncing radio time to %d", now)
await mc.commands.set_time(now)
return await get_radio_config()
@router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only."""
mc = require_connected()
try:
key_bytes = bytes.fromhex(update.private_key)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for private key")
logger.info("Importing private key")
result = await mc.commands.import_private_key(key_bytes)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to import private key: {result.payload}")
return {"status": "ok"}
@router.post("/advertise")
async def send_advertisement(flood: bool = True) -> dict:
"""Send a radio advertisement to announce presence on the mesh."""
mc = require_connected()
logger.info("Sending advertisement (flood=%s)", flood)
result = await mc.commands.send_advert(flood=flood)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send advertisement: {result.payload}")
return {"status": "ok", "flood": flood}
@router.post("/reboot")
async def reboot_radio() -> dict:
"""Reboot the radio. Connection will temporarily drop and auto-reconnect."""
mc = require_connected()
logger.info("Rebooting radio")
await mc.commands.reboot()
return {"status": "ok", "message": "Reboot command sent. Radio will reconnect automatically."}
@router.post("/reconnect")
async def reconnect_radio() -> dict:
"""Attempt to reconnect to the radio.
This will try to re-establish connection to the radio, with auto-detection
if no specific port is configured. Useful when the radio has been disconnected
or power-cycled.
"""
from app.radio import radio_manager
if radio_manager.is_connected:
return {"status": "ok", "message": "Already connected", "connected": True}
if radio_manager.is_reconnecting:
return {"status": "pending", "message": "Reconnection already in progress", "connected": False}
logger.info("Manual reconnect requested")
success = await radio_manager.reconnect()
if success:
# Re-register event handlers after successful reconnect
from app.event_handlers import register_event_handlers
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
# Restart auto message fetching
await radio_manager.meshcore.start_auto_message_fetching()
logger.info("Event handlers re-registered and auto message fetching started")
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
else:
raise HTTPException(
status_code=503,
detail="Failed to reconnect. Check radio connection and power."
)
+48
View File
@@ -0,0 +1,48 @@
import logging
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
class AppSettingsResponse(BaseModel):
max_radio_contacts: int = Field(description="Maximum non-repeater contacts to keep on radio for DM ACKs")
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
ge=1,
le=1000,
description="Maximum non-repeater contacts to keep on radio (1-1000)"
)
@router.get("", response_model=AppSettingsResponse)
async def get_settings() -> AppSettingsResponse:
"""Get current application settings."""
return AppSettingsResponse(
max_radio_contacts=settings.max_radio_contacts,
)
@router.patch("", response_model=AppSettingsResponse)
async def update_settings(update: AppSettingsUpdate) -> AppSettingsResponse:
"""Update application settings.
Note: Changes are applied immediately but not persisted across restarts.
Set MESHCORE_MAX_RADIO_CONTACTS environment variable for persistent changes.
"""
if update.max_radio_contacts is not None:
logger.info("Updating max_radio_contacts from %d to %d",
settings.max_radio_contacts, update.max_radio_contacts)
# Pydantic settings are mutable, we can update them directly
object.__setattr__(settings, 'max_radio_contacts', update.max_radio_contacts)
return AppSettingsResponse(
max_radio_contacts=settings.max_radio_contacts,
)
+61
View File
@@ -0,0 +1,61 @@
"""WebSocket router for real-time updates."""
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository
from app.websocket import ws_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
"""WebSocket endpoint for real-time updates."""
await ws_manager.connect(websocket)
# Send initial state
try:
# Health status
health_data = {
"radio_connected": radio_manager.is_connected,
"serial_port": radio_manager.port,
}
await ws_manager.send_personal(websocket, "health", health_data)
# Contacts
contacts = await ContactRepository.get_all(limit=500)
await ws_manager.send_personal(
websocket,
"contacts",
[c.model_dump() for c in contacts],
)
# Channels
channels = await ChannelRepository.get_all()
await ws_manager.send_personal(
websocket,
"channels",
[c.model_dump() for c in channels],
)
except Exception as e:
logger.error("Error sending initial state: %s", e)
# Keep connection alive and handle incoming messages
try:
while True:
# We don't expect messages from client, but need to keep connection open
# and handle pings/pongs
data = await websocket.receive_text()
# Client can send "ping" to keep alive
if data == "ping":
await websocket.send_text('{"type":"pong"}')
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
except Exception as e:
logger.debug("WebSocket error: %s", e)
await ws_manager.disconnect(websocket)
+92
View File
@@ -0,0 +1,92 @@
"""WebSocket manager for real-time updates."""
import asyncio
import json
import logging
from typing import Any
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class WebSocketManager:
"""Manages WebSocket connections and broadcasts events."""
def __init__(self):
self.active_connections: list[WebSocket] = []
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
async with self._lock:
self.active_connections.append(websocket)
logger.info("WebSocket client connected (%d total)", len(self.active_connections))
async def disconnect(self, websocket: WebSocket) -> None:
async with self._lock:
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info("WebSocket client disconnected (%d remaining)", len(self.active_connections))
async def broadcast(self, event_type: str, data: Any) -> None:
"""Broadcast an event to all connected clients."""
if not self.active_connections:
return
message = json.dumps({"type": event_type, "data": data})
async with self._lock:
disconnected = []
for connection in self.active_connections:
try:
await connection.send_text(message)
except Exception as e:
logger.debug("Failed to send to client: %s", e)
disconnected.append(connection)
# Clean up disconnected clients
for conn in disconnected:
if conn in self.active_connections:
self.active_connections.remove(conn)
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
"""Send an event to a specific client."""
message = json.dumps({"type": event_type, "data": data})
try:
await websocket.send_text(message)
except Exception as e:
logger.debug("Failed to send to client: %s", e)
# Global instance
ws_manager = WebSocketManager()
def broadcast_event(event_type: str, data: dict) -> None:
"""Schedule a broadcast without blocking.
Convenience function that creates an asyncio task to broadcast
an event to all connected WebSocket clients.
"""
asyncio.create_task(ws_manager.broadcast(event_type, data))
def broadcast_error(message: str, details: str | None = None) -> None:
"""Broadcast an error notification to all connected clients.
This appears as a toast notification in the frontend.
"""
data = {"message": message}
if details:
data["details"] = details
asyncio.create_task(ws_manager.broadcast("error", data))
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
"""Broadcast health status change to all connected clients."""
asyncio.create_task(ws_manager.broadcast("health", {
"status": "ok" if radio_connected else "degraded",
"radio_connected": radio_connected,
"serial_port": serial_port,
}))