mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 09:52:06 +02:00
Initial commit
This commit is contained in:
+391
@@ -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)
|
||||
```
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)")
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}))
|
||||
Reference in New Issue
Block a user