Initial commit

This commit is contained in:
Jack Kingsman
2026-01-06 19:59:51 -08:00
commit 557cb12879
82 changed files with 387739 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
frontend/node_modules/
# reference librarys
references/
+1
View File
@@ -0,0 +1 @@
3.12
+267
View File
@@ -0,0 +1,267 @@
# RemoteTerm for MeshCore
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over serial and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
**For detailed component documentation, see:**
- `app/CLAUDE.md` - Backend (FastAPI, database, radio connection, packet decryption)
- `frontend/CLAUDE.md` - Frontend (React, state management, WebSocket, components)
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ RawPacketList + CrackerPanel (WebGPU key bruteforcing) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ useWebSocket ←──── Real-time updates │
│ │ │
│ api.ts ←──── REST API calls │
└───────────────────────────┼──────────────────────────────────────┘
│ HTTP + WebSocket (/api/*)
┌───────────────────────────┼──────────────────────────────────────┐
│ Backend (FastAPI) │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
│ ↓ └───────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ RadioManager + Event Handlers │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────┼──────────────────────────────────────┘
│ Serial
┌──────┴──────┐
│ MeshCore │
│ Radio │
└─────────────┘
```
## Key Design Principles
1. **Store-and-serve**: Backend stores all packets even when no client is connected
2. **Parallel storage**: Messages stored both decrypted (when possible) and as raw packets
3. **Extended capacity**: Server stores contacts/channels beyond radio limits (~350 contacts, ~40 channels)
4. **Real-time updates**: WebSocket pushes events; REST for actions
5. **Offline-capable**: Radio operates independently; server syncs when connected
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
## Data Flow
### Incoming Messages
1. Radio receives message → MeshCore library emits event
2. `event_handlers.py` catches event → stores in database
3. `ws_manager` broadcasts to connected clients
4. Frontend `useWebSocket` receives → updates React state
### Outgoing Messages
1. User types message → clicks send
2. `api.sendChannelMessage()` → POST to backend
3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()`
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
### ACK and Repeat Detection
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
**Channel messages**: Flood messages echo back. The decoder identifies repeats by matching (channel_idx, text_hash, timestamp ±5s) and marks the original as "acked".
## Directory Structure
```
.
├── app/ # FastAPI backend
│ ├── CLAUDE.md # Backend documentation
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── repository.py # Database CRUD
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ └── websocket.py # Real-time broadcasts
├── frontend/ # React frontend
│ ├── CLAUDE.md # Frontend documentation
│ ├── src/
│ │ ├── App.tsx # Main component
│ │ ├── api.ts # REST client
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── components/
│ │ ├── CrackerPanel.tsx # WebGPU key cracking
│ │ └── ...
│ └── vite.config.ts
├── references/meshcore_py/ # MeshCore Python library
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
├── integration_test.html # Browser-based API tests
└── pyproject.toml # Python dependencies
```
## Development Setup
### Backend
```bash
# Install dependencies
uv sync
# Run server (auto-detects radio)
uv run uvicorn app.main:app --reload
# Or specify port
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api to :8000
```
### Both Together (Development)
Terminal 1: `uv run uvicorn app.main:app --reload`
Terminal 2: `cd frontend && npm run dev`
### Production
In production, the FastAPI backend serves the compiled frontend:
```bash
cd frontend && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
## Testing
### Backend (pytest)
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
- `tests/test_keystore.py` - Ephemeral key store
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_api.py` - API endpoints
### Frontend (Vitest)
```bash
cd frontend
npm run test:run
```
### Integration Tests
Open `integration_test.html` in a browser with the backend running.
## API Summary
All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status |
| GET | `/api/radio/config` | Radio configuration |
| PATCH | `/api/radio/config` | Update name, location, radio params |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| POST | `/api/radio/enable-server-decryption` | Export private key, enable decryption |
| GET | `/api/radio/decryption-status` | Check if decryption enabled |
| GET | `/api/contacts` | List contacts |
| POST | `/api/contacts/sync` | Pull from radio |
| GET | `/api/channels` | List channels |
| POST | `/api/channels` | Create channel |
| GET | `/api/messages` | List with filters |
| POST | `/api/messages/direct` | Send direct message |
| POST | `/api/messages/channel` | Send channel message |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
| WS | `/api/ws` | Real-time updates |
## Key Concepts
### Contact Public Keys
- Full key: 64-character hex string
- Prefix: 12-character hex (used for matching)
- Lookups use `LIKE 'prefix%'` for matching
### Contact Types
- `0` - Unknown
- `1` - Client (regular node)
- `2` - Repeater
- `3` - Room
### Channel Keys
- Stored as 32-character hex string (TEXT PRIMARY KEY)
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
- Custom channels: User-provided or generated
### Message Types
- `PRIV` - Direct messages
- `CHAN` - Channel messages
- Both use `conversation_key` (user pubkey for PRIV, channel key for CHAN)
### State Tracking Keys (Frontend)
Generated by `getStateKey()` for unread tracking and message times:
- Channels: `channel-{channel_key}`
- Contacts: `contact-{12-char-pubkey-prefix}`
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
### Server-Side Decryption
The server can decrypt historical packets if given the necessary keys:
**Channel messages**: Decrypted automatically using stored channel keys.
**Direct messages**: Requires the node's private key, which must be:
1. Exported from radio via `POST /radio/enable-server-decryption`
2. Stored **only in memory** (never persisted to disk)
3. Re-exported after every server restart
This allows decrypting messages from contacts whose public keys were learned after the message was received.
## MeshCore Library
The `meshcore_py` library provides radio communication. Key patterns:
```python
# Connection
mc = await MeshCore.create_serial(port="/dev/ttyUSB0")
# Commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(channel_idx, msg)
await mc.commands.get_contacts()
await mc.commands.set_channel(idx, name, key)
# Events
mc.subscribe(EventType.CONTACT_MSG_RECV, handler)
mc.subscribe(EventType.CHANNEL_MSG_RECV, handler)
mc.subscribe(EventType.ACK, handler)
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_MAX_RADIO_CONTACTS` | `200` | Max recent contacts to keep on radio for DM ACKs |
+7
View File
@@ -0,0 +1,7 @@
Copyright 2026 Jack Kingsman <jack@jackkingsman.me>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+118
View File
@@ -0,0 +1,118 @@
# RemoteTerm for MeshCore
Web interface for MeshCore mesh radio networks. Attach your radio over serial, and then you can:
* Cache all received packets, decrypting as you gain keys
* Send and receive DMs and GroupTexts
* Passively monitor as many contacts and channels as you want; radio limitations are irrelevant as all packets get hoovered up, then decrypted serverside
* Use your radio remotely over your network or away from home over a VPN
* Look for hashtag room names by brute forcing channel keys of GroupTexts you don't have the keys for yet
## This is a personal toolkit, and not optimized for general consumption! This is entirely vibecoded slop and I make no warranty of fitness for any purpose.
For real, this code is bad and totally LLM generated. If you insist on extending it, there are three `CLAUDE.md` fils you should have your LLM read in `./`, `./frontend`, and `./app`.
## Requirements
- Python 3.10+
- Node.js 18+
- UV (Python package manager): `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore-compatible radio connected via USB serial
## Quick Start
### Backend
```bash
# Install dependencies
uv sync
# Run (auto-detects serial port)
uv run uvicorn app.main:app --reload
# Or specify port explicitly
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
```
Backend runs at http://localhost:8000
### Frontend
```bash
cd frontend
# Install dependencies
npm install
# Development server (proxies API to localhost:8000)
npm run dev
# Production build
npm run build
```
Dev server runs at http://localhost:5173
## Production Deployment
For production, the FastAPI backend serves the compiled frontend directly.
```bash
# 1. Install Python dependencies
uv sync
# 2. Build frontend
cd frontend
npm install
npm run build
cd ..
# 3. Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# Or with explicit serial port
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access the app at http://localhost:8000 (or your server's IP/hostname).
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Baud rate |
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_MAX_RADIO_CONTACTS` | 200 | Max recent contacts to keep on radio for DM ACKs |
## Testing
### Backend (pytest)
```bash
# Install test dependencies
uv sync --extra test
# Run all tests
PYTHONPATH=. uv run pytest tests/ -v
# Run specific test file
PYTHONPATH=. uv run pytest tests/test_decoder.py -v
```
### Frontend (Vitest)
```bash
cd frontend
# Run tests once
npm run test:run
# Run tests in watch mode
npm test
```
## API Docs
With the backend running, visit http://localhost:8000/docs for interactive API documentation.
+391
View File
@@ -0,0 +1,391 @@
# Backend CLAUDE.md
This document provides context for AI assistants and developers working on the FastAPI backend.
## Technology Stack
- **FastAPI** - Async web framework with automatic OpenAPI docs
- **aiosqlite** - Async SQLite driver
- **meshcore** - MeshCore radio library (local dependency at `../meshcore_py`)
- **Pydantic** - Data validation and settings management
- **PyCryptodome** - AES-128 encryption for packet decryption
- **PyNaCl** - Ed25519/X25519 key exchange for direct message decryption
- **UV** - Python package manager
## Directory Structure
```
app/
├── main.py # FastAPI app, lifespan, router registration, static file serving
├── config.py # Pydantic settings (env vars: MESHCORE_*)
├── database.py # SQLite schema, connection management
├── models.py # Pydantic models for API request/response
├── repository.py # Database CRUD (ContactRepository, ChannelRepository, etc.)
├── radio.py # RadioManager - serial connection to MeshCore device
├── radio_sync.py # Periodic sync, contact auto-loading to radio
├── decoder.py # Packet decryption (channel + direct messages)
├── packet_processor.py # Raw packet processing, advertisement handling
├── keystore.py # Ephemeral key store (private key in memory only)
├── event_handlers.py # Radio event subscriptions, ACK tracking, repeat detection
├── websocket.py # WebSocketManager for real-time client updates
└── routers/ # All routes prefixed with /api
├── health.py # GET /api/health
├── radio.py # Radio config, advertise, private key, reboot
├── contacts.py # Contact CRUD and radio sync
├── channels.py # Channel CRUD and radio sync
├── messages.py # Message list and send (direct/channel)
├── packets.py # Raw packet endpoints, historical decryption
├── settings.py # App settings (max_radio_contacts)
└── ws.py # WebSocket endpoint at /api/ws
```
## Key Architectural Patterns
### Repository Pattern
All database operations go through repository classes in `repository.py`:
```python
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository
# Examples
contact = await ContactRepository.get_by_key_prefix("abc123")
await MessageRepository.create(msg_type="PRIV", text="Hello", received_at=timestamp)
await RawPacketRepository.mark_decrypted(packet_id, message_id)
```
### Radio Connection
`RadioManager` in `radio.py` handles serial connection:
```python
from app.radio import radio_manager
# Access meshcore instance
if radio_manager.meshcore:
await radio_manager.meshcore.commands.send_msg(dst, msg)
```
Auto-detection scans common serial ports when `MESHCORE_SERIAL_PORT` is not set.
### Event-Driven Architecture
Radio events flow through `event_handlers.py`:
| Event | Handler | Actions |
|-------|---------|---------|
| `CONTACT_MSG_RECV` | `on_contact_message` | Store message, update contact last_seen, broadcast via WS |
| `CHANNEL_MSG_RECV` | `on_channel_message` | Store message, broadcast via WS |
| `RAW_DATA` | `on_raw_data` | Store packet, try decrypt with all channel keys, detect repeats |
| `ADVERTISEMENT` | `on_advertisement` | Upsert contact with location |
| `ACK` | `on_ack` | Match pending ACKs, mark message acked, broadcast |
### WebSocket Broadcasting
Real-time updates use `ws_manager` singleton:
```python
from app.websocket import ws_manager
# Broadcast to all connected clients
await ws_manager.broadcast("message", {"id": 1, "text": "Hello"})
```
Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
Helper functions for common broadcasts:
```python
from app.websocket import broadcast_error, broadcast_health
# Notify clients of errors (shows toast in frontend)
broadcast_error("Operation failed", "Additional details")
# Notify clients of connection status change
broadcast_health(radio_connected=True, serial_port="/dev/ttyUSB0")
```
### Connection Monitoring
`RadioManager` includes a background task that monitors connection status:
- Checks connection every 5 seconds
- Broadcasts `health` event on status change
- Attempts automatic reconnection when connection lost
- Supports manual reconnection via `POST /api/radio/reconnect`
```python
from app.radio import radio_manager
# Manual reconnection
success = await radio_manager.reconnect()
# Background monitor (started automatically in app lifespan)
await radio_manager.start_connection_monitor()
await radio_manager.stop_connection_monitor()
```
## Database Schema
```sql
contacts (
public_key TEXT PRIMARY KEY, -- 64-char hex
name TEXT,
type INTEGER DEFAULT 0, -- 0=unknown, 1=client, 2=repeater, 3=room
flags INTEGER DEFAULT 0,
last_path TEXT, -- Routing path hex
last_path_len INTEGER DEFAULT -1,
last_advert INTEGER, -- Unix timestamp of last advertisement
lat REAL, lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0, -- Boolean: contact loaded on radio
last_contacted INTEGER -- Unix timestamp of last message sent/received
)
channels (
key TEXT PRIMARY KEY, -- 32-char hex channel key
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0, -- Key derived from SHA256(name)[:16]
on_radio INTEGER DEFAULT 0
)
messages (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL, -- 'PRIV' or 'CHAN'
conversation_key TEXT NOT NULL, -- User pubkey for PRIV, channel key for CHAN
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
path_len INTEGER,
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
acked INTEGER DEFAULT 0,
UNIQUE(type, conversation_key, text, sender_timestamp) -- Deduplication
)
raw_packets (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL, -- Raw packet bytes
decrypted INTEGER DEFAULT 0,
message_id INTEGER, -- FK to messages if decrypted
decrypt_attempts INTEGER DEFAULT 0,
last_attempt INTEGER,
FOREIGN KEY (message_id) REFERENCES messages(id)
)
```
## Packet Decryption (`decoder.py`)
The decoder handles MeshCore packet decryption for historical packet analysis:
### Packet Types
```python
class PayloadType(IntEnum):
GROUP_TEXT = 0x05 # Channel messages (decryptable)
TEXT_MESSAGE = 0x02 # Direct messages
ACK = 0x03
ADVERT = 0x04
# ... see decoder.py for full list
```
### Channel Key Derivation
Hashtag channels derive keys from name:
```python
channel_key = hashlib.sha256(b"#channelname").digest()[:16]
```
### Decryption Flow
1. Parse packet header to get payload type
2. For `GROUP_TEXT`: extract channel_hash (1 byte), cipher_mac (2 bytes), ciphertext
3. Verify HMAC-SHA256 using 32-byte secret (key + 16 zero bytes)
4. Decrypt with AES-128 ECB
5. Parse decrypted content: timestamp (4 bytes), flags (1 byte), "sender: message" text
```python
from app.decoder import try_decrypt_packet_with_channel_key
result = try_decrypt_packet_with_channel_key(raw_bytes, channel_key)
if result:
print(f"{result.sender}: {result.message}")
```
### Direct Message Decryption
Direct messages use ECDH key exchange (Ed25519 → X25519) with the sender's public key
and recipient's private key:
```python
from app.decoder import try_decrypt_packet_with_contact_key
result = try_decrypt_packet_with_contact_key(
raw_bytes, sender_pub_key, recipient_prv_key
)
if result:
print(f"Message: {result.message}")
```
**Requirements:**
- Sender's Ed25519 public key (32 bytes)
- Recipient's Ed25519 private key (64 bytes) - from ephemeral KeyStore
### Ephemeral Key Store (`keystore.py`)
Private keys are stored **only in memory** for security:
```python
from app.keystore import KeyStore
# Set private key (exported from radio)
KeyStore.set_private_key(private_key_bytes)
# Check if available
if KeyStore.has_private_key():
key = KeyStore.get_private_key()
# Clear from memory
KeyStore.clear_private_key()
```
**Security guarantees:**
- Never written to disk
- Never logged
- Lost on server restart (must re-export from radio)
## ACK and Repeat Detection
### Direct Message ACKs
When sending a direct message, an expected ACK code is tracked:
```python
from app.event_handlers import track_pending_ack
track_pending_ack(expected_ack="abc123", message_id=42, timeout_ms=30000)
```
When ACK event arrives, the message is marked as acked.
### Channel Message Repeats
Flood messages echo back through repeaters. Detection uses:
- Channel key
- Text hash
- Timestamp (±5 second window)
When a repeat is detected, the original outgoing message is marked as "acked".
### Auto-Contact Sync to Radio
To enable the radio to auto-ACK incoming DMs, recent non-repeater contacts are
automatically loaded to the radio. Configured via `max_radio_contacts` setting (default 200).
- Triggered on each advertisement from a non-repeater contact
- Loads most recently contacted non-repeaters (by `last_contacted` timestamp)
- Throttled to at most once per 30 seconds
- `last_contacted` updated on message send/receive
```python
from app.radio_sync import sync_recent_contacts_to_radio
result = await sync_recent_contacts_to_radio(force=True)
# Returns: {"loaded": 5, "already_on_radio": 195, "failed": 0}
```
## API Endpoints
All endpoints are prefixed with `/api`.
### Health
- `GET /api/health` - Connection status, serial port
### Radio
- `GET /api/radio/config` - Read config (public key, name, radio params)
- `PATCH /api/radio/config` - Update name, lat/lon, tx_power, radio params
- `PUT /api/radio/private-key` - Import private key (write-only)
- `POST /api/radio/advertise?flood=true` - Send advertisement
- `POST /api/radio/reboot` - Reboot radio
- `POST /api/radio/reconnect` - Manual reconnection attempt
- `POST /api/radio/enable-server-decryption` - Export private key from radio, enable server-side decryption
- `GET /api/radio/decryption-status` - Check if server-side decryption is enabled
- `POST /api/radio/disable-server-decryption` - Clear private key from memory
### Contacts
- `GET /api/contacts` - List from database
- `GET /api/contacts/{key}` - Get by public key or prefix
- `POST /api/contacts/sync` - Pull from radio to database
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
### Channels
- `GET /api/channels` - List from database
- `GET /api/channels/{key}` - Get by channel key
- `POST /api/channels` - Create (hashtag if name starts with # or no key provided)
- `POST /api/channels/sync` - Pull from radio
- `DELETE /api/channels/{key}` - Delete channel
### Messages
- `GET /api/messages?type=&conversation_key=&limit=&offset=` - List with filters
- `POST /api/messages/direct` - Send direct message
- `POST /api/messages/channel` - Send channel message
### Packets
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
### Settings
- `GET /api/settings` - Get app settings (max_radio_contacts)
- `PATCH /api/settings` - Update app settings
### WebSocket
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
### Static Files (Production)
In production, the backend also serves the frontend:
- `/` - Serves `frontend/dist/index.html`
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
- `/*` - Falls back to `index.html` for SPA routing
## Testing
Run tests with:
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange, real-world test vectors
- `tests/test_keystore.py` - Ephemeral key store operations
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_api.py` - API endpoint tests
## Common Tasks
### Adding a New Endpoint
1. Create or update router in `app/routers/`
2. Define Pydantic models in `app/models.py` if needed
3. Add repository methods in `app/repository.py` for database operations
4. Register router in `app/main.py` if new file
5. Add tests in `tests/`
### Adding a New Event Handler
1. Define handler in `app/event_handlers.py`
2. Register in `register_event_handlers()` function
3. Broadcast updates via `ws_manager` as needed
### Working with Radio Commands
```python
# Available via radio_manager.meshcore.commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(chan, msg)
await mc.commands.get_contacts()
await mc.commands.add_contact(contact_dict)
await mc.commands.set_channel(idx, name, key)
await mc.commands.send_advert(flood=True)
```
View File
+27
View File
@@ -0,0 +1,27 @@
import logging
from typing import Literal
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
max_radio_contacts: int = 200 # Max non-repeater contacts to keep on radio for DM ACKs
class Config:
env_prefix = "MESHCORE_"
settings = Settings()
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
level=settings.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
+93
View File
@@ -0,0 +1,93 @@
import logging
from pathlib import Path
import aiosqlite
from app.config import settings
logger = logging.getLogger(__name__)
SCHEMA = """
CREATE TABLE IF NOT EXISTS contacts (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER
);
CREATE TABLE IF NOT EXISTS channels (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
conversation_key TEXT NOT NULL,
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
path_len INTEGER,
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
acked INTEGER DEFAULT 0,
UNIQUE(type, conversation_key, text, sender_timestamp)
);
CREATE TABLE IF NOT EXISTS raw_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL,
decrypted INTEGER DEFAULT 0,
message_id INTEGER,
decrypt_attempts INTEGER DEFAULT 0,
last_attempt INTEGER,
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE INDEX IF NOT EXISTS idx_raw_packets_decrypted ON raw_packets(decrypted);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
"""
class Database:
def __init__(self, db_path: str):
self.db_path = db_path
self._connection: aiosqlite.Connection | None = None
async def connect(self) -> None:
logger.info("Connecting to database at %s", self.db_path)
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._connection = await aiosqlite.connect(self.db_path)
self._connection.row_factory = aiosqlite.Row
await self._connection.executescript(SCHEMA)
await self._connection.commit()
logger.debug("Database schema initialized")
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
self._connection = None
logger.debug("Database connection closed")
@property
def conn(self) -> aiosqlite.Connection:
if not self._connection:
raise RuntimeError("Database not connected")
return self._connection
db = Database(settings.database_path)
+364
View File
@@ -0,0 +1,364 @@
"""
MeshCore packet decoder for historical packet decryption.
Based on https://github.com/michaelhart/meshcore-decoder
"""
import hmac
import hashlib
import logging
from dataclasses import dataclass
from enum import IntEnum
from Crypto.Cipher import AES
logger = logging.getLogger(__name__)
class PayloadType(IntEnum):
REQUEST = 0x00
RESPONSE = 0x01
TEXT_MESSAGE = 0x02
ACK = 0x03
ADVERT = 0x04
GROUP_TEXT = 0x05
GROUP_DATA = 0x06
ANON_REQUEST = 0x07
PATH = 0x08
TRACE = 0x09
MULTIPART = 0x0A
CONTROL = 0x0B
RAW_CUSTOM = 0x0F
class RouteType(IntEnum):
TRANSPORT_FLOOD = 0x00
FLOOD = 0x01
DIRECT = 0x02
TRANSPORT_DIRECT = 0x03
@dataclass
class DecryptedGroupText:
"""Result of decrypting a GroupText (channel) message."""
timestamp: int
flags: int
sender: str | None
message: str
channel_hash: str
@dataclass
class ParsedAdvertisement:
"""Result of parsing an advertisement packet."""
public_key: str # 64-char hex
name: str | None
lat: float | None
lon: float | None
@dataclass
class PacketInfo:
"""Basic packet header info."""
route_type: RouteType
payload_type: PayloadType
payload_version: int
path_length: int
payload: bytes
def calculate_channel_hash(channel_key: bytes) -> str:
"""
Calculate the channel hash from a 16-byte channel key.
Returns the first byte of SHA256(key) as hex.
"""
hash_bytes = hashlib.sha256(channel_key).digest()
return format(hash_bytes[0], "02x")
def extract_payload(raw_packet: bytes) -> bytes | None:
"""
Extract just the payload from a raw packet, skipping header and path.
Packet structure:
- Byte 0: header (route_type, payload_type, version)
- For TRANSPORT routes: bytes 1-4 are transport codes
- Next byte: path_length
- Next path_length bytes: path data
- Remaining: payload
Returns the payload bytes, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload
return raw_packet[offset:]
except (ValueError, IndexError):
return None
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
"""Parse a raw packet and extract basic info."""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = RouteType(header & 0x03)
payload_type = PayloadType((header >> 2) & 0x0F)
payload_version = (header >> 6) & 0x03
offset = 1
# Skip transport codes if present
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload
payload = raw_packet[offset:]
return PacketInfo(
route_type=route_type,
payload_type=payload_type,
payload_version=payload_version,
path_length=path_length,
payload=payload,
)
except (ValueError, IndexError):
return None
def decrypt_group_text(
payload: bytes, channel_key: bytes
) -> DecryptedGroupText | None:
"""
Decrypt a GroupText payload using the channel key.
GroupText structure:
- channel_hash (1 byte): First byte of SHA256 of channel key
- cipher_mac (2 bytes): First 2 bytes of HMAC-SHA256
- ciphertext (rest): AES-128 ECB encrypted content
Decrypted content structure:
- timestamp (4 bytes, little-endian)
- flags (1 byte)
- message text (null-terminated string, format: "sender: message")
"""
if len(payload) < 3:
return None
channel_hash = format(payload[0], "02x")
cipher_mac = payload[1:3]
ciphertext = payload[3:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
# AES requires 16-byte blocks
return None
# Create the 32-byte channel secret (key + 16 zero bytes)
channel_secret = channel_key + bytes(16)
# Verify MAC: HMAC-SHA256 of ciphertext using full 32-byte secret
calculated_mac = hmac.new(channel_secret, ciphertext, hashlib.sha256).digest()
if calculated_mac[:2] != cipher_mac:
return None
# Decrypt using AES-128 ECB with the 16-byte key
try:
cipher = AES.new(channel_key, AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed: %s", e)
return None
if len(decrypted) < 5:
return None
# Parse decrypted content
timestamp = int.from_bytes(decrypted[0:4], "little")
flags = decrypted[4]
# Extract message text (UTF-8, null-terminated)
message_bytes = decrypted[5:]
try:
message_text = message_bytes.decode("utf-8")
# Remove null terminator and any padding
null_idx = message_text.find("\x00")
if null_idx >= 0:
message_text = message_text[:null_idx]
except UnicodeDecodeError:
return None
# Parse "sender: message" format
sender = None
content = message_text
colon_idx = message_text.find(": ")
if 0 < colon_idx < 50:
potential_sender = message_text[:colon_idx]
# Check for invalid characters in sender name
if not any(c in potential_sender for c in ":[]\x00"):
sender = potential_sender
content = message_text[colon_idx + 2 :]
return DecryptedGroupText(
timestamp=timestamp,
flags=flags,
sender=sender,
message=content,
channel_hash=channel_hash,
)
def try_decrypt_packet_with_channel_key(
raw_packet: bytes, channel_key: bytes
) -> DecryptedGroupText | None:
"""
Try to decrypt a raw packet using a channel key.
Returns decrypted content if successful, None otherwise.
"""
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
# Only GroupText packets can be decrypted with channel keys
if packet_info.payload_type != PayloadType.GROUP_TEXT:
return None
# Check if channel hash matches
if len(packet_info.payload) < 1:
return None
packet_channel_hash = format(packet_info.payload[0], "02x")
expected_hash = calculate_channel_hash(channel_key)
if packet_channel_hash != expected_hash:
return None
return decrypt_group_text(packet_info.payload, channel_key)
def get_packet_payload_type(raw_packet: bytes) -> PayloadType | None:
"""Get the payload type of a raw packet without full parsing."""
if len(raw_packet) < 1:
return None
header = raw_packet[0]
try:
return PayloadType((header >> 2) & 0x0F)
except ValueError:
return None
def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
"""
Parse an advertisement payload.
Advertisement structure:
- public_key (32 bytes): Ed25519 public key
- signature (64 bytes): Ed25519 signature
- advert_data (variable): Contains name and possibly lat/lon
The name is typically at the end of the payload as a UTF-8 string.
"""
# Minimum: 32 (pubkey) + 64 (sig) + at least 1 byte for flags/data
if len(payload) < 97:
return None
public_key = payload[:32].hex()
# signature = payload[32:96] # Not currently verified
advert_data = payload[96:]
if len(advert_data) == 0:
return ParsedAdvertisement(
public_key=public_key,
name=None,
lat=None,
lon=None,
)
# Try to extract name from the advert data
# The structure varies, but the name is typically near the end
name = None
lat = None
lon = None
# Try to decode the entire advert_data as UTF-8 to find the name
# Names are typically at the end after any binary data
try:
# Find the last valid UTF-8 string
for start in range(len(advert_data)):
try:
text = advert_data[start:].decode("utf-8")
# Filter out control characters and check if it looks like a name
null_idx = text.find("\x00")
if null_idx >= 0:
text = text[:null_idx]
text = text.strip()
if text and len(text) >= 1 and len(text) <= 40:
# Check if it contains printable characters
if any(c.isalnum() for c in text):
name = text
break
except UnicodeDecodeError:
continue
except Exception:
pass
return ParsedAdvertisement(
public_key=public_key,
name=name,
lat=lat,
lon=lon,
)
def try_parse_advertisement(raw_packet: bytes) -> ParsedAdvertisement | None:
"""
Try to parse a raw packet as an advertisement.
Returns parsed advertisement if successful, None otherwise.
"""
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
if packet_info.payload_type != PayloadType.ADVERT:
return None
return parse_advertisement(packet_info.payload)
+15
View File
@@ -0,0 +1,15 @@
"""Shared dependencies for FastAPI routers."""
from fastapi import HTTPException
from app.radio import radio_manager
def require_connected():
"""Dependency that ensures radio is connected and returns meshcore instance.
Raises HTTPException 503 if radio is not connected.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
raise HTTPException(status_code=503, detail="Radio not connected")
return radio_manager.meshcore
+193
View File
@@ -0,0 +1,193 @@
import logging
import time
from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import Contact
from app.packet_processor import process_raw_packet, track_pending_repeat
from app.repository import ContactRepository, MessageRepository
from app.websocket import broadcast_event
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
_pending_acks: dict[str, tuple[int, float, int]] = {}
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
"""Track a pending ACK for a direct message."""
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
logger.debug("Tracking pending ACK %s for message %d (timeout %dms)", expected_ack, message_id, timeout_ms)
def _cleanup_expired_acks() -> None:
"""Remove expired pending ACKs."""
now = time.time()
expired = []
for code, (msg_id, created_at, timeout_ms) in _pending_acks.items():
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
expired.append(code)
for code in expired:
del _pending_acks[code]
logger.debug("Expired pending ACK %s", code)
async def on_contact_message(event: "Event") -> None:
"""Handle incoming direct messages.
Direct messages are decrypted by MeshCore library using ECDH key exchange.
The packet processor cannot decrypt these without the node's private key.
"""
payload = event.payload
logger.debug("Received direct message from %s", payload.get("pubkey_prefix"))
# Get full public key if available, otherwise use prefix
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
received_at = int(time.time())
# Look up full public key from contact database if we only have prefix
if len(sender_pubkey) < 64:
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
if contact:
sender_pubkey = contact.public_key
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=payload.get("text", ""),
conversation_key=sender_pubkey,
sender_timestamp=payload.get("sender_timestamp"),
received_at=received_at,
path_len=payload.get("path_len"),
txt_type=payload.get("txt_type", 0),
signature=payload.get("signature"),
)
if msg_id is None:
# Duplicate message (same content from same sender) - skip broadcast
logger.debug("Duplicate direct message from %s ignored", sender_pubkey[:12])
return
# Broadcast only genuinely new messages
broadcast_event("message", {
"id": msg_id,
"type": "PRIV",
"conversation_key": sender_pubkey,
"text": payload.get("text", ""),
"sender_timestamp": payload.get("sender_timestamp"),
"received_at": received_at,
"path_len": payload.get("path_len"),
"txt_type": payload.get("txt_type", 0),
"signature": payload.get("signature"),
"outgoing": False,
"acked": False,
})
# Update contact last_seen and last_contacted
contact = await ContactRepository.get_by_key_prefix(sender_pubkey)
if contact:
await ContactRepository.update_last_contacted(contact.public_key, received_at)
async def on_rx_log_data(event: "Event") -> None:
"""Store raw RF packet data and process via centralized packet processor.
This is the unified entry point for all RF packets. The packet processor
handles channel messages (GROUP_TEXT) and advertisements (ADVERT).
"""
payload = event.payload
logger.debug("Received RX log data packet")
if "payload" not in payload:
logger.warning("RX_LOG_DATA event missing 'payload' field")
return
raw_hex = payload["payload"]
raw_bytes = bytes.fromhex(raw_hex)
await process_raw_packet(
raw_bytes=raw_bytes,
snr=payload.get("snr"),
rssi=payload.get("rssi"),
)
async def on_path_update(event: "Event") -> None:
"""Handle path update events."""
payload = event.payload
logger.debug("Path update for %s", payload.get("pubkey_prefix"))
pubkey_prefix = payload.get("pubkey_prefix", "")
path = payload.get("path", "")
path_len = payload.get("path_len", -1)
existing = await ContactRepository.get_by_key_prefix(pubkey_prefix)
if existing:
await ContactRepository.update_path(existing.public_key, path, path_len)
async def on_new_contact(event: "Event") -> None:
"""Handle new contact from radio's internal contact database.
This is different from RF advertisements - these are contacts synced
from the radio's stored contact list.
"""
payload = event.payload
public_key = payload.get("public_key", "")
if not public_key:
logger.warning("Received new contact event with no public_key, skipping")
return
logger.debug("New contact: %s", public_key[:12])
contact_data = {
**Contact.from_radio_dict(public_key, payload, on_radio=True),
"last_seen": int(time.time()),
}
await ContactRepository.upsert(contact_data)
broadcast_event("contact", contact_data)
async def on_ack(event: "Event") -> None:
"""Handle ACK events for direct messages."""
payload = event.payload
ack_code = payload.get("code", "")
if not ack_code:
logger.debug("Received ACK with no code")
return
logger.debug("Received ACK with code %s", ack_code)
_cleanup_expired_acks()
if ack_code in _pending_acks:
message_id, _, _ = _pending_acks.pop(ack_code)
logger.info("ACK received for message %d", message_id)
await MessageRepository.mark_acked(message_id)
broadcast_event("message_acked", {"message_id": message_id})
else:
logger.debug("ACK code %s does not match any pending messages", ack_code)
def register_event_handlers(meshcore) -> None:
"""Register event handlers with the MeshCore instance.
Note: CHANNEL_MSG_RECV and ADVERTISEMENT events are NOT subscribed.
These are handled by the packet processor via RX_LOG_DATA to avoid
duplicate processing and ensure consistent handling.
"""
meshcore.subscribe(EventType.CONTACT_MSG_RECV, on_contact_message)
meshcore.subscribe(EventType.RX_LOG_DATA, on_rx_log_data)
meshcore.subscribe(EventType.PATH_UPDATE, on_path_update)
meshcore.subscribe(EventType.NEW_CONTACT, on_new_contact)
meshcore.subscribe(EventType.ACK, on_ack)
logger.info("Event handlers registered")
+114
View File
@@ -0,0 +1,114 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import setup_logging
from app.database import db
from app.event_handlers import register_event_handlers
from app.radio import radio_manager
from app.radio_sync import (
start_periodic_sync,
stop_periodic_sync,
sync_and_offload_all,
)
from app.routers import channels, contacts, health, messages, packets, radio, settings, ws
setup_logging()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage database and radio connection lifecycle."""
await db.connect()
logger.info("Database connected")
try:
await radio_manager.connect()
logger.info("Connected to radio")
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all()
logger.info("Sync complete: %s", result)
# Start periodic sync
start_periodic_sync()
# Send advertisement to announce our presence
logger.info("Sending startup advertisement...")
advert_result = await radio_manager.meshcore.commands.send_advert(flood=True)
logger.info("Advertisement sent: %s", advert_result.type)
await radio_manager.meshcore.start_auto_message_fetching()
logger.info("Auto message fetching started")
except Exception as e:
logger.warning("Failed to connect to radio on startup: %s", e)
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
yield
logger.info("Shutting down")
await radio_manager.stop_connection_monitor()
stop_periodic_sync()
if radio_manager.meshcore:
await radio_manager.meshcore.stop_auto_message_fetching()
await radio_manager.disconnect()
await db.disconnect()
app = FastAPI(
title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(channels.router, prefix="/api")
app.include_router(messages.router, prefix="/api")
app.include_router(packets.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(ws.router, prefix="/api")
# Serve frontend static files in production
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if FRONTEND_DIR.exists():
# Serve static assets (JS, CSS, etc.)
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
# Serve other static files from frontend/dist (like wordlist)
@app.get("/{path:path}")
async def serve_frontend(path: str):
"""Serve frontend files, falling back to index.html for SPA routing."""
file_path = FRONTEND_DIR / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Fall back to index.html for SPA routing
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(FRONTEND_DIR / "index.html")
+124
View File
@@ -0,0 +1,124 @@
from pydantic import BaseModel, Field
class Contact(BaseModel):
public_key: str = Field(description="Public key (64-char hex)")
name: str | None = None
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
last_seen: int | None = None
on_radio: bool = False
last_contacted: int | None = None # Last time we sent/received a message
def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
"""
return {
"public_key": self.public_key,
"adv_name": self.name or "",
"type": self.type,
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
"adv_lat": self.lat or 0.0,
"adv_lon": self.lon or 0.0,
"last_advert": self.last_advert or 0,
}
@staticmethod
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
"""Convert radio contact data to database format dict.
This is the inverse of to_radio_dict(), used when syncing contacts
from radio to database.
"""
return {
"public_key": public_key,
"name": radio_data.get("adv_name"),
"type": radio_data.get("type", 0),
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
"on_radio": on_radio,
}
# Contact type constants
CONTACT_TYPE_REPEATER = 2
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str
is_hashtag: bool = False
on_radio: bool = False
class Message(BaseModel):
id: int
type: str = Field(description="PRIV or CHAN")
conversation_key: str = Field(description="User pubkey for PRIV, channel key for CHAN")
text: str
sender_timestamp: int | None = None
received_at: int
path_len: int | None = None
txt_type: int = 0
signature: str | None = None
outgoing: bool = False
acked: bool = False
class RawPacket(BaseModel):
"""Raw packet as stored in the database."""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
decrypted: bool = False
message_id: int | None = None
decrypt_attempts: int = 0
last_attempt: int | None = None
class RawPacketDecryptedInfo(BaseModel):
"""Decryption info for a raw packet (when successfully decrypted)."""
channel_name: str | None = None
sender: str | None = None
class RawPacketBroadcast(BaseModel):
"""Raw packet payload broadcast via WebSocket.
This extends the database model with runtime-computed fields
like payload_type, snr, rssi, and decryption info.
"""
id: int
timestamp: int
data: str = Field(description="Hex-encoded packet data")
payload_type: str = Field(description="Packet type name (e.g., GROUP_TEXT, ADVERT)")
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB")
rssi: int | None = Field(default=None, description="Received signal strength in dBm")
decrypted: bool = False
decrypted_info: RawPacketDecryptedInfo | None = None
class SendMessageRequest(BaseModel):
text: str = Field(min_length=1)
class SendDirectMessageRequest(SendMessageRequest):
destination: str = Field(description="Public key or prefix of recipient")
class SendChannelMessageRequest(SendMessageRequest):
channel_key: str = Field(description="Channel key (32-char hex)")
+351
View File
@@ -0,0 +1,351 @@
"""
Centralized packet processing for MeshCore messages.
This module handles:
- Storing raw packets
- Decrypting channel messages (GroupText) with stored channel keys
- Decrypting direct messages with stored contact keys (if private key available)
- Creating message entries for successfully decrypted packets
- Broadcasting updates via WebSocket
This is the primary path for message processing when channel/contact keys
are offloaded from the radio to the server.
"""
import asyncio
import logging
import time
from app.decoder import (
PayloadType,
parse_packet,
try_decrypt_packet_with_channel_key,
try_parse_advertisement,
)
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
from app.repository import ChannelRepository, ContactRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
# Pending repeats for outgoing message ACK detection
# Key: (channel_key, text_hash, timestamp) -> message_id
_pending_repeats: dict[tuple[str, str, int], int] = {}
_pending_repeat_expiry: dict[tuple[str, str, int], float] = {}
REPEAT_EXPIRY_SECONDS = 30
async def create_message_from_decrypted(
packet_id: int,
channel_key: str,
sender: str | None,
message_text: str,
timestamp: int,
received_at: int | None = None,
) -> int | None:
"""Create a message record from decrypted channel packet content.
This is the shared logic for storing decrypted channel messages,
used by both real-time packet processing and historical decryption.
Returns the message ID if created, None if duplicate.
"""
import time as time_module
received = received_at or int(time_module.time())
# Format the message text
text = f"{sender}: {message_text}" if sender else message_text
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel_key.upper(),
sender_timestamp=timestamp,
received_at=received,
)
if msg_id is None:
# Duplicate detected - find existing message ID for packet linkage
existing_id = await MessageRepository.find_duplicate(
conversation_key=channel_key.upper(),
text=text,
sender_timestamp=timestamp,
)
if existing_id:
await RawPacketRepository.mark_decrypted(packet_id, existing_id)
return None
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
return msg_id
def track_pending_repeat(channel_key: str, text: str, timestamp: int, message_id: int) -> None:
"""Track an outgoing channel message for repeat detection."""
text_hash = str(hash(text))
key = (channel_key.upper(), text_hash, timestamp)
_pending_repeats[key] = message_id
_pending_repeat_expiry[key] = time.time() + REPEAT_EXPIRY_SECONDS
logger.debug("Tracking repeat for channel %s, message %d", channel_key[:8], message_id)
def _cleanup_expired_repeats() -> None:
"""Remove expired pending repeats."""
now = time.time()
expired = [k for k, exp in _pending_repeat_expiry.items() if exp < now]
for k in expired:
_pending_repeats.pop(k, None)
_pending_repeat_expiry.pop(k, None)
async def process_raw_packet(
raw_bytes: bytes,
timestamp: int | None = None,
snr: float | None = None,
rssi: int | None = None,
) -> dict:
"""
Process an incoming raw packet.
This is the main entry point for all incoming RF packets.
"""
ts = timestamp or int(time.time())
packet_id = await RawPacketRepository.create(raw_bytes, ts)
raw_hex = raw_bytes.hex()
# Parse packet to get type
packet_info = parse_packet(raw_bytes)
payload_type = packet_info.payload_type if packet_info else None
payload_type_name = payload_type.name if payload_type else "Unknown"
result = {
"packet_id": packet_id,
"timestamp": ts,
"raw_hex": raw_hex,
"payload_type": payload_type_name,
"snr": snr,
"rssi": rssi,
"decrypted": False,
"message_id": None,
"channel_name": None,
"sender": None,
}
# Try to decrypt/parse based on payload type
if payload_type == PayloadType.GROUP_TEXT:
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
if decrypt_result:
result.update(decrypt_result)
elif payload_type == PayloadType.ADVERT:
await _process_advertisement(raw_bytes, ts)
# TODO: Add TEXT_MESSAGE (direct message) decryption when private key is available
# elif payload_type == PayloadType.TEXT_MESSAGE:
# decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
# if decrypt_result:
# result.update(decrypt_result)
# Broadcast raw packet for the packet feed UI
broadcast_payload = RawPacketBroadcast(
id=packet_id,
timestamp=ts,
data=raw_hex,
payload_type=payload_type_name,
snr=snr,
rssi=rssi,
decrypted=result["decrypted"],
decrypted_info=RawPacketDecryptedInfo(
channel_name=result["channel_name"],
sender=result["sender"],
) if result["decrypted"] else None,
)
broadcast_event("raw_packet", broadcast_payload.model_dump())
return result
async def _process_group_text(
raw_bytes: bytes,
packet_id: int,
timestamp: int,
packet_info,
) -> dict | None:
"""
Process a GroupText (channel message) packet.
Tries all known channel keys to decrypt.
Creates a message entry if successful.
Handles repeat detection for outgoing message ACKs.
"""
# Try to decrypt with all known channel keys
channels = await ChannelRepository.get_all()
for channel in channels:
# Convert hex key to bytes for decryption
try:
channel_key_bytes = bytes.fromhex(channel.key)
except ValueError:
continue
decrypted = try_decrypt_packet_with_channel_key(raw_bytes, channel_key_bytes)
if not decrypted:
continue
# Successfully decrypted!
logger.debug(
"Decrypted GroupText for channel %s: %s",
channel.name, decrypted.message[:50]
)
# Check for repeat detection (our own message echoed back)
is_repeat = False
_cleanup_expired_repeats()
text_hash = str(hash(decrypted.message))
for ts_offset in range(-5, 6):
key = (channel.key, text_hash, decrypted.timestamp + ts_offset)
if key in _pending_repeats:
message_id = _pending_repeats[key]
# Don't pop - let it expire naturally so subsequent repeats via
# different radio paths are also caught as duplicates
logger.info("Repeat detected for channel message %d", message_id)
await MessageRepository.mark_acked(message_id)
broadcast_event("message_acked", {"message_id": message_id})
is_repeat = True
break
if is_repeat:
# Mark packet as decrypted but don't create new message
await RawPacketRepository.mark_decrypted(packet_id, message_id)
return {
"decrypted": True,
"channel_name": channel.name,
"sender": decrypted.sender,
"message_id": message_id,
}
# Format the message text
if decrypted.sender:
text = f"{decrypted.sender}: {decrypted.message}"
else:
text = decrypted.message
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=text,
conversation_key=channel.key,
sender_timestamp=decrypted.timestamp,
received_at=timestamp,
)
if msg_id is None:
# Duplicate detected by database constraint (same message via different RF path)
# Find existing message ID for packet linkage
existing_id = await MessageRepository.find_duplicate(
conversation_key=channel.key,
text=text,
sender_timestamp=decrypted.timestamp,
)
logger.debug(
"Duplicate message detected for channel %s (existing id=%s)",
channel.name, existing_id
)
if existing_id:
await RawPacketRepository.mark_decrypted(packet_id, existing_id)
return {
"decrypted": True,
"channel_name": channel.name,
"sender": decrypted.sender,
"message_id": existing_id,
}
logger.info("Stored channel message %d for %s", msg_id, channel.name)
# Broadcast new message (only for genuinely new messages)
broadcast_event("message", {
"id": msg_id,
"type": "CHAN",
"conversation_key": channel.key,
"text": text,
"sender_timestamp": decrypted.timestamp,
"received_at": timestamp,
"path_len": packet_info.path_length if packet_info else None,
"txt_type": 0,
"signature": None,
"outgoing": False,
"acked": False,
})
# Mark the raw packet as decrypted
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
return {
"decrypted": True,
"channel_name": channel.name,
"sender": decrypted.sender,
"message_id": msg_id,
}
# Couldn't decrypt with any known key
return None
async def _process_advertisement(
raw_bytes: bytes,
timestamp: int,
) -> None:
"""
Process an advertisement packet.
Extracts contact info and updates the database/broadcasts to clients.
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
"""
advert = try_parse_advertisement(raw_bytes)
if not advert:
logger.debug("Failed to parse advertisement packet")
return
logger.debug("Parsed advertisement from %s: %s", advert.public_key[:12], advert.name)
# Try to find existing contact
existing = await ContactRepository.get_by_key(advert.public_key)
contact_data = {
"public_key": advert.public_key,
"name": advert.name,
"lat": advert.lat,
"lon": advert.lon,
"last_advert": timestamp,
"last_seen": timestamp,
}
await ContactRepository.upsert(contact_data)
# Broadcast contact update to connected clients
contact_type = existing.type if existing else 0
broadcast_event("contact", {
"public_key": advert.public_key,
"name": advert.name,
"type": contact_type,
"flags": existing.flags if existing else 0,
"last_path": existing.last_path if existing else None,
"last_path_len": existing.last_path_len if existing else -1,
"last_advert": timestamp,
"lat": advert.lat,
"lon": advert.lon,
"last_seen": timestamp,
"on_radio": existing.on_radio if existing else False,
})
# If this is not a repeater, trigger recent contacts sync to radio
# This ensures we can auto-ACK DMs from recent contacts
if contact_type != CONTACT_TYPE_REPEATER:
# Import here to avoid circular import
from app.radio_sync import sync_recent_contacts_to_radio
asyncio.create_task(sync_recent_contacts_to_radio())
+249
View File
@@ -0,0 +1,249 @@
import asyncio
import glob
import logging
import platform
from pathlib import Path
from meshcore import MeshCore
from app.config import settings
logger = logging.getLogger(__name__)
def detect_serial_devices() -> list[str]:
"""Detect available serial devices based on platform."""
devices: list[str] = []
system = platform.system()
if system == "Darwin":
# macOS: Use /dev/cu.* devices (callout devices, preferred over tty.*)
patterns = [
"/dev/cu.usb*",
"/dev/cu.wchusbserial*",
"/dev/cu.SLAB_USBtoUART*",
]
for pattern in patterns:
devices.extend(glob.glob(pattern))
devices.sort()
else:
# Linux: Prefer /dev/serial/by-id/ for persistent naming
by_id_path = Path("/dev/serial/by-id")
if by_id_path.is_dir():
devices.extend(str(p) for p in by_id_path.iterdir())
# Also check /dev/ttyACM* and /dev/ttyUSB* as fallback
resolved_paths = set()
for dev in devices:
try:
resolved_paths.add(str(Path(dev).resolve()))
except OSError:
pass
for pattern in ["/dev/ttyACM*", "/dev/ttyUSB*"]:
for dev in glob.glob(pattern):
try:
if str(Path(dev).resolve()) not in resolved_paths:
devices.append(dev)
except OSError:
devices.append(dev)
devices.sort()
return devices
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
"""Test if a MeshCore radio responds on the given serial port."""
try:
logger.debug("Testing serial device %s", port)
mc = await asyncio.wait_for(
MeshCore.create_serial(port=port, baudrate=baudrate),
timeout=timeout,
)
# Check if we got valid self_info (indicates successful communication)
if mc.is_connected and mc.self_info:
logger.debug("Device %s responded with valid self_info", port)
await mc.disconnect()
return True
await mc.disconnect()
return False
except asyncio.TimeoutError:
logger.debug("Device %s timed out", port)
return False
except Exception as e:
logger.debug("Device %s failed: %s", port, e)
return False
async def find_radio_port(baudrate: int) -> str | None:
"""Find the first serial port with a responding MeshCore radio."""
devices = detect_serial_devices()
if not devices:
logger.warning("No serial devices found")
return None
logger.info("Found %d serial device(s), testing for MeshCore radio...", len(devices))
for device in devices:
if await test_serial_device(device, baudrate):
logger.info("Found MeshCore radio at %s", device)
return device
logger.warning("No MeshCore radio found on any serial device")
return None
class RadioManager:
"""Manages the MeshCore radio connection."""
def __init__(self):
self._meshcore: MeshCore | None = None
self._port: str | None = None
self._reconnect_task: asyncio.Task | None = None
self._last_connected: bool = False
self._reconnecting: bool = False
@property
def meshcore(self) -> MeshCore | None:
return self._meshcore
@property
def port(self) -> str | None:
return self._port
@property
def is_connected(self) -> bool:
return self._meshcore is not None and self._meshcore.is_connected
@property
def is_reconnecting(self) -> bool:
return self._reconnecting
async def connect(self) -> None:
"""Connect to the radio over serial."""
if self._meshcore is not None:
await self.disconnect()
port = settings.serial_port
# Auto-detect if no port specified
if not port:
logger.info("No serial port specified, auto-detecting...")
port = await find_radio_port(settings.serial_baudrate)
if not port:
raise RuntimeError("No MeshCore radio found. Please specify MESHCORE_SERIAL_PORT.")
logger.debug(
"Connecting to radio at %s (baud %d)",
port,
settings.serial_baudrate,
)
self._meshcore = await MeshCore.create_serial(
port=port,
baudrate=settings.serial_baudrate,
auto_reconnect=True,
max_reconnect_attempts=10,
)
self._port = port
self._last_connected = True
logger.debug("Serial connection established")
async def disconnect(self) -> None:
"""Disconnect from the radio."""
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
await self._meshcore.disconnect()
self._meshcore = None
logger.debug("Radio disconnected")
async def reconnect(self) -> bool:
"""Attempt to reconnect to the radio.
Returns True if reconnection was successful, False otherwise.
"""
from app.websocket import broadcast_error, broadcast_health
if self._reconnecting:
logger.debug("Reconnection already in progress")
return False
self._reconnecting = True
logger.info("Attempting to reconnect to radio...")
try:
# Disconnect if we have a stale connection
if self._meshcore is not None:
try:
await self._meshcore.disconnect()
except Exception:
pass
self._meshcore = None
# Try to connect (will auto-detect if no port specified)
await self.connect()
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._port)
broadcast_health(True, self._port)
return True
else:
logger.warning("Reconnection failed: not connected after connect()")
return False
except Exception as e:
logger.warning("Reconnection failed: %s", e)
broadcast_error("Reconnection failed", str(e))
return False
finally:
self._reconnecting = False
async def start_connection_monitor(self) -> None:
"""Start background task to monitor connection and auto-reconnect."""
if self._reconnect_task is not None:
return
async def monitor_loop():
from app.websocket import broadcast_health
while True:
await asyncio.sleep(5) # Check every 5 seconds
current_connected = self.is_connected
# Detect status change
if self._last_connected and not current_connected:
# Connection lost
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._port)
self._last_connected = False
# Attempt reconnection
await asyncio.sleep(3) # Wait a bit before trying
await self.reconnect()
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically)
logger.info("Radio connection restored")
broadcast_health(True, self._port)
self._last_connected = True
self._reconnect_task = asyncio.create_task(monitor_loop())
logger.info("Radio connection monitor started")
async def stop_connection_monitor(self) -> None:
"""Stop the connection monitor task."""
if self._reconnect_task is not None:
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
self._reconnect_task = None
logger.info("Radio connection monitor stopped")
radio_manager = RadioManager()
+299
View File
@@ -0,0 +1,299 @@
"""
Radio sync and offload management.
This module handles syncing contacts and channels from the radio to the database,
then removing them from the radio to free up space for new discoveries.
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
"""
import asyncio
import logging
import time
from meshcore import EventType
from app.config import settings
from app.models import CONTACT_TYPE_REPEATER, Contact
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository
logger = logging.getLogger(__name__)
# Background task handle
_sync_task: asyncio.Task | None = None
# Sync interval in seconds (5 minutes)
SYNC_INTERVAL = 300
async def sync_and_offload_contacts() -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.warning("Cannot sync contacts: radio not connected")
return {"synced": 0, "removed": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
synced = 0
removed = 0
try:
# Get all contacts from radio
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error("Failed to get contacts from radio: %s", result)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
logger.info("Found %d contacts on radio", len(contacts))
# Sync each contact to database, then remove from radio
for public_key, contact_data in contacts.items():
# Save to database
await ContactRepository.upsert(
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
)
synced += 1
# Remove from radio
try:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
else:
logger.warning(
"Failed to remove contact %s: %s",
public_key[:12], remove_result.payload
)
except Exception as e:
logger.warning("Error removing contact %s: %s", public_key[:12], e)
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
except Exception as e:
logger.error("Error during contact sync: %s", e)
return {"synced": synced, "removed": removed, "error": str(e)}
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels() -> dict:
"""
Sync channels from radio to database, then clear them from radio.
Returns counts of synced and cleared channels.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.warning("Cannot sync channels: radio not connected")
return {"synced": 0, "cleared": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
synced = 0
cleared = 0
try:
# Check all 40 channel slots
for idx in range(40):
result = await mc.commands.get_channel(idx)
if result.type != EventType.CHANNEL_INFO:
continue
payload = result.payload
name = payload.get("channel_name", "")
secret = payload.get("channel_secret", b"")
# Skip empty channels
if not name or name == "\x00" * len(name) or all(b == 0 for b in secret):
continue
is_hashtag = name.startswith("#")
# Convert key bytes to hex string
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
key_hex = key_bytes.hex().upper()
# Save to database
await ChannelRepository.upsert(
key=key_hex,
name=name,
is_hashtag=is_hashtag,
on_radio=False, # We're about to clear it
)
synced += 1
logger.debug("Synced channel %s: %s", key_hex[:8], name)
# Clear from radio (set empty name and zero key)
try:
clear_result = await mc.commands.set_channel(
channel_idx=idx,
channel_name="",
channel_secret=bytes(16),
)
if clear_result.type == EventType.OK:
cleared += 1
else:
logger.warning(
"Failed to clear channel %d: %s",
idx, clear_result.payload
)
except Exception as e:
logger.warning("Error clearing channel %d: %s", idx, e)
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
except Exception as e:
logger.error("Error during channel sync: %s", e)
return {"synced": synced, "cleared": cleared, "error": str(e)}
return {"synced": synced, "cleared": cleared}
async def ensure_default_channels() -> None:
"""
Ensure default channels exist in the database.
These will be configured on the radio when needed for sending.
"""
# Public channel - no hashtag, specific key
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
existing = await ChannelRepository.get_by_name("Public")
if not existing:
logger.info("Creating default Public channel")
await ChannelRepository.upsert(
key=PUBLIC_CHANNEL_KEY_HEX,
name="Public",
is_hashtag=False,
on_radio=False,
)
async def sync_and_offload_all() -> dict:
"""Sync and offload both contacts and channels, then ensure defaults exist."""
logger.info("Starting full radio sync and offload")
contacts_result = await sync_and_offload_contacts()
channels_result = await sync_and_offload_channels()
# Ensure default channels exist
await ensure_default_channels()
return {
"contacts": contacts_result,
"channels": channels_result,
}
async def _periodic_sync_loop():
"""Background task that periodically syncs and offloads."""
while True:
try:
await asyncio.sleep(SYNC_INTERVAL)
logger.debug("Running periodic radio sync")
await sync_and_offload_all()
except asyncio.CancelledError:
logger.info("Periodic sync task cancelled")
break
except Exception as e:
logger.error("Error in periodic sync: %s", e)
def start_periodic_sync():
"""Start the periodic sync background task."""
global _sync_task
if _sync_task is None or _sync_task.done():
_sync_task = asyncio.create_task(_periodic_sync_loop())
logger.info("Started periodic radio sync (interval: %ds)", SYNC_INTERVAL)
def stop_periodic_sync():
"""Stop the periodic sync background task."""
global _sync_task
if _sync_task and not _sync_task.done():
_sync_task.cancel()
logger.info("Stopped periodic radio sync")
# Throttling for contact sync to radio
_last_contact_sync: float = 0.0
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
"""
Load recent non-repeater contacts to the radio for DM ACK support.
This ensures the radio can auto-ACK incoming DMs from recent contacts.
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
Returns counts of contacts loaded.
"""
global _last_contact_sync
# Throttle unless forced
now = time.time()
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
logger.debug("Contact sync throttled (last sync %ds ago)", int(now - _last_contact_sync))
return {"loaded": 0, "throttled": True}
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.debug("Cannot sync contacts to radio: not connected")
return {"loaded": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
_last_contact_sync = now
try:
# Get recent non-repeater contacts from database
max_contacts = settings.max_radio_contacts
contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
logger.debug("Found %d recent non-repeater contacts to sync", len(contacts))
loaded = 0
already_on_radio = 0
failed = 0
for contact in contacts:
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
already_on_radio += 1
# Update DB if not marked as on_radio
if not contact.on_radio:
await ContactRepository.set_on_radio(contact.public_key, True)
continue
try:
result = await mc.commands.add_contact(contact.to_radio_dict())
if result.type == EventType.OK:
loaded += 1
await ContactRepository.set_on_radio(contact.public_key, True)
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
else:
failed += 1
logger.warning(
"Failed to load contact %s: %s",
contact.public_key[:12], result.payload
)
except Exception as e:
failed += 1
logger.warning("Error loading contact %s: %s", contact.public_key[:12], e)
if loaded > 0 or failed > 0:
logger.info(
"Contact sync: loaded %d, already on radio %d, failed %d",
loaded, already_on_radio, failed
)
return {
"loaded": loaded,
"already_on_radio": already_on_radio,
"failed": failed,
}
except Exception as e:
logger.error("Error syncing contacts to radio: %s", e)
return {"loaded": 0, "error": str(e)}
+483
View File
@@ -0,0 +1,483 @@
import time
from typing import Any
from app.database import db
from app.models import Channel, Contact, Message, RawPacket
class ContactRepository:
@staticmethod
async def upsert(contact: dict[str, Any]) -> None:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
last_advert, lat, lon, last_seen, on_radio, last_contacted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = excluded.type,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon),
last_seen = excluded.last_seen,
on_radio = excluded.on_radio,
last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted)
""",
(
contact.get("public_key"),
contact.get("name") or contact.get("adv_name"),
contact.get("type", 0),
contact.get("flags", 0),
contact.get("last_path") or contact.get("out_path"),
contact.get("last_path_len") if "last_path_len" in contact else contact.get("out_path_len", -1),
contact.get("last_advert"),
contact.get("lat") or contact.get("adv_lat"),
contact.get("lon") or contact.get("adv_lon"),
contact.get("last_seen", int(time.time())),
contact.get("on_radio", False),
contact.get("last_contacted"),
),
)
await db.conn.commit()
@staticmethod
def _row_to_contact(row) -> Contact:
"""Convert a database row to a Contact model."""
return Contact(
public_key=row["public_key"],
name=row["name"],
type=row["type"],
flags=row["flags"],
last_path=row["last_path"],
last_path_len=row["last_path_len"],
last_advert=row["last_advert"],
lat=row["lat"],
lon=row["lon"],
last_seen=row["last_seen"],
on_radio=bool(row["on_radio"]),
last_contacted=row["last_contacted"],
)
@staticmethod
async def get_by_key(public_key: str) -> Contact | None:
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key = ?", (public_key,)
)
row = await cursor.fetchone()
return ContactRepository._row_to_contact(row) if row else None
@staticmethod
async def get_by_key_prefix(prefix: str) -> Contact | None:
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key LIKE ? LIMIT 1",
(f"{prefix}%",),
)
row = await cursor.fetchone()
return ContactRepository._row_to_contact(row) if row else None
@staticmethod
async def get_by_key_or_prefix(key_or_prefix: str) -> Contact | None:
"""Get a contact by exact key match, falling back to prefix match.
Useful when the input might be a full 64-char public key or a shorter prefix.
"""
contact = await ContactRepository.get_by_key(key_or_prefix)
if not contact:
contact = await ContactRepository.get_by_key_prefix(key_or_prefix)
return contact
@staticmethod
async def get_all(limit: int = 100, offset: int = 0) -> list[Contact]:
cursor = await db.conn.execute(
"SELECT * FROM contacts ORDER BY COALESCE(name, public_key) LIMIT ? OFFSET ?",
(limit, offset),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get the most recently active non-repeater contacts.
Orders by most recent activity (last_contacted or last_advert),
excluding repeaters (type=2).
"""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(public_key: str, path: str, path_len: int) -> None:
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key),
)
await db.conn.commit()
@staticmethod
async def set_on_radio(public_key: str, on_radio: bool) -> None:
await db.conn.execute(
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
(on_radio, public_key),
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
await db.conn.execute(
"DELETE FROM contacts WHERE public_key = ?",
(public_key,),
)
await db.conn.commit()
@staticmethod
async def update_last_contacted(public_key: str, timestamp: int | None = None) -> None:
"""Update the last_contacted timestamp for a contact."""
ts = timestamp or int(time.time())
await db.conn.execute(
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
(ts, ts, public_key),
)
await db.conn.commit()
@staticmethod
async def clear_all_on_radio() -> None:
"""Clear the on_radio flag for all contacts."""
await db.conn.execute("UPDATE contacts SET on_radio = 0")
await db.conn.commit()
@staticmethod
async def set_multiple_on_radio(public_keys: list[str], on_radio: bool = True) -> None:
"""Set on_radio flag for multiple contacts."""
if not public_keys:
return
placeholders = ",".join("?" * len(public_keys))
await db.conn.execute(
f"UPDATE contacts SET on_radio = ? WHERE public_key IN ({placeholders})",
[on_radio] + public_keys,
)
await db.conn.commit()
class ChannelRepository:
@staticmethod
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
"""Upsert a channel. Key is 32-char hex string."""
await db.conn.execute(
"""
INSERT INTO channels (key, name, is_hashtag, on_radio)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
name = excluded.name,
is_hashtag = excluded.is_hashtag,
on_radio = excluded.on_radio
""",
(key.upper(), name, is_hashtag, on_radio),
)
await db.conn.commit()
@staticmethod
async def get_by_key(key: str) -> Channel | None:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
(key.upper(),)
)
row = await cursor.fetchone()
if row:
return Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
)
return None
@staticmethod
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels ORDER BY name"
)
rows = await cursor.fetchall()
return [
Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
)
for row in rows
]
@staticmethod
async def get_by_name(name: str) -> Channel | None:
"""Get a channel by name."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE name = ?", (name,)
)
row = await cursor.fetchone()
if row:
return Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
)
return None
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
await db.conn.execute(
"DELETE FROM channels WHERE key = ?",
(key.upper(),),
)
await db.conn.commit()
class MessageRepository:
@staticmethod
async def create(
msg_type: str,
text: str,
received_at: int,
conversation_key: str,
sender_timestamp: int | None = None,
path_len: int | None = None,
txt_type: int = 0,
signature: str | None = None,
outgoing: bool = False,
) -> int | None:
"""Create a message, returning the ID or None if duplicate.
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
(type, conversation_key, text, sender_timestamp). This prevents
duplicate messages when the same message arrives via multiple RF paths.
"""
cursor = await db.conn.execute(
"""
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
received_at, path_len, txt_type, signature, outgoing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(msg_type, conversation_key, text, sender_timestamp, received_at,
path_len, txt_type, signature, outgoing),
)
await db.conn.commit()
# lastrowid is 0 if no row was inserted (duplicate)
return cursor.lastrowid if cursor.lastrowid else None
@staticmethod
async def get_all(
limit: int = 100,
offset: int = 0,
msg_type: str | None = None,
conversation_key: str | None = None,
) -> list[Message]:
query = "SELECT * FROM messages WHERE 1=1"
params: list[Any] = []
if msg_type:
query += " AND type = ?"
params.append(msg_type)
if conversation_key:
# Support both exact match and prefix match for DMs
query += " AND conversation_key LIKE ?"
params.append(f"{conversation_key}%")
query += " ORDER BY received_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor = await db.conn.execute(query, params)
rows = await cursor.fetchall()
return [
Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
path_len=row["path_len"],
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=bool(row["acked"]),
)
for row in rows
]
@staticmethod
async def mark_acked(message_id: int) -> None:
await db.conn.execute(
"UPDATE messages SET acked = 1 WHERE id = ?", (message_id,)
)
await db.conn.commit()
@staticmethod
async def find_duplicate(
conversation_key: str,
text: str,
sender_timestamp: int | None,
) -> int | None:
"""Find existing message with same content (for deduplication).
Returns message ID if found, None otherwise.
Used to detect the same message arriving via multiple RF paths.
"""
if sender_timestamp is None:
return None
cursor = await db.conn.execute(
"""
SELECT id FROM messages
WHERE conversation_key = ?
AND text = ?
AND sender_timestamp = ?
LIMIT 1
""",
(conversation_key, text, sender_timestamp),
)
row = await cursor.fetchone()
return row["id"] if row else None
@staticmethod
async def get_bulk(
conversations: list[dict],
limit_per_conversation: int = 100,
) -> dict[str, list["Message"]]:
"""Fetch messages for multiple conversations in one query per conversation.
Args:
conversations: List of {type: 'PRIV'|'CHAN', conversation_key: string}
limit_per_conversation: Max messages to return per conversation
Returns:
Dict mapping 'type:conversation_key' to list of messages
"""
result: dict[str, list[Message]] = {}
for conv in conversations:
msg_type = conv.get("type")
conv_key = conv.get("conversation_key")
if not msg_type or not conv_key:
continue
key = f"{msg_type}:{conv_key}"
cursor = await db.conn.execute(
"""
SELECT * FROM messages
WHERE type = ? AND conversation_key LIKE ?
ORDER BY received_at DESC
LIMIT ?
""",
(msg_type, f"{conv_key}%", limit_per_conversation),
)
rows = await cursor.fetchall()
result[key] = [
Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
path_len=row["path_len"],
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=bool(row["acked"]),
)
for row in rows
]
return result
class RawPacketRepository:
@staticmethod
async def create(data: bytes, timestamp: int | None = None) -> int:
"""Create a raw packet. Always stores (no deduplication at this level)."""
ts = timestamp or int(time.time())
cursor = await db.conn.execute(
"INSERT INTO raw_packets (timestamp, data) VALUES (?, ?)",
(ts, data),
)
await db.conn.commit()
return cursor.lastrowid or 0
@staticmethod
async def get_undecrypted_count() -> int:
"""Get count of undecrypted packets."""
cursor = await db.conn.execute(
"SELECT COUNT(*) as count FROM raw_packets WHERE decrypted = 0"
)
row = await cursor.fetchone()
return row["count"] if row else 0
@staticmethod
async def get_all_undecrypted() -> list[tuple[int, bytes]]:
"""Get all undecrypted packets as (id, data) tuples."""
cursor = await db.conn.execute(
"SELECT id, data FROM raw_packets WHERE decrypted = 0 ORDER BY timestamp ASC"
)
rows = await cursor.fetchall()
return [(row["id"], bytes(row["data"])) for row in rows]
@staticmethod
async def mark_decrypted(packet_id: int, message_id: int) -> None:
await db.conn.execute(
"UPDATE raw_packets SET decrypted = 1, message_id = ? WHERE id = ?",
(message_id, packet_id),
)
await db.conn.commit()
@staticmethod
async def get_undecrypted(limit: int = 100) -> list[RawPacket]:
cursor = await db.conn.execute(
"""
SELECT * FROM raw_packets
WHERE decrypted = 0
ORDER BY timestamp DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [
RawPacket(
id=row["id"],
timestamp=row["timestamp"],
data=row["data"].hex(),
decrypted=bool(row["decrypted"]),
message_id=row["message_id"],
decrypt_attempts=row["decrypt_attempts"],
last_attempt=row["last_attempt"],
)
for row in rows
]
@staticmethod
async def increment_attempts(packet_id: int) -> None:
await db.conn.execute(
"""
UPDATE raw_packets
SET decrypt_attempts = decrypt_attempts + 1, last_attempt = ?
WHERE id = ?
""",
(int(time.time()), packet_id),
)
await db.conn.commit()
View File
+130
View File
@@ -0,0 +1,130 @@
import logging
from hashlib import sha256
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import Channel
from app.repository import ChannelRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
class CreateChannelRequest(BaseModel):
name: str = Field(min_length=1, max_length=32)
key: str | None = Field(
default=None,
description="Channel key as hex string (32 chars = 16 bytes). If omitted or name starts with #, key is derived from name hash."
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
return await ChannelRepository.get_all()
@router.get("/{key}", response_model=Channel)
async def get_channel(key: str) -> Channel:
"""Get a specific channel by key (32-char hex string)."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
return channel
@router.post("", response_model=Channel)
async def create_channel(request: CreateChannelRequest) -> Channel:
"""Create a channel in the database.
Channels are NOT pushed to radio on creation. They are loaded to the radio
automatically when sending a message (see messages.py send_channel_message).
"""
is_hashtag = request.name.startswith("#")
# Determine the channel secret
if request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key")
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(request.name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, request.name, is_hashtag)
# Store in database only - radio sync happens at send time
await ChannelRepository.upsert(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
)
return Channel(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
)
@router.post("/sync")
async def sync_channels_from_radio(
max_channels: int = Query(default=40, ge=1, le=40)
) -> dict:
"""Sync channels from the radio to the database."""
mc = require_connected()
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
count = 0
for idx in range(max_channels):
result = await mc.commands.get_channel(idx)
if result.type == EventType.CHANNEL_INFO:
payload = result.payload
name = payload.get("channel_name", "")
secret = payload.get("channel_secret", b"")
# Skip empty channels
if not name or name == "\x00" * len(name):
continue
is_hashtag = name.startswith("#")
key_bytes = secret if isinstance(secret, bytes) else bytes(secret)
key_hex = key_bytes.hex().upper()
await ChannelRepository.upsert(
key=key_hex,
name=name,
is_hashtag=is_hashtag,
on_radio=True,
)
count += 1
logger.debug("Synced channel %s: %s", key_hex, name)
logger.info("Synced %d channels from radio", count)
return {"synced": count}
@router.delete("/{key}")
async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key.
Note: This does not clear the channel from the radio. The radio's channel
slots are managed separately (channels are loaded temporarily when sending).
"""
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)
return {"status": "ok"}
+138
View File
@@ -0,0 +1,138 @@
import logging
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.models import Contact
from app.radio import radio_manager
from app.repository import ContactRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contacts", tags=["contacts"])
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> list[Contact]:
"""List contacts from the database."""
return await ContactRepository.get_all(limit=limit, offset=offset)
@router.get("/{public_key}", response_model=Contact)
async def get_contact(public_key: str) -> Contact:
"""Get a specific contact by public key or prefix."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
return contact
@router.post("/sync")
async def sync_contacts_from_radio() -> dict:
"""Sync contacts from the radio to the database."""
mc = require_connected()
logger.info("Syncing contacts from radio")
result = await mc.commands.get_contacts()
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to get contacts: {result.payload}"
)
contacts = result.payload
count = 0
for public_key, contact_data in contacts.items():
await ContactRepository.upsert(
Contact.from_radio_dict(public_key, contact_data, on_radio=True)
)
count += 1
logger.info("Synced %d contacts from radio", count)
return {"synced": count}
@router.post("/{public_key}/remove-from-radio")
async def remove_contact_from_radio(public_key: str) -> dict:
"""Remove a contact from the radio (keeps it in database)."""
mc = require_connected()
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Get the contact from radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not radio_contact:
# Already not on radio
await ContactRepository.set_on_radio(contact.public_key, False)
return {"status": "ok", "message": "Contact was not on radio"}
logger.info("Removing contact %s from radio", contact.public_key[:12])
result = await mc.commands.remove_contact(radio_contact)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to remove contact: {result.payload}"
)
await ContactRepository.set_on_radio(contact.public_key, False)
return {"status": "ok"}
@router.post("/{public_key}/add-to-radio")
async def add_contact_to_radio(public_key: str) -> dict:
"""Add a contact from the database to the radio."""
mc = require_connected()
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found in database")
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
return {"status": "ok", "message": "Contact already on radio"}
logger.info("Adding contact %s to radio", contact.public_key[:12])
result = await mc.commands.add_contact(contact.to_radio_dict())
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to add contact: {result.payload}"
)
await ContactRepository.set_on_radio(contact.public_key, True)
return {"status": "ok"}
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Remove from radio if connected and contact is on radio
if radio_manager.is_connected and radio_manager.meshcore:
mc = radio_manager.meshcore
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
logger.info("Removing contact %s from radio before deletion", contact.public_key[:12])
await mc.commands.remove_contact(radio_contact)
# Delete from database
await ContactRepository.delete(contact.public_key)
logger.info("Deleted contact %s", contact.public_key[:12])
return {"status": "ok"}
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter
from pydantic import BaseModel
from app.radio import radio_manager
router = APIRouter(tags=["health"])
class HealthResponse(BaseModel):
status: str
radio_connected: bool
serial_port: str | None
@router.get("/health", response_model=HealthResponse)
async def healthcheck() -> HealthResponse:
"""Check if the API is running and if the radio is connected."""
return HealthResponse(
status="ok" if radio_manager.is_connected else "degraded",
radio_connected=radio_manager.is_connected,
serial_port=radio_manager.port,
)
+206
View File
@@ -0,0 +1,206 @@
import logging
import time
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.event_handlers import track_pending_ack, track_pending_repeat
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
from app.repository import MessageRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["messages"])
@router.get("", response_model=list[Message])
async def list_messages(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
conversation_key: str | None = Query(default=None, description="Filter by conversation key (channel key or contact pubkey)"),
) -> list[Message]:
"""List messages from the database."""
return await MessageRepository.get_all(
limit=limit,
offset=offset,
msg_type=type,
conversation_key=conversation_key,
)
@router.post("/bulk", response_model=dict[str, list[Message]])
async def get_messages_bulk(
conversations: list[dict],
limit_per_conversation: int = Query(default=100, ge=1, le=1000),
) -> dict[str, list[Message]]:
"""Fetch messages for multiple conversations in one request.
Body should be a list of {type: 'PRIV'|'CHAN', conversation_key: string}.
Returns a dict mapping 'type:conversation_key' to list of messages.
"""
return await MessageRepository.get_bulk(conversations, limit_per_conversation)
@router.post("/direct", response_model=Message)
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
"""Send a direct message to a contact."""
mc = require_connected()
# First check our database for the contact
from app.repository import ContactRepository
db_contact = await ContactRepository.get_by_key_or_prefix(request.destination)
if not db_contact:
raise HTTPException(
status_code=404,
detail=f"Contact not found in database: {request.destination}"
)
# Check if contact is on radio, if not add it
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
if not contact:
logger.info("Adding contact %s to radio before sending", db_contact.public_key[:12])
contact_data = db_contact.to_radio_dict()
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
# Continue anyway - might still work
# Get the contact from radio again
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
if not contact:
# Use the contact_data we built as fallback
contact = contact_data
logger.info("Sending direct message to %s", db_contact.public_key[:12])
result = await mc.commands.send_msg(
dst=contact,
msg=request.text,
)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send message: {result.payload}"
)
# Store outgoing message
now = int(time.time())
message_id = await MessageRepository.create(
msg_type="PRIV",
text=request.text,
conversation_key=db_contact.public_key,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
# Update last_contacted for the contact
await ContactRepository.update_last_contacted(db_contact.public_key, now)
# Track the expected ACK for this message
expected_ack = result.payload.get("expected_ack")
suggested_timeout = result.payload.get("suggested_timeout", 10000) # default 10s
if expected_ack:
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
track_pending_ack(ack_code, message_id, suggested_timeout)
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
return Message(
id=message_id,
type="PRIV",
conversation_key=db_contact.public_key,
text=request.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=False,
)
# Temporary radio slot used for sending channel messages
TEMP_RADIO_SLOT = 0
@router.post("/channel", response_model=Message)
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
"""Send a message to a channel."""
mc = require_connected()
# Get channel info from our database
from app.repository import ChannelRepository
from app.decoder import calculate_channel_hash
db_channel = await ChannelRepository.get_by_key(request.channel_key)
if not db_channel:
raise HTTPException(
status_code=404,
detail=f"Channel {request.channel_key} not found in database"
)
# Convert channel key hex to bytes
try:
key_bytes = bytes.fromhex(request.channel_key)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid channel key format: {request.channel_key}"
)
expected_hash = calculate_channel_hash(key_bytes)
logger.info(
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
request.channel_key, db_channel.name, TEMP_RADIO_SLOT, expected_hash
)
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
logger.warning(
"Failed to set channel on radio slot %d before sending: %s",
TEMP_RADIO_SLOT, set_result.payload
)
# Continue anyway - the channel might already be correctly configured
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send message: {result.payload}"
)
# Store outgoing message
now = int(time.time())
channel_key_upper = request.channel_key.upper()
message_id = await MessageRepository.create(
msg_type="CHAN",
text=request.text,
conversation_key=channel_key_upper,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
# Track for repeat detection (flood messages get confirmed by hearing repeats)
track_pending_repeat(channel_key_upper, request.text, now, message_id)
return Message(
id=message_id,
type="CHAN",
conversation_key=channel_key_upper,
text=request.text,
sender_timestamp=now,
received_at=now,
outgoing=True,
acked=False,
)
+175
View File
@@ -0,0 +1,175 @@
import logging
from hashlib import sha256
from fastapi import APIRouter, BackgroundTasks
from pydantic import BaseModel, Field
from app.decoder import try_decrypt_packet_with_channel_key
from app.packet_processor import create_message_from_decrypted
from app.repository import RawPacketRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/packets", tags=["packets"])
class DecryptRequest(BaseModel):
key_type: str = Field(description="Type of key: 'channel' or 'contact'")
channel_key: str | None = Field(default=None, description="Channel key as hex (16 bytes = 32 chars)")
channel_name: str | None = Field(default=None, description="Channel name (for hashtag channels, key derived from name)")
class DecryptResult(BaseModel):
started: bool
total_packets: int
message: str
class DecryptProgress(BaseModel):
total: int
processed: int
decrypted: int
in_progress: bool
# Global state for tracking decryption progress
_decrypt_progress: DecryptProgress | None = None
async def _run_historical_decryption(channel_key_bytes: bytes, channel_key_hex: str) -> None:
"""Background task to decrypt historical packets with a channel key."""
global _decrypt_progress
packets = await RawPacketRepository.get_all_undecrypted()
total = len(packets)
processed = 0
decrypted_count = 0
_decrypt_progress = DecryptProgress(
total=total, processed=0, decrypted=0, in_progress=True
)
logger.info("Starting historical decryption of %d packets", total)
for packet_id, packet_data in packets:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is not None:
# Successfully decrypted - use shared logic to store message
logger.debug(
"Decrypted packet %d: sender=%s, message=%s",
packet_id,
result.sender,
result.message[:50] if result.message else "",
)
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
)
if msg_id is not None:
decrypted_count += 1
processed += 1
_decrypt_progress = DecryptProgress(
total=total, processed=processed, decrypted=decrypted_count, in_progress=True
)
_decrypt_progress = DecryptProgress(
total=total, processed=processed, decrypted=decrypted_count, in_progress=False
)
logger.info(
"Historical decryption complete: %d/%d packets decrypted", decrypted_count, total
)
@router.get("/undecrypted/count")
async def get_undecrypted_count() -> dict:
"""Get the count of undecrypted packets."""
count = await RawPacketRepository.get_undecrypted_count()
return {"count": count}
@router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks
) -> DecryptResult:
"""
Attempt to decrypt historical packets with the provided key.
Runs in the background to avoid blocking.
"""
global _decrypt_progress
# Check if decryption is already in progress
if _decrypt_progress and _decrypt_progress.in_progress:
return DecryptResult(
started=False,
total_packets=_decrypt_progress.total,
message=f"Decryption already in progress: {_decrypt_progress.processed}/{_decrypt_progress.total}",
)
# Determine the channel key
channel_key_bytes: bytes | None = None
channel_key_hex: str | None = None
if request.key_type == "channel":
if request.channel_key:
# Direct key provided
try:
channel_key_bytes = bytes.fromhex(request.channel_key)
if len(channel_key_bytes) != 16:
return DecryptResult(
started=False,
total_packets=0,
message="Channel key must be 16 bytes (32 hex chars)",
)
channel_key_hex = request.channel_key.upper()
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for channel key",
)
elif request.channel_name:
# Derive key from channel name (hashtag channel)
channel_key_bytes = sha256(request.channel_name.encode("utf-8")).digest()[:16]
channel_key_hex = channel_key_bytes.hex().upper()
else:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide channel_key or channel_name",
)
else:
# Contact decryption not yet supported (requires Ed25519 shared secret)
return DecryptResult(
started=False,
total_packets=0,
message="Contact key decryption not yet supported",
)
# Get count of undecrypted packets
count = await RawPacketRepository.get_undecrypted_count()
if count == 0:
return DecryptResult(
started=False, total_packets=0, message="No undecrypted packets to process"
)
# Start background decryption
background_tasks.add_task(_run_historical_decryption, channel_key_bytes, channel_key_hex)
return DecryptResult(
started=True,
total_packets=count,
message=f"Started decryption of {count} packets in background",
)
@router.get("/decrypt/progress", response_model=DecryptProgress | None)
async def get_decrypt_progress() -> DecryptProgress | None:
"""Get the current progress of historical decryption."""
return _decrypt_progress
+188
View File
@@ -0,0 +1,188 @@
import logging
import time
from fastapi import APIRouter, HTTPException
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
class RadioSettings(BaseModel):
freq: float = Field(description="Frequency in MHz")
bw: float = Field(description="Bandwidth in kHz")
sf: int = Field(description="Spreading factor (7-12)")
cr: int = Field(description="Coding rate (1-4)")
class RadioConfigResponse(BaseModel):
public_key: str = Field(description="Public key (64-char hex)")
name: str
lat: float
lon: float
tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum transmit power in dBm")
radio: RadioSettings
class RadioConfigUpdate(BaseModel):
name: str | None = None
lat: float | None = None
lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
radio: RadioSettings | None = None
class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
mc = require_connected()
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
lat=info.get("adv_lat", 0.0),
lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_tx_power", 0),
radio=RadioSettings(
freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 0.0),
sf=info.get("radio_sf", 0),
cr=info.get("radio_cr", 0),
),
)
@router.patch("/config", response_model=RadioConfigResponse)
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
"""Update radio configuration. Only provided fields will be updated."""
mc = require_connected()
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)
if update.lat is not None or update.lon is not None:
current_info = mc.self_info
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
logger.info("Setting radio coordinates to %f, %f", lat, lon)
await mc.commands.set_coords(lat=lat, lon=lon)
if update.tx_power is not None:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
update.radio.freq,
update.radio.bw,
update.radio.sf,
update.radio.cr,
)
await mc.commands.set_radio(
freq=update.radio.freq,
bw=update.radio.bw,
sf=update.radio.sf,
cr=update.radio.cr,
)
# Sync time with system clock
now = int(time.time())
logger.debug("Syncing radio time to %d", now)
await mc.commands.set_time(now)
return await get_radio_config()
@router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only."""
mc = require_connected()
try:
key_bytes = bytes.fromhex(update.private_key)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for private key")
logger.info("Importing private key")
result = await mc.commands.import_private_key(key_bytes)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to import private key: {result.payload}")
return {"status": "ok"}
@router.post("/advertise")
async def send_advertisement(flood: bool = True) -> dict:
"""Send a radio advertisement to announce presence on the mesh."""
mc = require_connected()
logger.info("Sending advertisement (flood=%s)", flood)
result = await mc.commands.send_advert(flood=flood)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send advertisement: {result.payload}")
return {"status": "ok", "flood": flood}
@router.post("/reboot")
async def reboot_radio() -> dict:
"""Reboot the radio. Connection will temporarily drop and auto-reconnect."""
mc = require_connected()
logger.info("Rebooting radio")
await mc.commands.reboot()
return {"status": "ok", "message": "Reboot command sent. Radio will reconnect automatically."}
@router.post("/reconnect")
async def reconnect_radio() -> dict:
"""Attempt to reconnect to the radio.
This will try to re-establish connection to the radio, with auto-detection
if no specific port is configured. Useful when the radio has been disconnected
or power-cycled.
"""
from app.radio import radio_manager
if radio_manager.is_connected:
return {"status": "ok", "message": "Already connected", "connected": True}
if radio_manager.is_reconnecting:
return {"status": "pending", "message": "Reconnection already in progress", "connected": False}
logger.info("Manual reconnect requested")
success = await radio_manager.reconnect()
if success:
# Re-register event handlers after successful reconnect
from app.event_handlers import register_event_handlers
if radio_manager.meshcore:
register_event_handlers(radio_manager.meshcore)
# Restart auto message fetching
await radio_manager.meshcore.start_auto_message_fetching()
logger.info("Event handlers re-registered and auto message fetching started")
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
else:
raise HTTPException(
status_code=503,
detail="Failed to reconnect. Check radio connection and power."
)
+48
View File
@@ -0,0 +1,48 @@
import logging
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
class AppSettingsResponse(BaseModel):
max_radio_contacts: int = Field(description="Maximum non-repeater contacts to keep on radio for DM ACKs")
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
ge=1,
le=1000,
description="Maximum non-repeater contacts to keep on radio (1-1000)"
)
@router.get("", response_model=AppSettingsResponse)
async def get_settings() -> AppSettingsResponse:
"""Get current application settings."""
return AppSettingsResponse(
max_radio_contacts=settings.max_radio_contacts,
)
@router.patch("", response_model=AppSettingsResponse)
async def update_settings(update: AppSettingsUpdate) -> AppSettingsResponse:
"""Update application settings.
Note: Changes are applied immediately but not persisted across restarts.
Set MESHCORE_MAX_RADIO_CONTACTS environment variable for persistent changes.
"""
if update.max_radio_contacts is not None:
logger.info("Updating max_radio_contacts from %d to %d",
settings.max_radio_contacts, update.max_radio_contacts)
# Pydantic settings are mutable, we can update them directly
object.__setattr__(settings, 'max_radio_contacts', update.max_radio_contacts)
return AppSettingsResponse(
max_radio_contacts=settings.max_radio_contacts,
)
+61
View File
@@ -0,0 +1,61 @@
"""WebSocket router for real-time updates."""
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository
from app.websocket import ws_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
"""WebSocket endpoint for real-time updates."""
await ws_manager.connect(websocket)
# Send initial state
try:
# Health status
health_data = {
"radio_connected": radio_manager.is_connected,
"serial_port": radio_manager.port,
}
await ws_manager.send_personal(websocket, "health", health_data)
# Contacts
contacts = await ContactRepository.get_all(limit=500)
await ws_manager.send_personal(
websocket,
"contacts",
[c.model_dump() for c in contacts],
)
# Channels
channels = await ChannelRepository.get_all()
await ws_manager.send_personal(
websocket,
"channels",
[c.model_dump() for c in channels],
)
except Exception as e:
logger.error("Error sending initial state: %s", e)
# Keep connection alive and handle incoming messages
try:
while True:
# We don't expect messages from client, but need to keep connection open
# and handle pings/pongs
data = await websocket.receive_text()
# Client can send "ping" to keep alive
if data == "ping":
await websocket.send_text('{"type":"pong"}')
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
except Exception as e:
logger.debug("WebSocket error: %s", e)
await ws_manager.disconnect(websocket)
+92
View File
@@ -0,0 +1,92 @@
"""WebSocket manager for real-time updates."""
import asyncio
import json
import logging
from typing import Any
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class WebSocketManager:
"""Manages WebSocket connections and broadcasts events."""
def __init__(self):
self.active_connections: list[WebSocket] = []
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
async with self._lock:
self.active_connections.append(websocket)
logger.info("WebSocket client connected (%d total)", len(self.active_connections))
async def disconnect(self, websocket: WebSocket) -> None:
async with self._lock:
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info("WebSocket client disconnected (%d remaining)", len(self.active_connections))
async def broadcast(self, event_type: str, data: Any) -> None:
"""Broadcast an event to all connected clients."""
if not self.active_connections:
return
message = json.dumps({"type": event_type, "data": data})
async with self._lock:
disconnected = []
for connection in self.active_connections:
try:
await connection.send_text(message)
except Exception as e:
logger.debug("Failed to send to client: %s", e)
disconnected.append(connection)
# Clean up disconnected clients
for conn in disconnected:
if conn in self.active_connections:
self.active_connections.remove(conn)
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
"""Send an event to a specific client."""
message = json.dumps({"type": event_type, "data": data})
try:
await websocket.send_text(message)
except Exception as e:
logger.debug("Failed to send to client: %s", e)
# Global instance
ws_manager = WebSocketManager()
def broadcast_event(event_type: str, data: dict) -> None:
"""Schedule a broadcast without blocking.
Convenience function that creates an asyncio task to broadcast
an event to all connected WebSocket clients.
"""
asyncio.create_task(ws_manager.broadcast(event_type, data))
def broadcast_error(message: str, details: str | None = None) -> None:
"""Broadcast an error notification to all connected clients.
This appears as a toast notification in the frontend.
"""
data = {"message": message}
if details:
data["details"] = details
asyncio.create_task(ws_manager.broadcast("error", data))
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
"""Broadcast health status change to all connected clients."""
asyncio.create_task(ws_manager.broadcast("health", {
"status": "ok" if radio_connected else "degraded",
"radio_connected": radio_connected,
"serial_port": serial_port,
}))
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+497
View File
@@ -0,0 +1,497 @@
# Frontend CLAUDE.md
This document provides context for AI assistants and developers working on the React frontend.
## Technology Stack
- **React 18** - UI framework with hooks
- **TypeScript** - Type safety
- **Vite** - Build tool with HMR
- **Vitest** - Testing framework
- **Sonner** - Toast notifications
- **shadcn/ui components** - Sheet, Tabs, Button (in `components/ui/`)
- **meshcore-cracker** - WebGPU-accelerated channel key bruteforcing
## Directory Structure
```
frontend/
├── src/
│ ├── main.tsx # Entry point, renders App
│ ├── App.tsx # Main component, all state management
│ ├── api.ts # REST API client
│ ├── types.ts # TypeScript interfaces
│ ├── useWebSocket.ts # WebSocket hook with auto-reconnect
│ ├── styles.css # Dark theme CSS
│ ├── utils/
│ │ ├── messageParser.ts # Text parsing utilities
│ │ ├── conversationState.ts # localStorage for unread tracking
│ │ ├── pubkey.ts # Public key utilities (prefix matching, display names)
│ │ └── contactAvatar.ts # Avatar generation (colors, initials/emoji)
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ │ ├── sonner.tsx # Toast notifications (Sonner wrapper)
│ │ │ ├── sheet.tsx # Slide-out panel
│ │ │ ├── tabs.tsx # Tab navigation
│ │ │ └── button.tsx # Button component
│ │ ├── StatusBar.tsx # Radio status, reconnect button, config button
│ │ ├── Sidebar.tsx # Contacts/channels list, search, unread badges
│ │ ├── MessageList.tsx # Message display, avatars, clickable senders
│ │ ├── MessageInput.tsx # Text input with imperative handle
│ │ ├── ContactAvatar.tsx # Contact profile image component
│ │ ├── RawPacketList.tsx # Raw packet feed display
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker
│ │ ├── NewMessageModal.tsx
│ │ └── ConfigModal.tsx # Radio config + app settings
│ └── test/
│ ├── setup.ts # Test setup (jsdom, matchers)
│ ├── messageParser.test.ts
│ ├── unreadCounts.test.ts
│ ├── contactAvatar.test.ts
│ ├── messageDeduplication.test.ts
│ └── websocket.test.ts
├── index.html
├── vite.config.ts # API proxy config
├── tsconfig.json
└── package.json
```
## State Management
All application state lives in `App.tsx` using React hooks. No external state library.
### Core State
```typescript
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [contacts, setContacts] = useState<Contact[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
```
### State Flow
1. **WebSocket** pushes real-time updates (health, contacts, channels, messages)
2. **REST API** fetches initial data and handles user actions
3. **Components** receive state as props, call handlers to trigger changes
## WebSocket (`useWebSocket.ts`)
The `useWebSocket` hook manages real-time connection:
```typescript
const wsHandlers = useMemo(() => ({
onHealth: (data: HealthStatus) => setHealth(data),
onMessage: (msg: Message) => { /* add to list, track unread */ },
onMessageAcked: (messageId: number) => { /* update acked status */ },
// ...
}), []);
useWebSocket(wsHandlers);
```
### Features
- **Auto-reconnect**: Reconnects after 3 seconds on disconnect
- **Heartbeat**: Sends ping every 30 seconds
- **Event types**: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
- **Error handling**: `onError` handler displays toast notifications for backend errors
### URL Detection
```typescript
const isDev = window.location.port === '5173';
const wsUrl = isDev
? 'ws://localhost:8000/api/ws'
: `${protocol}//${window.location.host}/api/ws`;
```
## API Client (`api.ts`)
Typed REST client with consistent error handling:
```typescript
import { api } from './api';
// Health
await api.getHealth();
// Radio
await api.getRadioConfig();
await api.updateRadioConfig({ name: 'MyRadio' });
await api.sendAdvertisement(true);
// Contacts/Channels
await api.getContacts();
await api.getChannels();
await api.createChannel('#test');
// Messages
await api.getMessages({ type: 'CHAN', conversation_key: channelKey, limit: 200 });
await api.sendChannelMessage(channelKey, 'Hello');
await api.sendDirectMessage(publicKey, 'Hello');
// Historical decryption
await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test' });
// Radio reconnection
await api.reconnectRadio(); // Returns { status, message, connected }
```
### API Proxy (Development)
Vite proxies `/api/*` to backend (backend routes are already prefixed with `/api`):
```typescript
// vite.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
}
```
## Type Definitions (`types.ts`)
### Key Type Aliases
```typescript
type PublicKey = string; // 64-char hex identifying a contact/node
type PubkeyPrefix = string; // 12-char hex prefix (used in message routing)
type ChannelKey = string; // 32-char hex identifying a channel
```
### Key Interfaces
```typescript
interface Contact {
public_key: PublicKey;
name: string | null;
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
on_radio: boolean;
// ...
}
interface Channel {
key: ChannelKey;
name: string;
is_hashtag: boolean;
on_radio: boolean;
}
interface Message {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string; // PublicKey for PRIV, ChannelKey for CHAN
text: string;
outgoing: boolean;
acked: boolean;
// ...
}
interface Conversation {
type: 'contact' | 'channel' | 'raw';
id: string; // PublicKey for contacts, ChannelKey for channels
name: string;
}
interface AppSettings {
max_radio_contacts: number;
}
```
## Component Patterns
### MessageInput with Imperative Handle
Exposes `appendText` method for click-to-mention:
```typescript
export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled }, ref) {
useImperativeHandle(ref, () => ({
appendText: (text: string) => {
setText((prev) => prev + text);
inputRef.current?.focus();
},
}));
// ...
}
);
// Usage in App.tsx
const messageInputRef = useRef<MessageInputHandle>(null);
messageInputRef.current?.appendText(`@[${sender}] `);
```
### Unread Count Tracking
Uses refs to avoid stale closures in memoized handlers:
```typescript
const activeConversationRef = useRef<Conversation | null>(null);
// Keep ref in sync
useEffect(() => {
activeConversationRef.current = activeConversation;
}, [activeConversation]);
// In WebSocket handler (can safely access current value)
const activeConv = activeConversationRef.current;
```
### State Tracking Keys
State tracking keys (for unread counts and message times) are generated by `getStateKey()`:
```typescript
import { getStateKey } from './utils/conversationState';
// Channels: "channel-{channelKey}"
getStateKey('channel', channelKey) // e.g., "channel-8B3387E9C5CDEA6AC9E5EDBAA115CD72"
// Contacts: "contact-{12-char-prefix}"
getStateKey('contact', publicKey) // e.g., "contact-abc123def456"
```
**Note:** `getStateKey()` is NOT the same as `Message.conversation_key`. The state key is prefixed
for localStorage tracking, while `conversation_key` is the raw database field.
## Utility Functions
### Message Parser (`utils/messageParser.ts`)
```typescript
// Parse "sender: message" format from channel messages
parseSenderFromText(text: string): { sender: string | null; content: string }
// Format Unix timestamp to time string
formatTime(timestamp: number): string
```
### Public Key Utilities (`utils/pubkey.ts`)
Consistent handling of 64-char full keys and 12-char prefixes:
```typescript
import { getPubkeyPrefix, pubkeysMatch, getContactDisplayName } from './utils/pubkey';
// Extract 12-char prefix (works with full keys or existing prefixes)
getPubkeyPrefix(key) // "abc123def456..."
// Compare keys by prefix (handles mixed full/prefix comparisons)
pubkeysMatch(key1, key2) // true if prefixes match
// Get display name with fallback to prefix
getContactDisplayName(name, publicKey) // name or first 12 chars of key
```
### Conversation State (`utils/conversationState.ts`)
```typescript
import { getStateKey, setLastMessageTime, setLastReadTime } from './utils/conversationState';
// Generate state tracking key (NOT the same as Message.conversation_key)
getStateKey('channel', channelKey)
getStateKey('contact', publicKey)
// Track message times for unread detection
setLastMessageTime(stateKey, timestamp)
setLastReadTime(stateKey, timestamp)
```
### Contact Avatar (`utils/contactAvatar.ts`)
Generates consistent profile "images" for contacts using hash-based colors:
```typescript
import { getContactAvatar, CONTACT_TYPE_REPEATER } from './utils/contactAvatar';
// Get avatar info for a contact
const avatar = getContactAvatar(name, publicKey, contactType);
// Returns: { text: 'JD', background: 'hsl(180, 60%, 40%)', textColor: '#ffffff' }
// Repeaters (type=2) always show 🛜 with gray background
const repeaterAvatar = getContactAvatar('Some Repeater', key, CONTACT_TYPE_REPEATER);
// Returns: { text: '🛜', background: '#444444', textColor: '#ffffff' }
```
Avatar text priority:
1. First emoji in name
2. Initials (first letter + first letter after space)
3. Single first letter
4. First 2 chars of public key (fallback)
## CSS Patterns
The app uses a minimal dark theme in `styles.css`.
### Key Classes
```css
.app /* Root container */
.status-bar /* Top bar with radio info */
.sidebar /* Left panel with contacts/channels */
.sidebar-item /* Individual contact/channel row */
.sidebar-item.unread /* Bold with badge */
.message-area /* Main content area */
.message-list /* Scrollable message container */
.message /* Individual message */
.message.outgoing /* Right-aligned, different color */
.message .sender /* Clickable sender name */
```
### Unread Badge
```css
.sidebar-item.unread .name {
font-weight: 700;
color: #fff;
}
.sidebar-item .unread-badge {
background: #4caf50;
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
}
```
## Testing
Run tests with:
```bash
cd frontend
npm run test:run # Single run
npm run test # Watch mode
```
### Test Files
- `messageParser.test.ts` - Sender extraction, time formatting, conversation keys
- `unreadCounts.test.ts` - Unread tracking logic
- `contactAvatar.test.ts` - Avatar text extraction, color generation, repeater handling
- `messageDeduplication.test.ts` - Message deduplication logic
- `websocket.test.ts` - WebSocket message routing
### Test Setup
Tests use jsdom environment with `@testing-library/react`:
```typescript
// src/test/setup.ts
import '@testing-library/jest-dom';
```
## Common Tasks
### Adding a New Component
1. Create component in `src/components/`
2. Add TypeScript props interface
3. Import and use in `App.tsx` or parent component
4. Add styles to `styles.css`
### Adding a New API Endpoint
1. Add method to `api.ts`
2. Add/update types in `types.ts`
3. Call from `App.tsx` or component
### Adding New WebSocket Event
1. Add handler option to `UseWebSocketOptions` interface in `useWebSocket.ts`
2. Add case to `onmessage` switch
3. Provide handler in `wsHandlers` object in `App.tsx`
### Adding State
1. Add `useState` in `App.tsx`
2. Pass down as props to components
3. If needed in WebSocket handler, also use a ref to avoid stale closures
## Development Workflow
```bash
# Start dev server (hot reload)
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Run tests
npm run test:run
```
The dev server runs on port 5173 and proxies API requests to `localhost:8000`.
### Production Build
In production, the FastAPI backend serves the compiled frontend from `frontend/dist`:
```bash
npm run build
# Then run backend: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
## CrackerPanel
The `CrackerPanel` component provides WebGPU-accelerated brute-forcing of channel keys for undecrypted GROUP_TEXT packets.
### Features
- **Dictionary attack first**: Uses `words_alpha.txt` wordlist
- **GPU bruteforce**: Falls back to character-by-character search
- **Queue management**: Automatically processes new packets as they arrive
- **Auto-channel creation**: Cracked channels are automatically added to the channel list
- **Configurable max length**: Adjustable while running (default: 6)
- **Retry failed**: Option to retry failed packets at increasing lengths
### Key Implementation Patterns
Uses refs to avoid stale closures in async callbacks:
```typescript
const isRunningRef = useRef(false);
const isProcessingRef = useRef(false); // Prevents concurrent GPU operations
const queueRef = useRef<Map<number, QueueItem>>(new Map());
const retryFailedRef = useRef(false);
const maxLengthRef = useRef(6);
```
Progress reporting shows rate in Mkeys/s or Gkeys/s depending on speed.
## Toast Notifications
The app uses Sonner for toast notifications via a custom wrapper at `components/ui/sonner.tsx`:
```typescript
import { toast } from './components/ui/sonner';
// Success toast
toast.success('Operation completed', { description: 'Details here' });
// Error toast (muted red styling for readability)
toast.error('Operation failed', { description: 'Error details' });
```
Toasts are automatically shown for:
- Radio connection/disconnection status changes
- Backend errors received via WebSocket `error` events
- Manual reconnection success/failure
The `<Toaster />` component is rendered in `App.tsx` with `position="top-right"`.
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RemoteTerm for MeshCore</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5967
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"meshcore-cracker": "file:../references/standalone_cracker",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^25.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.23",
"jsdom": "^25.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vitest": "^2.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because it is too large Load Diff
+781
View File
@@ -0,0 +1,781 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { api } from './api';
import { useWebSocket } from './useWebSocket';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList';
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import { ConfigModal } from './components/ConfigModal';
import { RawPacketList } from './components/RawPacketList';
import { CrackerPanel } from './components/CrackerPanel';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import {
getLastMessageTimes,
getLastReadTimes,
setLastMessageTime,
setLastReadTime,
getStateKey,
type ConversationTimes,
} from './utils/conversationState';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
Channel,
Conversation,
HealthStatus,
Message,
RawPacket,
RadioConfig,
RadioConfigUpdate,
} from './types';
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
// Generate a key for deduplicating messages by content
function getMessageContentKey(msg: Message): string {
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
}
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const seenMessageContent = useRef<Set<string>>(new Set());
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [contacts, setContacts] = useState<Contact[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [messagesLoading, setMessagesLoading] = useState(false);
const [showNewMessage, setShowNewMessage] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [undecryptedCount, setUndecryptedCount] = useState(0);
// Track last message times (persisted in localStorage, used for sorting)
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Track unread counts (calculated on load and incremented during session)
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
// Track previous health status to detect changes
const prevHealthRef = useRef<HealthStatus | null>(null);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(() => ({
onHealth: (data: HealthStatus) => {
const prev = prevHealthRef.current;
prevHealthRef.current = data;
setHealth(data);
// Show toast on connection status change
if (prev !== null && prev.radio_connected !== data.radio_connected) {
if (data.radio_connected) {
toast.success('Radio connected', {
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
});
} else {
toast.error('Radio disconnected', {
description: 'Check radio connection and power',
});
}
}
},
onError: (error: { message: string; details?: string }) => {
toast.error(error.message, {
description: error.details,
});
},
onContacts: (data: Contact[]) => setContacts(data),
onChannels: (data: Channel[]) => setChannels(data),
onMessage: (msg: Message) => {
const activeConv = activeConversationRef.current;
// Skip duplicate messages (same content + timestamp)
const contentKey = getMessageContentKey(msg);
if (seenMessageContent.current.has(contentKey)) {
console.debug('Duplicate message content ignored:', contentKey.slice(0, 50));
return;
}
seenMessageContent.current.add(contentKey);
// Limit set size to prevent memory issues (keep last 1000)
if (seenMessageContent.current.size > 1000) {
const entries = Array.from(seenMessageContent.current);
seenMessageContent.current = new Set(entries.slice(-500));
}
// Determine conversation key for this message
let conversationKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
conversationKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
conversationKey = getStateKey('contact', msg.conversation_key);
}
// Check if message belongs to the active conversation
const isForActiveConversation = (() => {
if (!activeConv) return false;
if (msg.type === 'CHAN' && activeConv.type === 'channel') {
return msg.conversation_key === activeConv.id;
}
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
// Match by public key or prefix (either could be full key or prefix)
return msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
}
return false;
})();
// Only add to message list if it's for the active conversation
if (isForActiveConversation) {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) {
return prev;
}
return [...prev, msg];
});
}
// Track last message time for sorting and unread detection
if (conversationKey) {
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(conversationKey, timestamp);
setLastMessageTimes(updated);
// Count unread messages during this session (for non-active, incoming messages)
if (!msg.outgoing && !isForActiveConversation) {
setUnreadCounts((prev) => ({
...prev,
[conversationKey]: (prev[conversationKey] || 0) + 1,
}));
}
}
},
onContact: (contact: Contact) => {
// Update or add contact, preserving existing non-null values
setContacts((prev) => {
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
if (idx >= 0) {
const updated = [...prev];
const existing = prev[idx];
// Merge: prefer new non-null values, but keep existing values if new is null
updated[idx] = {
...existing,
...contact,
name: contact.name ?? existing.name,
last_path: contact.last_path ?? existing.last_path,
lat: contact.lat ?? existing.lat,
lon: contact.lon ?? existing.lon,
};
return updated;
}
return [...prev, contact as Contact];
});
},
onRawPacket: (packet: RawPacket) => {
setRawPackets((prev) => {
// Check if packet already exists
if (prev.some((p) => p.id === packet.id)) {
return prev;
}
// Limit to MAX_RAW_PACKETS, removing oldest
const updated = [...prev, packet];
if (updated.length > MAX_RAW_PACKETS) {
return updated.slice(-MAX_RAW_PACKETS);
}
return updated;
});
},
onMessageAcked: (messageId: number) => {
// Update message acked status
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...prev[idx], acked: true };
return updated;
}
return prev;
});
},
}), []);
// Connect to WebSocket
useWebSocket(wsHandlers);
// Fetch radio config (not sent via WebSocket)
const fetchConfig = useCallback(async () => {
try {
const data = await api.getRadioConfig();
setConfig(data);
} catch (err) {
console.error('Failed to fetch config:', err);
}
}, []);
// Fetch app settings
const fetchAppSettings = useCallback(async () => {
try {
const data = await api.getSettings();
setAppSettings(data);
} catch (err) {
console.error('Failed to fetch app settings:', err);
}
}, []);
// Fetch undecrypted packet count
const fetchUndecryptedCount = useCallback(async () => {
try {
const data = await api.getUndecryptedPacketCount();
setUndecryptedCount(data.count);
} catch (err) {
console.error('Failed to fetch undecrypted count:', err);
}
}, []);
const MESSAGE_PAGE_SIZE = 200;
// Fetch messages for active conversation
const fetchMessages = useCallback(async (showLoading = false) => {
if (!activeConversation) {
setMessages([]);
setHasOlderMessages(false);
return;
}
if (showLoading) {
setMessagesLoading(true);
}
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
});
setMessages(data);
// If we got a full page, there might be more
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch messages:', err);
} finally {
if (showLoading) {
setMessagesLoading(false);
}
}
}, [activeConversation]);
// Fetch older messages (pagination)
const fetchOlderMessages = useCallback(async () => {
if (!activeConversation || loadingOlder || !hasOlderMessages) return;
setLoadingOlder(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
offset: messages.length,
});
if (data.length > 0) {
// Prepend older messages (they come sorted DESC, so older are at the end)
setMessages(prev => [...prev, ...data]);
}
// If we got less than a full page, no more messages
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch older messages:', err);
} finally {
setLoadingOlder(false);
}
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
// Initial fetch for config and settings (WebSocket handles health/contacts/channels)
useEffect(() => {
fetchConfig();
fetchAppSettings();
fetchUndecryptedCount();
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
// Select Public channel by default when channels first load
const hasSetDefaultConversation = useRef(false);
useEffect(() => {
if (hasSetDefaultConversation.current || channels.length === 0 || activeConversation) return;
const publicChannel = channels.find(c => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
});
hasSetDefaultConversation.current = true;
}
}, [channels, activeConversation]);
// Fetch messages and count unreads for all conversations on load (single bulk request)
const fetchedChannels = useRef<Set<string>>(new Set());
const fetchedContacts = useRef<Set<string>>(new Set());
useEffect(() => {
// Find channels and contacts we haven't fetched yet
const newChannels = channels.filter(c => !fetchedChannels.current.has(c.key));
const newContacts = contacts.filter(c => c.public_key && !fetchedContacts.current.has(c.public_key));
if (newChannels.length === 0 && newContacts.length === 0) return;
// Mark as fetched before starting (to avoid duplicate fetches if effect re-runs)
newChannels.forEach(c => fetchedChannels.current.add(c.key));
newContacts.forEach(c => fetchedContacts.current.add(c.public_key));
const fetchAndCountUnreads = async () => {
// Build list of conversations to fetch
const conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }> = [
...newChannels.map(c => ({ type: 'CHAN' as const, conversation_key: c.key })),
...newContacts.map(c => ({ type: 'PRIV' as const, conversation_key: c.public_key })),
];
if (conversations.length === 0) return;
try {
// Single bulk request for all conversations
const bulkMessages = await api.getMessagesBulk(conversations, 100);
// Read lastReadTimes fresh from localStorage for accurate comparison
const currentReadTimes = getLastReadTimes();
const newUnreadCounts: Record<string, number> = {};
const newLastMessageTimes: Record<string, number> = {};
// Process channel messages
for (const channel of newChannels) {
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
if (msgs.length > 0) {
const key = getStateKey('channel', channel.key);
const lastRead = currentReadTimes[key] || 0;
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
if (unreadCount > 0) {
newUnreadCounts[key] = unreadCount;
}
const latestTime = Math.max(...msgs.map(m => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
}
// Process contact messages
for (const contact of newContacts) {
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
if (msgs.length > 0) {
const key = getStateKey('contact', contact.public_key);
const lastRead = currentReadTimes[key] || 0;
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
if (unreadCount > 0) {
newUnreadCounts[key] = unreadCount;
}
const latestTime = Math.max(...msgs.map(m => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
}
// Update state with all the counts and times
if (Object.keys(newUnreadCounts).length > 0) {
setUnreadCounts(prev => ({ ...prev, ...newUnreadCounts }));
}
setLastMessageTimes(getLastMessageTimes());
} catch (err) {
console.error('Failed to fetch messages bulk:', err);
}
};
fetchAndCountUnreads();
}, [channels, contacts]);
// Keep ref in sync with state and mark conversation as read when viewed
useEffect(() => {
activeConversationRef.current = activeConversation;
// Mark conversation as read when user views it
if (activeConversation && activeConversation.type !== 'raw') {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
);
// Update localStorage-based read time
const now = Math.floor(Date.now() / 1000);
setLastReadTime(key, now);
// Clear unread count for this conversation
setUnreadCounts((prev) => {
if (prev[key]) {
const next = { ...prev };
delete next[key];
return next;
}
return prev;
});
}
}, [activeConversation]);
// Fetch messages when conversation changes
useEffect(() => {
fetchMessages(true);
}, [fetchMessages]);
// Send message handler
const handleSendMessage = useCallback(
async (text: string) => {
if (!activeConversation) return;
if (activeConversation.type === 'channel') {
await api.sendChannelMessage(activeConversation.id, text);
} else {
await api.sendDirectMessage(activeConversation.id, text);
}
// Message will arrive via WebSocket, but fetch to be safe
await fetchMessages();
},
[activeConversation, fetchMessages]
);
// Config save handler
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
await fetchConfig();
}, [fetchConfig]);
// App settings save handler
const handleSaveAppSettings = useCallback(async (update: AppSettingsUpdate) => {
await api.updateSettings(update);
await fetchAppSettings();
}, [fetchAppSettings]);
// Set private key handler
const handleSetPrivateKey = useCallback(async (key: string) => {
await api.setPrivateKey(key);
await fetchConfig();
}, [fetchConfig]);
// Reboot radio handler
const handleReboot = useCallback(async () => {
await api.rebootRadio();
// Immediately show disconnected state
setHealth((prev) =>
prev ? { ...prev, radio_connected: false } : prev
);
// Health updates will come via WebSocket when reconnected
// But also poll as backup
const pollUntilReconnected = async () => {
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
try {
const data = await api.getHealth();
setHealth(data);
if (data.radio_connected) {
fetchConfig();
return;
}
} catch {
// Keep polling
}
}
};
pollUntilReconnected();
}, [fetchConfig]);
// Send flood advertisement handler
const handleAdvertise = useCallback(async () => {
try {
await api.sendAdvertisement(true);
} catch (err) {
console.error('Failed to send advertisement:', err);
}
}, []);
// Handle sender click to add mention
const handleSenderClick = useCallback((sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `);
}, []);
// Handle conversation selection (closes sidebar on mobile)
const handleSelectConversation = useCallback((conv: Conversation) => {
setActiveConversation(conv);
setSidebarOpen(false);
}, []);
// Delete channel handler
const handleDeleteChannel = useCallback(async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
try {
await api.deleteChannel(key);
setChannels((prev) => prev.filter((c) => c.key !== key));
setActiveConversation(null);
} catch (err) {
console.error('Failed to delete channel:', err);
}
}, []);
// Delete contact handler
const handleDeleteContact = useCallback(async (publicKey: string) => {
if (!confirm('Delete this contact? Message history will be preserved.')) return;
try {
await api.deleteContact(publicKey);
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
setActiveConversation(null);
} catch (err) {
console.error('Failed to delete contact:', err);
}
}, []);
// Create contact handler
const handleCreateContact = useCallback(
async (name: string, publicKey: string, tryHistorical: boolean) => {
const newContact: Contact = {
public_key: publicKey,
name,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
};
setContacts((prev) => [...prev, newContact]);
// Open the new contact
setActiveConversation({
type: 'contact',
id: publicKey,
name: getContactDisplayName(name, publicKey),
});
if (tryHistorical) {
console.log('Contact historical decryption not yet supported');
}
},
[]
);
// Create channel handler
const handleCreateChannel = useCallback(
async (name: string, key: string, tryHistorical: boolean) => {
const created = await api.createChannel(name, key);
// Channel will be broadcast via WebSocket, but fetch to be safe
const data = await api.getChannels();
setChannels(data);
// Open the new channel (use created.key as the id)
setActiveConversation({
type: 'channel',
id: created.key,
name,
});
if (tryHistorical) {
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
}
},
[fetchUndecryptedCount]
);
// Create hashtag channel handler
const handleCreateHashtagChannel = useCallback(
async (name: string, tryHistorical: boolean) => {
const channelName = name.startsWith('#') ? name : `#${name}`;
const created = await api.createChannel(channelName);
const data = await api.getChannels();
setChannels(data);
// Open the new channel (use created.key as the id)
setActiveConversation({
type: 'channel',
id: created.key,
name: channelName,
});
if (tryHistorical) {
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_name: channelName,
});
fetchUndecryptedCount();
}
},
[fetchUndecryptedCount]
);
// Sidebar content (shared between desktop and mobile)
const sidebarContent = (
<Sidebar
contacts={contacts}
channels={channels}
activeConversation={activeConversation}
onSelectConversation={handleSelectConversation}
onNewMessage={() => {
setShowNewMessage(true);
setSidebarOpen(false);
}}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
/>
);
return (
<div className="flex flex-col h-screen">
<StatusBar
health={health}
config={config}
onConfigClick={() => setShowConfig(true)}
onAdvertise={handleAdvertise}
onMenuClick={() => setSidebarOpen(true)}
/>
<div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar - hidden on mobile */}
<div className="hidden md:block">
{sidebarContent}
</div>
{/* Mobile sidebar - Sheet that slides in */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden">
{sidebarContent}
</div>
</SheetContent>
</Sheet>
<div className="flex-1 flex flex-col bg-background">
{activeConversation ? (
activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Raw Packet Feed</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-hidden min-h-0">
<RawPacketList packets={rawPackets} />
</div>
<div className="h-[280px] flex-shrink-0 border-t border-border overflow-hidden">
<CrackerPanel
packets={rawPackets}
channels={channels}
onChannelCreate={async (name, key) => {
// Create channel without navigating to it
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
// Try to decrypt historical packets with this key
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
}}
/>
</div>
</div>
</>
) : (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
<span>
{activeConversation.type === 'channel' && !activeConversation.name.startsWith('#') ? '#' : ''}
{activeConversation.name}
</span>
<span className="font-normal text-xs text-muted-foreground font-mono">
{activeConversation.id}
</span>
</span>
{!(activeConversation.type === 'channel' && activeConversation.name === 'Public') && (
<button
className="py-1 px-3 bg-destructive/20 border border-destructive/30 text-destructive rounded text-xs cursor-pointer hover:bg-destructive/30"
onClick={() => {
if (activeConversation.type === 'channel') {
handleDeleteChannel(activeConversation.id);
} else {
handleDeleteContact(activeConversation.id);
}
}}
>
Delete
</button>
)}
</div>
<MessageList
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
onSenderClick={activeConversation.type === 'channel' ? handleSenderClick : undefined}
onLoadOlder={fetchOlderMessages}
/>
<MessageInput
ref={messageInputRef}
onSend={handleSendMessage}
disabled={!health?.radio_connected}
placeholder={
health?.radio_connected
? `Message ${activeConversation.name}...`
: 'Radio not connected'
}
/>
</>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div>
)}
</div>
</div>
<NewMessageModal
open={showNewMessage}
contacts={contacts}
undecryptedCount={undecryptedCount}
onClose={() => setShowNewMessage(false)}
onSelectConversation={(conv) => {
setActiveConversation(conv);
setShowNewMessage(false);
}}
onCreateContact={handleCreateContact}
onCreateChannel={handleCreateChannel}
onCreateHashtagChannel={handleCreateHashtagChannel}
/>
<ConfigModal
open={showConfig}
config={config}
appSettings={appSettings}
onClose={() => setShowConfig(false)}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
/>
<Toaster position="top-right" />
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import type {
AppSettings,
AppSettingsUpdate,
Channel,
Contact,
HealthStatus,
Message,
RadioConfig,
RadioConfigUpdate,
} from './types';
const API_BASE = '/api';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
const error = await res.text();
throw new Error(error || res.statusText);
}
return res.json();
}
interface DecryptResult {
started: boolean;
total_packets: number;
message: string;
}
export const api = {
// Health
getHealth: () => fetchJson<HealthStatus>('/health'),
// Radio config
getRadioConfig: () => fetchJson<RadioConfig>('/radio/config'),
updateRadioConfig: (config: RadioConfigUpdate) =>
fetchJson<RadioConfig>('/radio/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
setPrivateKey: (privateKey: string) =>
fetchJson<{ status: string }>('/radio/private-key', {
method: 'PUT',
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: (flood = true) =>
fetchJson<{ status: string; flood: boolean }>(
`/radio/advertise?flood=${flood}`,
{ method: 'POST' }
),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
}),
reconnectRadio: () =>
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
method: 'POST',
}),
// Contacts
getContacts: (limit = 100, offset = 0) =>
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getContact: (publicKey: string) => fetchJson<Contact>(`/contacts/${publicKey}`),
syncContacts: () =>
fetchJson<{ synced: number }>('/contacts/sync', { method: 'POST' }),
addContactToRadio: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}/add-to-radio`, {
method: 'POST',
}),
removeContactFromRadio: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}/remove-from-radio`, {
method: 'POST',
}),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),
getChannel: (key: string) => fetchJson<Channel>(`/channels/${key}`),
createChannel: (name: string, key?: string) =>
fetchJson<Channel>('/channels', {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
syncChannels: () =>
fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
// Messages
getMessages: (params?: {
limit?: number;
offset?: number;
type?: 'PRIV' | 'CHAN';
conversation_key?: string;
}) => {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
if (params?.type) searchParams.set('type', params.type);
if (params?.conversation_key)
searchParams.set('conversation_key', params.conversation_key);
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`);
},
getMessagesBulk: (
conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }>,
limitPerConversation: number = 100
) =>
fetchJson<Record<string, Message[]>>(
`/messages/bulk?limit_per_conversation=${limitPerConversation}`,
{
method: 'POST',
body: JSON.stringify(conversations),
}
),
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',
body: JSON.stringify({ destination, text }),
}),
sendChannelMessage: (channelKey: string, text: string) =>
fetchJson<Message>('/messages/channel', {
method: 'POST',
body: JSON.stringify({ channel_key: channelKey, text }),
}),
// Packets
getUndecryptedPacketCount: () =>
fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact';
channel_key?: string;
channel_name?: string;
}) =>
fetchJson<DecryptResult>('/packets/decrypt/historical', {
method: 'POST',
body: JSON.stringify(params),
}),
// App Settings
getSettings: () => fetchJson<AppSettings>('/settings'),
updateSettings: (settings: AppSettingsUpdate) =>
fetchJson<AppSettings>('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
}),
};
+328
View File
@@ -0,0 +1,328 @@
import { useState, useEffect } from 'react';
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
interface ConfigModalProps {
open: boolean;
config: RadioConfig | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
}
export function ConfigModal({
open,
config,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
}: ConfigModalProps) {
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
const handleSave = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
name,
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio Configuration</DialogTitle>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">
Loading configuration...
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat">Latitude</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon">Longitude</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="1"
max="4"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<div className="flex gap-2">
<Input
id="private-key"
type="password"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
className="flex-1"
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || !privateKey.trim()}
>
Set
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-3">
<Label>Reboot Radio</Label>
<Alert variant="warning">
<AlertDescription>
Some configuration changes (like name) require a radio reboot to take effect.
The connection will temporarily drop and automatically reconnect.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
</div>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || !config}>
{loading ? 'Saving...' : 'Save Config'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { getContactAvatar } from '../utils/contactAvatar';
interface ContactAvatarProps {
name: string | null;
publicKey: string;
size?: number;
contactType?: number;
}
export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) {
const avatar = getContactAvatar(name, publicKey, contactType);
return (
<div
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
style={{
backgroundColor: avatar.background,
color: avatar.textColor,
width: size,
height: size,
fontSize: size * 0.45,
}}
>
{avatar.text}
</div>
);
}
+387
View File
@@ -0,0 +1,387 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-cracker';
import type { RawPacket, Channel } from '../types';
import { cn } from '@/lib/utils';
interface CrackedRoom {
roomName: string;
key: string;
packetId: number;
message: string;
crackedAt: number;
}
interface QueueItem {
packet: RawPacket;
attempts: number;
lastAttemptLength: number;
status: 'pending' | 'cracking' | 'cracked' | 'failed';
}
interface CrackerPanelProps {
packets: RawPacket[];
channels: Channel[];
onChannelCreate: (name: string, key: string) => Promise<void>;
}
export function CrackerPanel({ packets, channels, onChannelCreate }: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
const [progress, setProgress] = useState<ProgressReport | null>(null);
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
const [wordlistLoaded, setWordlistLoaded] = useState(false);
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
const crackerRef = useRef<GroupTextCracker | null>(null);
const isRunningRef = useRef(false);
const abortedRef = useRef(false);
const isProcessingRef = useRef(false);
const queueRef = useRef<Map<number, QueueItem>>(new Map());
const retryFailedRef = useRef(false);
const maxLengthRef = useRef(6);
// Initialize cracker
useEffect(() => {
const cracker = new GroupTextCracker();
crackerRef.current = cracker;
setGpuAvailable(cracker.isGpuAvailable());
// Load wordlist
cracker.loadWordlist('/words_alpha.txt')
.then(() => setWordlistLoaded(true))
.catch((err) => console.error('Failed to load wordlist:', err));
return () => {
cracker.destroy();
crackerRef.current = null;
};
}, []);
// Get existing channel keys for filtering
const existingChannelKeys = new Set(channels.map(c => c.key.toUpperCase()));
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
p => p.payload_type === 'GROUP_TEXT' && !p.decrypted
);
// Update queue when packets change
useEffect(() => {
setQueue(prev => {
const newQueue = new Map(prev);
let changed = false;
for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) {
newQueue.set(packet.id, {
packet,
attempts: 0,
lastAttemptLength: 0,
status: 'pending',
});
changed = true;
}
}
if (changed) {
queueRef.current = newQueue;
return newQueue;
}
return prev;
});
}, [undecryptedGroupText.length]);
// Keep refs in sync with state
useEffect(() => {
queueRef.current = queue;
}, [queue]);
useEffect(() => {
retryFailedRef.current = retryFailedAtNextLength;
}, [retryFailedAtNextLength]);
useEffect(() => {
maxLengthRef.current = maxLength;
}, [maxLength]);
// Stats (cracking count is implicit - if progress is shown, we're cracking one)
const pendingCount = Array.from(queue.values()).filter(q => q.status === 'pending').length;
const crackedCount = Array.from(queue.values()).filter(q => q.status === 'cracked').length;
const failedCount = Array.from(queue.values()).filter(q => q.status === 'failed').length;
// Process next packet in queue
const processNext = useCallback(async () => {
// Prevent concurrent processing
if (isProcessingRef.current) return;
if (!crackerRef.current || !isRunningRef.current) return;
const currentQueue = queueRef.current;
// Find next pending packet
let nextItem: QueueItem | null = null;
let nextId: number | null = null;
for (const [id, item] of currentQueue.entries()) {
if (item.status === 'pending') {
nextItem = item;
nextId = id;
break;
}
}
// If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength
if (!nextItem && retryFailedRef.current) {
const failedItems = Array.from(currentQueue.entries()).filter(
([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10
);
if (failedItems.length > 0) {
// Sort by lastAttemptLength ascending and pick the first (lowest)
failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength);
[nextId, nextItem] = failedItems[0];
}
}
if (!nextItem || nextId === null) {
// Nothing to process right now, but keep running and check again later
if (isRunningRef.current) {
setTimeout(() => processNext(), 1000);
}
return;
}
// Lock processing
isProcessingRef.current = true;
const currentMaxLength = maxLengthRef.current;
const targetLength = nextItem.lastAttemptLength > 0
? nextItem.lastAttemptLength + 1
: currentMaxLength;
try {
const result = await crackerRef.current.crack(
nextItem.packet.data,
{
maxLength: targetLength,
useTimestampFilter: true,
useUtf8Filter: true,
},
(prog) => {
setProgress(prog);
}
);
if (abortedRef.current) {
abortedRef.current = false;
isProcessingRef.current = false;
setProgress(null);
return;
}
if (result.found && result.roomName && result.key) {
// Success!
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'cracked',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
const newRoom: CrackedRoom = {
roomName: result.roomName,
key: result.key,
packetId: nextId!,
message: result.decryptedMessage || '',
crackedAt: Date.now(),
};
setCrackedRooms(prev => [...prev, newRoom]);
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
if (!existingChannelKeys.has(keyUpper)) {
try {
await onChannelCreate('#' + result.roomName, result.key);
} catch (err) {
console.error('Failed to create channel:', err);
}
}
} else {
// Failed
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'failed',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
}
} catch (err) {
console.error('Cracking error:', err);
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'failed',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
}
// Unlock processing
isProcessingRef.current = false;
setProgress(null);
// Continue processing if still running
if (isRunningRef.current) {
setTimeout(() => processNext(), 100);
}
}, [existingChannelKeys, onChannelCreate]);
// Start/stop handlers
const handleStart = () => {
if (!gpuAvailable) {
alert('WebGPU is not available in your browser. Please use Chrome 113+ or Edge 113+.');
return;
}
setIsRunning(true);
isRunningRef.current = true;
abortedRef.current = false;
processNext();
};
const handleStop = () => {
setIsRunning(false);
isRunningRef.current = false;
abortedRef.current = true;
crackerRef.current?.abort();
};
return (
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border">
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={isRunning ? handleStop : handleStart}
disabled={!wordlistLoaded || gpuAvailable === false}
className={cn(
"px-4 py-1.5 rounded text-sm font-medium",
isRunning
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
{isRunning ? 'Stop' : 'Start Cracking'}
</button>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Max Length:</label>
<input
type="number"
min={1}
max={10}
value={maxLength}
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={retryFailedAtNextLength}
onChange={(e) => setRetryFailedAtNextLength(e.target.checked)}
className="rounded"
/>
Retry failed at n+1
</label>
</div>
{/* Status */}
<div className="flex gap-4 text-sm">
<span className="text-muted-foreground">
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
</span>
</div>
{/* Progress */}
{progress && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'}
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}
: {progress.currentPosition}
</span>
<span>
{progress.rateKeysPerSec >= 1e9
? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s`
: `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}
{' '} ETA: {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
</span>
</div>
<div className="h-2 bg-muted rounded overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
<div className="text-sm text-muted-foreground">
Loading wordlist...
</div>
)}
{/* Cracked rooms list */}
{crackedRooms.length > 0 && (
<div className="flex-1 overflow-y-auto min-h-0">
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
<div className="space-y-1">
{crackedRooms.map((room, i) => (
<div key={i} className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1">
<span className="text-green-400 font-medium">#{room.roomName}</span>
<span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)}{room.message.length > 50 ? '...' : ''}"
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, type FormEvent, type KeyboardEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
}
export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder }, ref) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
appendText: (appendedText: string) => {
setText((prev) => prev + appendedText);
// Focus the input after appending
inputRef.current?.focus();
},
}));
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
},
[text, sending, disabled, onSend]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as FormEvent);
}
},
[handleSubmit]
);
return (
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
<Input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}
className="flex-1"
/>
<Button type="submit" disabled={disabled || sending || !text.trim()}>
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
);
});
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import type { Contact, Message } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { cn } from '@/lib/utils';
interface MessageListProps {
messages: Message[];
contacts: Contact[];
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
}
export function MessageList({
messages,
contacts,
loading,
loadingOlder = false,
hasOlderMessages = false,
onSenderClick,
onLoadOlder,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
scrollTop: 0,
scrollHeight: 0,
wasNearTop: false,
});
// Handle scroll position AFTER render
useLayoutEffect(() => {
if (!listRef.current) return;
const list = listRef.current;
const messagesAdded = messages.length - prevMessagesLengthRef.current;
if (isInitialLoadRef.current && messages.length > 0) {
// Initial load - scroll to bottom
list.scrollTop = list.scrollHeight;
isInitialLoadRef.current = false;
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
// Messages were added - use scroll state captured before the update
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
// User was near top (loading older) - preserve position by adding the height diff
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
} else if (!scrollStateRef.current.wasNearTop) {
// User was at bottom - scroll to bottom for new messages
list.scrollTop = list.scrollHeight;
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages]);
// Reset initial load flag when conversation changes (messages becomes empty then filled)
useEffect(() => {
if (messages.length === 0) {
isInitialLoadRef.current = true;
prevMessagesLengthRef.current = 0;
scrollStateRef.current = { scrollTop: 0, scrollHeight: 0, wasNearTop: false };
}
}, [messages.length]);
// Handle scroll - capture state and detect when user is near top
const handleScroll = useCallback(() => {
if (!listRef.current) return;
const { scrollTop, scrollHeight } = listRef.current;
// Always capture current scroll state (needed for scroll preservation)
scrollStateRef.current = {
scrollTop,
scrollHeight,
wasNearTop: scrollTop < 150,
};
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
// Trigger load when within 100px of top
if (scrollTop < 100) {
onLoadOlder();
}
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
// Look up contact by public key or prefix
const getContact = (conversationKey: string | null): Contact | null => {
if (!conversationKey) return null;
return contacts.find(c => pubkeysMatch(c.public_key, conversationKey)) || null;
};
// Look up contact by name (for channel messages where we parse sender from text)
const getContactByName = (name: string): Contact | null => {
return contacts.find(c => c.name === name) || null;
};
if (loading) {
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">Loading messages...</div>;
}
if (messages.length === 0) {
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">No messages yet</div>;
}
// Deduplicate messages by content + timestamp (same message via different paths)
const deduplicatedMessages = messages.reduce<Message[]>((acc, msg) => {
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
const existing = acc.find(m =>
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
);
if (!existing) {
acc.push(msg);
}
return acc;
}, []);
// Sort messages by received_at ascending (oldest first)
const sortedMessages = [...deduplicatedMessages].sort(
(a, b) => a.received_at - b.received_at
);
// Helper to get a unique sender key for grouping messages
const getSenderKey = (msg: Message, sender: string | null): string => {
if (msg.outgoing) return '__outgoing__';
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
return sender || '__unknown__';
};
return (
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-0.5" ref={listRef} onScroll={handleScroll}>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm">
Loading older messages...
</div>
)}
{!loadingOlder && hasOlderMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
Scroll up for older messages
</div>
)}
{sortedMessages.map((msg, index) => {
const { sender, content } = parseSenderFromText(msg.text);
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const displaySender = msg.outgoing
? 'You'
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
// Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey(msg, sender);
const prevMsg = sortedMessages[index - 1];
const prevSenderKey = prevMsg ? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender) : null;
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
const isFirstMessage = index === 0;
// Get avatar info for incoming messages
let avatarName: string | null = null;
let avatarKey: string = '';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) {
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
} else if (sender) {
// Channel message: try to find contact by name, or use sender name as pseudo-key
const senderContact = getContactByName(sender);
avatarName = sender;
avatarKey = senderContact?.public_key || `name:${sender}`;
}
}
return (
<div
key={msg.id}
className={cn(
"flex items-start max-w-[85%]",
msg.outgoing && "flex-row-reverse self-end",
showAvatar && !isFirstMessage && "mt-3"
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
)}
</div>
)}
<div className={cn(
"py-1.5 px-3 rounded-lg min-w-0",
msg.outgoing ? "bg-[#1e3a29]" : "bg-muted"
)}>
{showAvatar && (
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary hover:underline"
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{line}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
)}
{msg.outgoing && (msg.acked ? ' ✓' : ' ?')}
</div>
</div>
</div>
);
})}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { useState, useRef } from 'react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
contacts: Contact[];
undecryptedCount: number;
onClose: () => void;
onSelectConversation: (conversation: Conversation) => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
}
export function NewMessageModal({
open,
contacts,
undecryptedCount,
onClose,
onSelectConversation,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('existing');
const [name, setName] = useState('');
const [key, setKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const handleCreate = async () => {
setError('');
setLoading(true);
try {
if (tab === 'new-contact') {
if (!name.trim() || !key.trim()) {
setError('Name and public key are required');
return;
}
await onCreateContact(name.trim(), key.trim(), tryHistorical);
onSelectConversation({
type: 'contact',
id: key.trim(),
name: name.trim(),
});
} else if (tab === 'new-room') {
if (!name.trim() || !key.trim()) {
setError('Room name and key are required');
return;
}
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
if (validationError) {
setError(validationError);
return;
}
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
try {
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
setName('');
hashtagInputRef.current?.focus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
}
};
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="existing">Existing</TabsTrigger>
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-room">Room</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
No contacts available
</div>
) : (
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent"
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key)}
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="new-contact" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="contact-name">Name</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contact name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-key">Public Key</Label>
<Input
id="contact-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="64-character hex public key"
/>
</div>
</TabsContent>
<TabsContent value="new-room" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label>
<Input
id="room-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Room name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Input
id="room-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Pre-shared key (hex)"
/>
</div>
</TabsContent>
<TabsContent value="hashtag" className="mt-4">
<div className="space-y-2">
<Label htmlFor="hashtag-name">Hashtag Channel</Label>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">#</span>
<Input
ref={hashtagInputRef}
id="hashtag-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="channel-name"
className="flex-1"
/>
</div>
</div>
</TabsContent>
</Tabs>
{showHistoricalOption && (
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="text-sm text-muted-foreground cursor-pointer"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet{undecryptedCount !== 1 ? 's' : ''}
</Label>
<Checkbox
id="try-historical"
checked={tryHistorical}
onCheckedChange={(checked) => setTryHistorical(checked === true)}
/>
</div>
)}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
{tab === 'hashtag' && (
<Button variant="secondary" onClick={handleCreateAndAddAnother} disabled={loading}>
{loading ? 'Creating...' : 'Create & Add Another'}
</Button>
)}
{tab !== 'existing' && (
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react';
import type { RawPacket } from '../types';
interface RawPacketListProps {
packets: RawPacket[];
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatPayloadType(type: string): string {
// Convert SNAKE_CASE to Title Case
return type
.split('_')
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
function getDecryptedLabel(packet: RawPacket): string {
if (!packet.decrypted || !packet.decrypted_info) {
return formatPayloadType(packet.payload_type);
}
const info = packet.decrypted_info;
if (packet.payload_type === 'GROUP_TEXT' && info.channel_name) {
return `GroupText to ${info.channel_name}`;
}
if (packet.payload_type === 'TEXT_MESSAGE' && info.sender) {
return `TextMessage from ${info.sender}`;
}
return formatPayloadType(packet.payload_type);
}
function formatSignalInfo(packet: RawPacket): string {
const parts: string[] = [];
if (packet.snr !== null && packet.snr !== undefined) {
parts.push(`SNR: ${packet.snr.toFixed(1)} dB`);
}
if (packet.rssi !== null && packet.rssi !== undefined) {
parts.push(`RSSI: ${packet.rssi} dBm`);
}
return parts.join(' | ');
}
export function RawPacketList({ packets }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [packets]);
if (packets.length === 0) {
return (
<div className="h-full overflow-y-auto p-5 text-center text-muted-foreground">
No packets received yet. Packets will appear here in real-time.
</div>
);
}
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = [...packets].sort((a, b) => a.timestamp - b.timestamp);
return (
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
{sortedPackets.map((packet) => (
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
<div className={packet.decrypted ? 'text-primary' : 'text-destructive'}>
{!packet.decrypted && <span className="mr-1">🔒</span>}
{getDecryptedLabel(packet)}
{' • '}
{formatTime(packet.timestamp)}
</div>
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatSignalInfo(packet)}
</div>
)}
<div className="font-mono text-[11px] break-all text-muted-foreground/70 mt-1">
{packet.data.toUpperCase()}
</div>
</div>
))}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useState } from 'react';
import type { Contact, Channel, Conversation } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
type SortOrder = 'alpha' | 'recent';
interface SidebarProps {
contacts: Contact[];
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
}
// Load sort preference from localStorage
function loadSortOrder(): SortOrder {
try {
const stored = localStorage.getItem('remoteterm-sortOrder');
return stored === 'recent' ? 'recent' : 'alpha';
} catch {
return 'alpha';
}
}
// Save sort preference to localStorage
function saveSortOrder(order: SortOrder): void {
try {
localStorage.setItem('remoteterm-sortOrder', order);
} catch {
// localStorage might be full or disabled
}
}
export function Sidebar({
contacts,
channels,
activeConversation,
onSelectConversation,
onNewMessage,
lastMessageTimes,
unreadCounts,
}: SidebarProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
const [searchQuery, setSearchQuery] = useState('');
const handleSortToggle = () => {
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
setSortOrder(newOrder);
saveSortOrder(newOrder);
};
const handleSelectConversation = (conversation: Conversation) => {
setSearchQuery('');
onSelectConversation(conversation);
};
const isActive = (type: 'contact' | 'channel' | 'raw', id: string) =>
activeConversation?.type === type && activeConversation?.id === id;
// Get unread count for a conversation
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
const key = getStateKey(type, id);
return unreadCounts[key] || 0;
};
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
const key = getStateKey(type, id);
return lastMessageTimes[key] || 0;
};
// Deduplicate channels by name, keeping the first (lowest index)
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
if (!acc.some((c) => c.name === channel.name)) {
acc.push(channel);
}
return acc;
}, []);
// Deduplicate contacts by 12-char prefix, preferring ones with names
// Also filter out any contacts with empty public keys
const uniqueContacts = contacts
.filter((c) => c.public_key && c.public_key.length > 0)
.sort((a, b) => {
// Sort contacts with names first
if (a.name && !b.name) return -1;
if (!a.name && b.name) return 1;
return (a.name || '').localeCompare(b.name || '');
})
.reduce<Contact[]>((acc, contact) => {
const prefix = getPubkeyPrefix(contact.public_key);
if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) {
acc.push(contact);
}
return acc;
}, []);
// Sort channels based on sort order, with Public always first
const sortedChannels = [...uniqueChannels].sort((a, b) => {
// Public channel always sorts to the top
if (a.name === 'Public') return -1;
if (b.name === 'Public') return 1;
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return a.name.localeCompare(b.name);
});
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
const sortedContacts = [...uniqueContacts].sort((a, b) => {
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
// Repeaters always go to the bottom
if (aIsRepeater && !bIsRepeater) return 1;
if (!aIsRepeater && bIsRepeater) return -1;
// Both repeaters: always sort alphabetically
if (aIsRepeater && bIsRepeater) {
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}
// Both non-repeaters: use selected sort order
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
});
// Filter by search query
const query = searchQuery.toLowerCase().trim();
const filteredChannels = query
? sortedChannels.filter((c) => c.name.toLowerCase().includes(query))
: sortedChannels;
const filteredContacts = query
? sortedContacts.filter((c) =>
(c.name?.toLowerCase().includes(query)) ||
c.public_key.toLowerCase().includes(query)
)
: sortedContacts;
return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
{/* Header */}
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
<h2 className="text-xs uppercase text-muted-foreground font-medium">Conversations</h2>
<Button
variant="ghost"
size="sm"
onClick={onNewMessage}
title="New Message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
>
+
</Button>
</div>
{/* Search */}
<div className="relative px-3 py-2 border-b border-border">
<Input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 text-sm pr-8"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none"
onClick={() => setSearchQuery('')}
title="Clear search"
>
×
</button>
)}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{/* Raw Packet Feed */}
{!query && (
<div
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('raw', 'raw') && "bg-accent border-l-primary"
)}
onClick={() =>
handleSelectConversation({
type: 'raw',
id: 'raw',
name: 'Raw Packet Feed',
})
}
>
<span className="text-muted-foreground text-xs">📡</span>
<span className="flex-1 truncate">Packet Feed</span>
</div>
)}
{/* Channels */}
{filteredChannels.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
</div>
{filteredChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key);
return (
<div
key={`chan-${channel.key}`}
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('channel', channel.key) && "bg-accent border-l-primary",
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
)}
onClick={() =>
handleSelectConversation({
type: 'channel',
id: channel.key,
name: channel.name,
})
}
>
<span className="text-muted-foreground text-xs">#</span>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{unreadCount}
</span>
)}
</div>
);
})}
</>
)}
{/* Contacts */}
{filteredContacts.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
{filteredChannels.length === 0 && (
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
</div>
{filteredContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key);
return (
<div
key={contact.public_key}
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('contact', contact.public_key) && "bg-accent border-l-primary",
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
)}
onClick={() =>
handleSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
})
}
>
<ContactAvatar name={contact.name} publicKey={contact.public_key} size={24} contactType={contact.type} />
<span className="name flex-1 truncate">
{getContactDisplayName(contact.name, contact.public_key)}
</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{unreadCount}
</span>
)}
</div>
);
})}
</>
)}
{/* Empty state */}
{filteredContacts.length === 0 && filteredChannels.length === 0 && (
<div className="p-5 text-center text-muted-foreground">
{query ? 'No matches found' : 'No conversations yet'}
</div>
)}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
interface StatusBarProps {
health: HealthStatus | null;
config: RadioConfig | null;
onConfigClick: () => void;
onAdvertise: () => void;
onMenuClick?: () => void;
}
export function StatusBar({ health, config, onConfigClick, onAdvertise, onMenuClick }: StatusBarProps) {
const connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false);
const handleReconnect = async () => {
setReconnecting(true);
try {
const result = await api.reconnectRadio();
if (result.connected) {
toast.success('Reconnected', { description: result.message });
}
} catch (err) {
toast.error('Reconnection failed', {
description: err instanceof Error ? err.message : 'Check radio connection and power',
});
} finally {
setReconnecting(false);
}
};
return (
<div className="flex items-center gap-4 px-4 py-2 bg-[#252525] border-b border-[#333] text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
onClick={onMenuClick}
className="md:hidden p-1 bg-transparent border-none text-[#e0e0e0] cursor-pointer"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
)}
<h1 className="hidden lg:block text-base font-semibold mr-auto">RemoteTerm</h1>
<div className="flex items-center gap-1 text-[#888]">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[#4caf50]' : 'bg-[#666]'}`} />
<span className="hidden lg:inline text-[#e0e0e0]">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
{health?.serial_port && (
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
</div>
)}
{config && (
<>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
Name: <span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Freq: <span className="text-[#e0e0e0]">{config.radio.freq} MHz</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
SF{config.radio.sf}/CR{config.radio.cr}
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
TX: <span className="text-[#e0e0e0]">{config.tx_power} dBm</span>
</div>
</>
)}
{/* Spacer to push buttons right on mobile */}
<div className="flex-1 lg:hidden" />
{!connected && (
<button
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1 bg-[#4a3000] border border-[#6b4500] text-[#ffa500] rounded text-xs cursor-pointer hover:bg-[#5a3a00] disabled:opacity-50 disabled:cursor-not-allowed"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
</button>
)}
<button
onClick={onAdvertise}
disabled={!connected}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
>
Advertise
</button>
<button
onClick={onConfigClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
Config
</button>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-yellow-500/50 bg-yellow-500/10 text-yellow-200 [&>svg]:text-yellow-500",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+122
View File
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+144
View File
@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
hideCloseButton?: boolean
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, hideCloseButton = false, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{!hideCloseButton && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+28
View File
@@ -0,0 +1,28 @@
import { Toaster as Sonner, toast } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
// Muted error style - dark red-tinted background with readable text
error: "group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]",
},
}}
{...props}
/>
)
}
export { Toaster, toast }
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 10%;
--foreground: 0 0% 88%;
--card: 0 0% 14%;
--card-foreground: 0 0% 88%;
--popover: 0 0% 14%;
--popover-foreground: 0 0% 88%;
--primary: 122 39% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 20%;
--secondary-foreground: 0 0% 88%;
--muted: 0 0% 20%;
--muted-foreground: 0 0% 53%;
--accent: 0 0% 20%;
--accent-foreground: 0 0% 88%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 20%;
--input: 0 0% 20%;
--ring: 122 39% 49%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+17
View File
@@ -0,0 +1,17 @@
/* Base styles - minimal CSS that Tailwind/shadcn can't handle */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Mobile sidebar override - ensures sidebar fills Sheet container */
[data-state] .sidebar {
width: 100%;
border-right: none;
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { getAvatarText, getAvatarColor, getContactAvatar, CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
describe('getAvatarText', () => {
it('returns first emoji when name contains emoji', () => {
expect(getAvatarText('John 🚀 Doe', 'abc123')).toBe('🚀');
expect(getAvatarText('🎉 Party', 'abc123')).toBe('🎉');
expect(getAvatarText('Test 😀 More 🎯', 'abc123')).toBe('😀');
});
it('returns initials when name has space', () => {
expect(getAvatarText('John Doe', 'abc123')).toBe('JD');
expect(getAvatarText('Alice Bob Charlie', 'abc123')).toBe('AB');
expect(getAvatarText('jane smith', 'abc123')).toBe('JS');
});
it('returns single letter when no space', () => {
expect(getAvatarText('John', 'abc123')).toBe('J');
expect(getAvatarText('alice', 'abc123')).toBe('A');
});
it('falls back to public key when name is null', () => {
expect(getAvatarText(null, 'abc123def456')).toBe('AB');
});
it('falls back to public key when name has no letters', () => {
expect(getAvatarText('123 456', 'xyz789')).toBe('XY');
expect(getAvatarText('---', 'def456')).toBe('DE');
});
it('handles space but no letter after', () => {
expect(getAvatarText('John ', 'abc123')).toBe('J');
expect(getAvatarText('A 123', 'abc123')).toBe('A');
});
it('emoji takes priority over initials', () => {
expect(getAvatarText('John 🎯 Doe', 'abc123')).toBe('🎯');
});
});
describe('getAvatarColor', () => {
it('returns consistent colors for same public key', () => {
const color1 = getAvatarColor('abc123def456');
const color2 = getAvatarColor('abc123def456');
expect(color1).toEqual(color2);
});
it('returns different colors for different public keys', () => {
const color1 = getAvatarColor('abc123def456');
const color2 = getAvatarColor('xyz789uvw012');
expect(color1.background).not.toBe(color2.background);
});
it('returns valid HSL background color', () => {
const color = getAvatarColor('test123');
expect(color.background).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
});
it('returns white or black text color', () => {
const color = getAvatarColor('test123');
expect(['#ffffff', '#000000']).toContain(color.text);
});
});
describe('getContactAvatar', () => {
it('returns complete avatar info', () => {
const avatar = getContactAvatar('John Doe', 'abc123def456');
expect(avatar.text).toBe('JD');
expect(avatar.background).toMatch(/^hsl\(/);
expect(['#ffffff', '#000000']).toContain(avatar.textColor);
});
it('handles null name', () => {
const avatar = getContactAvatar(null, 'abc123def456');
expect(avatar.text).toBe('AB');
});
it('returns repeater avatar for type=2', () => {
const avatar = getContactAvatar('Some Repeater', 'abc123def456', CONTACT_TYPE_REPEATER);
expect(avatar.text).toBe('🛜');
expect(avatar.background).toBe('#444444');
expect(avatar.textColor).toBe('#ffffff');
});
it('repeater avatar ignores name', () => {
const avatar1 = getContactAvatar('🚀 Rocket', 'abc123', CONTACT_TYPE_REPEATER);
const avatar2 = getContactAvatar(null, 'xyz789', CONTACT_TYPE_REPEATER);
expect(avatar1.text).toBe('🛜');
expect(avatar2.text).toBe('🛜');
expect(avatar1.background).toBe(avatar2.background);
});
it('non-repeater types use normal avatar', () => {
const avatar0 = getContactAvatar('John', 'abc123', 0);
const avatar1 = getContactAvatar('John', 'abc123', 1);
expect(avatar0.text).toBe('J');
expect(avatar1.text).toBe('J');
});
});
@@ -0,0 +1,139 @@
/**
* Tests for message deduplication in MessageList.
*
* Messages arriving via different packet paths should be deduplicated
* based on (type, conversation_key, text, sender_timestamp).
*/
import { describe, it, expect } from 'vitest';
import type { Message } from '../types';
/**
* Deduplication logic extracted from MessageList for testing.
* Same message via different paths = same (type, conversation_key, text, timestamp)
*/
function deduplicateMessages(messages: Message[]): Message[] {
return messages.reduce<Message[]>((acc, msg) => {
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
const existing = acc.find(m =>
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
);
if (!existing) {
acc.push(msg);
}
return acc;
}, []);
}
function createMessage(overrides: Partial<Message>): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
text: 'Test message',
sender_timestamp: 1700000000,
received_at: 1700000001,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: false,
...overrides,
};
}
describe('Message Deduplication', () => {
it('keeps unique messages', () => {
const messages = [
createMessage({ id: 1, text: 'Message 1', sender_timestamp: 1000 }),
createMessage({ id: 2, text: 'Message 2', sender_timestamp: 2000 }),
createMessage({ id: 3, text: 'Message 3', sender_timestamp: 3000 }),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(3);
});
it('deduplicates same channel message via different paths', () => {
const messages = [
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
createMessage({ id: 3, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1); // keeps first occurrence
});
it('keeps messages with same text but different timestamps', () => {
const messages = [
createMessage({ id: 1, text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, text: 'Hello', sender_timestamp: 2000 }), // different timestamp
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('keeps messages with same text but different channels', () => {
const messages = [
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, conversation_key: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', text: 'Hello', sender_timestamp: 1000 }), // different channel
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('deduplicates same DM via different paths', () => {
const messages = [
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }), // duplicate
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
});
it('keeps DMs from different senders with same text', () => {
const messages = [
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
createMessage({ id: 2, type: 'PRIV', conversation_key: 'def456789012345678901234567890123456789012345678901234567890', text: 'Hi', sender_timestamp: 1000 }),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('keeps channel message and DM with same text', () => {
const messages = [
createMessage({ id: 1, type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hello', sender_timestamp: 1000 }),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('handles empty array', () => {
const result = deduplicateMessages([]);
expect(result).toHaveLength(0);
});
it('handles single message', () => {
const messages = [createMessage({ id: 1 })];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests for message parsing utilities.
*
* These tests verify the sender extraction logic used to parse
* channel messages in "sender: message" format.
*/
import { describe, it, expect } from 'vitest';
import { parseSenderFromText, formatTime } from '../utils/messageParser';
import { getStateKey } from '../utils/conversationState';
describe('parseSenderFromText', () => {
it('extracts sender and content from "sender: message" format', () => {
const result = parseSenderFromText('Alice: Hello everyone!');
expect(result.sender).toBe('Alice');
expect(result.content).toBe('Hello everyone!');
});
it('handles sender names with spaces', () => {
const result = parseSenderFromText('Bob Smith: How are you?');
expect(result.sender).toBe('Bob Smith');
expect(result.content).toBe('How are you?');
});
it('returns null sender for plain messages without colon-space', () => {
const result = parseSenderFromText('Just a plain message');
expect(result.sender).toBeNull();
expect(result.content).toBe('Just a plain message');
});
it('returns null sender when colon has no space after', () => {
const result = parseSenderFromText('Note:this is not a sender');
expect(result.sender).toBeNull();
expect(result.content).toBe('Note:this is not a sender');
});
it('rejects sender containing square brackets', () => {
const result = parseSenderFromText('[System]: Alert message');
expect(result.sender).toBeNull();
expect(result.content).toBe('[System]: Alert message');
});
it('rejects sender containing colon', () => {
const result = parseSenderFromText('12:30: Time announcement');
expect(result.sender).toBeNull();
expect(result.content).toBe('12:30: Time announcement');
});
it('rejects sender names longer than 50 characters', () => {
const longName = 'A'.repeat(60);
const result = parseSenderFromText(`${longName}: message`);
expect(result.sender).toBeNull();
});
it('handles empty string', () => {
const result = parseSenderFromText('');
expect(result.sender).toBeNull();
expect(result.content).toBe('');
});
it('handles message with multiple colons', () => {
const result = parseSenderFromText('User: Check this URL: https://example.com');
expect(result.sender).toBe('User');
expect(result.content).toBe('Check this URL: https://example.com');
});
it('handles colon at start of message', () => {
const result = parseSenderFromText(': no sender here');
expect(result.sender).toBeNull();
expect(result.content).toBe(': no sender here');
});
});
describe('formatTime', () => {
it('formats today timestamp as time only', () => {
// Use current time to ensure it's "today"
const now = Math.floor(Date.now() / 1000);
const result = formatTime(now);
// Should be just time (HH:MM format)
expect(result).toMatch(/^\d{1,2}:\d{2}( [AP]M)?$/);
});
it('formats older timestamp with date and time', () => {
// Use a timestamp from 2023 (definitely not today)
const timestamp = 1700000000; // 2023-11-14
const result = formatTime(timestamp);
// Should contain month, day, and time
expect(result).toMatch(/\w+ \d{1,2}/); // e.g., "Nov 14"
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
});
});
describe('getStateKey', () => {
it('creates channel state key with full id', () => {
const key = getStateKey('channel', '5');
expect(key).toBe('channel-5');
});
it('creates contact state key with 12-char prefix', () => {
const fullKey = 'abcdef123456789012345678901234567890';
const key = getStateKey('contact', fullKey);
expect(key).toBe('contact-abcdef123456');
});
it('handles contact key shorter than 12 chars', () => {
const shortKey = 'abc123';
const key = getStateKey('contact', shortKey);
expect(key).toBe('contact-abc123');
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+177
View File
@@ -0,0 +1,177 @@
/**
* Tests for unread count tracking logic.
*
* These tests verify the unread message counting behavior
* without involving React component rendering.
*/
import { describe, it, expect } from 'vitest';
import type { Message, Conversation } from '../types';
import { getPubkeyPrefix, pubkeysMatch } from '../utils/pubkey';
/**
* Determine if a message should increment unread count.
* Extracted logic from App.tsx for testing.
*/
function shouldIncrementUnread(
msg: Message,
activeConversation: Conversation | null
): { key: string } | null {
// Only count incoming messages
if (msg.outgoing) {
return null;
}
if (msg.type === 'CHAN' && msg.conversation_key) {
const key = `channel-${msg.conversation_key}`;
// Don't count if this channel is active
if (activeConversation?.type === 'channel' && activeConversation?.id === msg.conversation_key) {
return null;
}
return { key };
}
if (msg.type === 'PRIV' && msg.conversation_key) {
// Use 12-char prefix for contact key
const key = `contact-${getPubkeyPrefix(msg.conversation_key)}`;
// Don't count if this contact is active (compare by prefix)
if (activeConversation?.type === 'contact' && pubkeysMatch(activeConversation.id, msg.conversation_key)) {
return null;
}
return { key };
}
return null;
}
/**
* Get unread count for a conversation from the counts map.
* Extracted logic from Sidebar.tsx for testing.
*/
function getUnreadCount(
type: 'channel' | 'contact',
id: string,
unreadCounts: Record<string, number>
): number {
if (type === 'channel') {
return unreadCounts[`channel-${id}`] || 0;
}
// For contacts, use prefix
const prefix = `contact-${getPubkeyPrefix(id)}`;
return unreadCounts[prefix] || 0;
}
describe('shouldIncrementUnread', () => {
const createMessage = (overrides: Partial<Message>): Message => ({
id: 1,
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
text: 'Test',
sender_timestamp: null,
received_at: Date.now(),
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: false,
...overrides,
});
it('returns key for incoming channel message when not viewing that channel', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', name: 'other' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
});
it('returns null for incoming channel message when viewing that channel', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', name: '#test' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toBeNull();
});
it('returns null for outgoing messages', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', outgoing: true });
const result = shouldIncrementUnread(msg, null);
expect(result).toBeNull();
});
it('returns key for incoming direct message when not viewing that contact', () => {
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
const activeConversation: Conversation = { type: 'contact', id: 'xyz999999999012345678901234567890123456789012345678901234567', name: 'other' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'contact-abc123456789' });
});
it('returns null for incoming direct message when viewing that contact', () => {
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
const activeConversation: Conversation = {
type: 'contact',
id: 'abc123456789fullkey12345678901234567890123456789012345678',
name: 'Alice',
};
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toBeNull();
});
it('returns key when no conversation is active', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
const result = shouldIncrementUnread(msg, null);
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
});
it('returns key when viewing raw packet feed', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
const activeConversation: Conversation = { type: 'raw', id: 'raw', name: 'Packets' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
});
});
describe('getUnreadCount', () => {
it('returns count for channel by exact key match', () => {
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
expect(getUnreadCount('channel', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', counts)).toBe(3);
});
it('returns 0 for channel with no unread', () => {
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
expect(getUnreadCount('channel', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9', counts)).toBe(0);
});
it('returns count for contact using 12-char prefix', () => {
const counts = { 'contact-abc123456789': 5 };
// Full public key lookup should match the prefix
expect(getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)).toBe(5);
});
it('handles contact key shorter than 12 chars', () => {
const counts = { 'contact-short': 2 };
expect(getUnreadCount('contact', 'short', counts)).toBe(2);
});
it('returns 0 for contact with no unread', () => {
const counts = { 'contact-abc123456789': 5 };
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* Tests for WebSocket message parsing.
*
* These tests verify that WebSocket messages are correctly parsed
* and routed to the appropriate handlers.
*/
import { describe, it, expect, vi } from 'vitest';
import type { HealthStatus, Contact, Channel, Message, RawPacket } from '../types';
/**
* Parse and route a WebSocket message.
* Extracted logic from useWebSocket.ts for testing.
*/
function parseWebSocketMessage(
data: string,
handlers: {
onHealth?: (health: HealthStatus) => void;
onContacts?: (contacts: Contact[]) => void;
onChannels?: (channels: Channel[]) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number) => void;
}
): { type: string; handled: boolean } {
try {
const msg = JSON.parse(data);
switch (msg.type) {
case 'health':
handlers.onHealth?.(msg.data as HealthStatus);
return { type: msg.type, handled: !!handlers.onHealth };
case 'contacts':
handlers.onContacts?.(msg.data as Contact[]);
return { type: msg.type, handled: !!handlers.onContacts };
case 'channels':
handlers.onChannels?.(msg.data as Channel[]);
return { type: msg.type, handled: !!handlers.onChannels };
case 'message':
handlers.onMessage?.(msg.data as Message);
return { type: msg.type, handled: !!handlers.onMessage };
case 'contact':
handlers.onContact?.(msg.data as Contact);
return { type: msg.type, handled: !!handlers.onContact };
case 'raw_packet':
handlers.onRawPacket?.(msg.data as RawPacket);
return { type: msg.type, handled: !!handlers.onRawPacket };
case 'message_acked':
handlers.onMessageAcked?.((msg.data as { message_id: number }).message_id);
return { type: msg.type, handled: !!handlers.onMessageAcked };
case 'pong':
return { type: msg.type, handled: true };
default:
return { type: msg.type, handled: false };
}
} catch {
return { type: 'error', handled: false };
}
}
describe('parseWebSocketMessage', () => {
it('routes health message to onHealth handler', () => {
const onHealth = vi.fn();
const data = JSON.stringify({
type: 'health',
data: { radio_connected: true, serial_port: '/dev/ttyUSB0' },
});
const result = parseWebSocketMessage(data, { onHealth });
expect(result.type).toBe('health');
expect(result.handled).toBe(true);
expect(onHealth).toHaveBeenCalledWith({
radio_connected: true,
serial_port: '/dev/ttyUSB0',
});
});
it('routes message_acked to onMessageAcked with message ID', () => {
const onMessageAcked = vi.fn();
const data = JSON.stringify({
type: 'message_acked',
data: { message_id: 42 },
});
const result = parseWebSocketMessage(data, { onMessageAcked });
expect(result.type).toBe('message_acked');
expect(result.handled).toBe(true);
expect(onMessageAcked).toHaveBeenCalledWith(42);
});
it('routes new message to onMessage handler', () => {
const onMessage = vi.fn();
const messageData = {
id: 123,
type: 'CHAN',
channel_idx: 0,
text: 'Hello',
received_at: 1700000000,
outgoing: false,
acked: false,
};
const data = JSON.stringify({ type: 'message', data: messageData });
const result = parseWebSocketMessage(data, { onMessage });
expect(result.type).toBe('message');
expect(result.handled).toBe(true);
expect(onMessage).toHaveBeenCalledWith(messageData);
});
it('handles pong messages silently', () => {
const data = JSON.stringify({ type: 'pong' });
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('pong');
expect(result.handled).toBe(true);
});
it('returns unhandled for unknown message types', () => {
const data = JSON.stringify({ type: 'unknown_type', data: {} });
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('unknown_type');
expect(result.handled).toBe(false);
});
it('handles invalid JSON gracefully', () => {
const data = 'not valid json {';
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('error');
expect(result.handled).toBe(false);
});
it('does not call handler when not provided', () => {
const data = JSON.stringify({
type: 'health',
data: { radio_connected: true },
});
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('health');
expect(result.handled).toBe(false);
});
it('routes raw_packet to onRawPacket handler', () => {
const onRawPacket = vi.fn();
const packetData = {
id: 1,
timestamp: 1700000000,
data: 'deadbeef',
payload_type: 'GROUP_TEXT',
decrypted: true,
decrypted_info: { channel_name: '#test', sender: 'Alice' },
};
const data = JSON.stringify({ type: 'raw_packet', data: packetData });
const result = parseWebSocketMessage(data, { onRawPacket });
expect(result.type).toBe('raw_packet');
expect(result.handled).toBe(true);
expect(onRawPacket).toHaveBeenCalledWith(packetData);
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Type aliases for key types used throughout the application.
* These are all hex strings but serve different purposes.
*/
/** 64-character hex string identifying a contact/node */
export type PublicKey = string;
/** 12-character hex prefix of a public key (used in message routing) */
export type PubkeyPrefix = string;
/** 32-character hex string identifying a channel */
export type ChannelKey = string;
export interface RadioSettings {
freq: number;
bw: number;
sf: number;
cr: number;
}
export interface RadioConfig {
public_key: string;
name: string;
lat: number;
lon: number;
tx_power: number;
max_tx_power: number;
radio: RadioSettings;
}
export interface RadioConfigUpdate {
name?: string;
lat?: number;
lon?: number;
tx_power?: number;
radio?: RadioSettings;
}
export interface HealthStatus {
status: string;
radio_connected: boolean;
serial_port: string | null;
}
export interface Contact {
public_key: PublicKey;
name: string | null;
type: number;
flags: number;
last_path: string | null;
last_path_len: number;
last_advert: number | null;
lat: number | null;
lon: number | null;
last_seen: number | null;
on_radio: boolean;
}
export interface Channel {
key: ChannelKey;
name: string;
is_hashtag: boolean;
on_radio: boolean;
}
export interface Message {
id: number;
type: 'PRIV' | 'CHAN';
/** For PRIV: sender's PublicKey (or prefix). For CHAN: ChannelKey */
conversation_key: string;
text: string;
sender_timestamp: number | null;
received_at: number;
path_len: number | null;
txt_type: number;
signature: string | null;
outgoing: boolean;
acked: boolean;
}
export type ConversationType = 'contact' | 'channel' | 'raw';
export interface Conversation {
type: ConversationType;
/** PublicKey for contacts, ChannelKey for channels, 'raw' for raw feed */
id: string;
name: string;
}
export interface RawPacket {
id: number;
timestamp: number;
data: string; // hex
payload_type: string;
snr: number | null; // Signal-to-noise ratio in dB
rssi: number | null; // Received signal strength in dBm
decrypted: boolean;
decrypted_info: {
channel_name: string | null;
sender: string | null;
} | null;
}
export interface AppSettings {
max_radio_contacts: number;
}
export interface AppSettingsUpdate {
max_radio_contacts?: number;
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { HealthStatus, Contact, Channel, Message, RawPacket } from './types';
interface WebSocketMessage {
type: string;
data: unknown;
}
interface ErrorEvent {
message: string;
details?: string;
}
interface UseWebSocketOptions {
onHealth?: (health: HealthStatus) => void;
onContacts?: (contacts: Contact[]) => void;
onChannels?: (channels: Channel[]) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number) => void;
onError?: (error: ErrorEvent) => void;
}
export function useWebSocket(options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const [connected, setConnected] = useState(false);
const connect = useCallback(() => {
// Determine WebSocket URL based on current location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// In development, connect directly to backend; in production, use same host
const isDev = window.location.port === '5173';
const wsUrl = isDev
? `ws://localhost:8000/api/ws`
: `${protocol}//${window.location.host}/api/ws`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
setConnected(true);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setConnected(false);
wsRef.current = null;
// Reconnect after 3 seconds
reconnectTimeoutRef.current = window.setTimeout(() => {
console.log('Attempting WebSocket reconnect...');
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onmessage = (event) => {
try {
const msg: WebSocketMessage = JSON.parse(event.data);
switch (msg.type) {
case 'health':
options.onHealth?.(msg.data as HealthStatus);
break;
case 'contacts':
options.onContacts?.(msg.data as Contact[]);
break;
case 'channels':
options.onChannels?.(msg.data as Channel[]);
break;
case 'message':
options.onMessage?.(msg.data as Message);
break;
case 'contact':
options.onContact?.(msg.data as Contact);
break;
case 'raw_packet':
options.onRawPacket?.(msg.data as RawPacket);
break;
case 'message_acked':
options.onMessageAcked?.((msg.data as { message_id: number }).message_id);
break;
case 'error':
options.onError?.(msg.data as ErrorEvent);
break;
case 'pong':
// Heartbeat response, ignore
break;
default:
console.log('Unknown WebSocket message type:', msg.type);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
wsRef.current = ws;
}, [options]);
useEffect(() => {
connect();
// Ping every 30 seconds to keep connection alive
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send('ping');
}
}, 30000);
return () => {
clearInterval(pingInterval);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
return { connected };
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Generate consistent profile "images" for contacts.
*
* Uses the contact's public key to generate a consistent background color,
* and extracts initials or emoji from the name for display.
* Repeaters (type=2) always show 🛜 with a gray background.
*/
// Contact type constants (matches backend)
export const CONTACT_TYPE_REPEATER = 2;
// Repeater avatar styling
const REPEATER_AVATAR = {
text: '🛜',
background: '#444444',
textColor: '#ffffff',
};
// Simple hash function for strings
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
// Regex to match emoji (covers most common emoji ranges)
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
/**
* Extract display characters from a contact name.
* Priority:
* 1. First emoji in the name
* 2. First letter + first letter after first space (initials)
* 3. First letter only
*/
export function getAvatarText(name: string | null, publicKey: string): string {
if (!name) {
// Use first 2 chars of public key as fallback
return publicKey.slice(0, 2).toUpperCase();
}
// Check for emoji first
const emojiMatch = name.match(emojiRegex);
if (emojiMatch) {
return emojiMatch[0];
}
// Find first letter
const letters = name.match(/[a-zA-Z]/g);
if (!letters || letters.length === 0) {
// No letters, use first 2 chars of public key
return publicKey.slice(0, 2).toUpperCase();
}
// Check for space - get initials
const spaceIndex = name.indexOf(' ');
if (spaceIndex !== -1) {
const firstLetter = letters[0];
// Find first letter after the space
const afterSpace = name.slice(spaceIndex + 1).match(/[a-zA-Z]/);
if (afterSpace) {
return (firstLetter + afterSpace[0]).toUpperCase();
}
}
// Single letter
return letters[0].toUpperCase();
}
/**
* Generate a consistent HSL color from a public key.
* Uses saturation and lightness ranges that work well for backgrounds.
*/
export function getAvatarColor(publicKey: string): {
background: string;
text: string;
} {
const hash = hashString(publicKey);
// Use hash to generate hue (0-360)
const hue = hash % 360;
// Use different bits of hash for saturation variation (50-80%)
const saturation = 50 + ((hash >> 8) % 30);
// Lightness in a range that allows readable text (35-55%)
const lightness = 35 + ((hash >> 16) % 20);
const background = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// Calculate perceived luminance to determine text color
// For HSL, we can approximate: if lightness < 50%, use white text
// We'll use a slightly lower threshold since saturated colors appear darker
const textColor = lightness < 45 ? '#ffffff' : '#000000';
return { background, text: textColor };
}
/**
* Get all avatar properties for a contact.
* Repeaters (type=2) always get a special gray avatar with 🛜.
*/
export function getContactAvatar(
name: string | null,
publicKey: string,
contactType?: number
): {
text: string;
background: string;
textColor: string;
} {
// Repeaters always get the repeater avatar
if (contactType === CONTACT_TYPE_REPEATER) {
return REPEATER_AVATAR;
}
const text = getAvatarText(name, publicKey);
const colors = getAvatarColor(publicKey);
return {
text,
background: colors.background,
textColor: colors.text,
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* localStorage utilities for tracking conversation read/message state.
*
* Stores two maps:
* - lastMessageTime: when each conversation last received a message
* - lastReadTime: when the user last viewed each conversation
*
* A conversation has unread messages if lastMessageTime > lastReadTime.
*/
import { getPubkeyPrefix } from './pubkey';
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const LAST_READ_KEY = 'remoteterm-lastReadTime';
export type ConversationTimes = Record<string, number>;
function loadTimes(key: string): ConversationTimes {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function saveTimes(key: string, times: ConversationTimes): void {
try {
localStorage.setItem(key, JSON.stringify(times));
} catch {
// localStorage might be full or disabled
}
}
export function getLastMessageTimes(): ConversationTimes {
return loadTimes(LAST_MESSAGE_KEY);
}
export function getLastReadTimes(): ConversationTimes {
return loadTimes(LAST_READ_KEY);
}
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_MESSAGE_KEY);
// Only update if this is a newer message
if (!times[stateKey] || timestamp > times[stateKey]) {
times[stateKey] = timestamp;
saveTimes(LAST_MESSAGE_KEY, times);
}
return times;
}
export function setLastReadTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_READ_KEY);
times[stateKey] = timestamp;
saveTimes(LAST_READ_KEY, times);
return times;
}
/**
* Generate a state tracking key for unread counts and message times.
*
* This is NOT the same as Message.conversation_key (the database field).
* This creates prefixed keys for localStorage/state tracking:
* - Channels: "channel-{channelKey}"
* - Contacts: "contact-{12-char-pubkey-prefix}"
*
* The 12-char prefix for contacts ensures consistent matching regardless
* of whether we have a full 64-char pubkey or just a prefix.
*/
export function getStateKey(
type: 'channel' | 'contact',
id: string
): string {
if (type === 'channel') {
return `channel-${id}`;
}
// For contacts, use 12-char prefix for consistent matching
return `contact-${getPubkeyPrefix(id)}`;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Parse sender from channel message text.
* Channel messages have format "sender: message".
*/
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
if (colonIndex > 0 && colonIndex < 50) {
const potentialSender = text.substring(0, colonIndex);
// Check for invalid characters that would indicate it's not a sender
if (!/[:\[\]]/.test(potentialSender)) {
return {
sender: potentialSender,
content: text.substring(colonIndex + 2),
};
}
}
return { sender: null, content: text };
}
/**
* Format a Unix timestamp to a time string.
* Shows date for messages not from today.
*/
export function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) {
return time;
}
// Show short date for older messages
const dateStr = date.toLocaleDateString([], { month: 'short', day: 'numeric' });
return `${dateStr} ${time}`;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Public key utilities for consistent handling of 64-char full keys
* and 12-char prefixes throughout the application.
*
* MeshCore uses 64-character hex strings for public keys, but messages
* and some radio operations only provide 12-character prefixes. This
* module provides utilities for working with both formats consistently.
*/
/** Length of a full public key in hex characters */
export const PUBKEY_FULL_LENGTH = 64;
/** Length of a public key prefix in hex characters */
export const PUBKEY_PREFIX_LENGTH = 12;
/**
* Extract the 12-character prefix from a public key.
* Works with both full keys and existing prefixes.
*/
export function getPubkeyPrefix(key: string): string {
return key.slice(0, PUBKEY_PREFIX_LENGTH);
}
/**
* Check if two public keys match by comparing their prefixes.
* This handles the case where one key is full (64 chars) and
* the other is a prefix (12 chars).
*/
export function pubkeysMatch(a: string, b: string): boolean {
if (!a || !b) return false;
return getPubkeyPrefix(a) === getPubkeyPrefix(b);
}
/**
* Check if a public key starts with the given prefix.
* More explicit than using .startsWith() directly.
*/
export function pubkeyMatchesPrefix(fullKey: string, prefix: string): boolean {
if (!fullKey || !prefix) return false;
return fullKey.startsWith(prefix);
}
/**
* Get a display name for a contact, falling back to pubkey prefix.
*/
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
return name || getPubkeyPrefix(pubkey);
}
/**
* Check if a key is a full 64-character public key.
*/
export function isFullPubkey(key: string): boolean {
return key.length === PUBKEY_FULL_LENGTH;
}
/**
* Check if a key is a 12-character prefix.
*/
export function isPubkeyPrefix(key: string): boolean {
return key.length === PUBKEY_PREFIX_LENGTH;
}
+53
View File
@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
import path from "path"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: 'dist',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
watch: {
usePolling: true,
},
},
})
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
+26
View File
@@ -0,0 +1,26 @@
[project]
name = "remoteterm-meshcore"
version = "0.1.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"pydantic-settings>=2.0.0",
"aiosqlite>=0.19.0",
"pycryptodome>=3.20.0",
"pynacl>=1.5.0",
"meshcore",
]
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.27.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+16
View File
@@ -0,0 +1,16 @@
"""Pytest configuration and shared fixtures."""
import pytest
@pytest.fixture
def sample_channel_key():
"""A sample 16-byte channel key for testing."""
return bytes.fromhex("0123456789abcdef0123456789abcdef")
@pytest.fixture
def sample_hashtag_key():
"""A channel key derived from hashtag name '#test'."""
import hashlib
return hashlib.sha256(b"#test").digest()[:16]
+185
View File
@@ -0,0 +1,185 @@
"""Tests for API endpoints.
These tests verify the REST API behavior for critical operations.
Uses FastAPI's TestClient for synchronous testing.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
class TestHealthEndpoint:
"""Test the health check endpoint."""
def test_health_returns_connection_status(self):
"""Health endpoint returns radio connection status."""
from fastapi.testclient import TestClient
with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.port = "/dev/ttyUSB0"
from app.main import app
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["radio_connected"] is True
assert data["serial_port"] == "/dev/ttyUSB0"
def test_health_disconnected_state(self):
"""Health endpoint reflects disconnected radio."""
from fastapi.testclient import TestClient
with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.port = None
from app.main import app
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["radio_connected"] is False
assert data["serial_port"] is None
class TestMessagesEndpoint:
"""Test message-related endpoints."""
def test_send_direct_message_requires_connection(self):
"""Sending message when disconnected returns 503."""
from fastapi.testclient import TestClient
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
from app.main import app
client = TestClient(app)
response = client.post(
"/messages/direct",
json={"destination": "abc123", "text": "Hello"}
)
assert response.status_code == 503
assert "not connected" in response.json()["detail"].lower()
def test_send_channel_message_requires_connection(self):
"""Sending channel message when disconnected returns 503."""
from fastapi.testclient import TestClient
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
from app.main import app
client = TestClient(app)
response = client.post(
"/messages/channel",
json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"}
)
assert response.status_code == 503
def test_send_direct_message_contact_not_found(self):
"""Sending to unknown contact returns 404."""
from fastapi.testclient import TestClient
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix.return_value = None
with patch("app.dependencies.radio_manager") as mock_rm, \
patch("app.repository.ContactRepository.get_by_key_or_prefix", new_callable=AsyncMock) as mock_get:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
mock_get.return_value = None
from app.main import app
client = TestClient(app)
response = client.post(
"/messages/direct",
json={"destination": "nonexistent", "text": "Hello"}
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
class TestChannelsEndpoint:
"""Test channel-related endpoints."""
@pytest.mark.asyncio
async def test_create_hashtag_channel_derives_key(self):
"""Creating hashtag channel derives key from name and stores in DB."""
import hashlib
from app.routers.channels import create_channel, CreateChannelRequest
with patch("app.routers.channels.ChannelRepository") as mock_repo:
mock_repo.upsert = AsyncMock()
request = CreateChannelRequest(name="#mychannel")
result = await create_channel(request)
# Verify the key derivation - channel stored in DB, not pushed to radio
expected_key_hex = hashlib.sha256(b"#mychannel").digest()[:16].hex().upper()
mock_repo.upsert.assert_called_once()
call_args = mock_repo.upsert.call_args
assert call_args[1]["key"] == expected_key_hex
assert call_args[1]["name"] == "#mychannel"
assert call_args[1]["is_hashtag"] is True
assert call_args[1]["on_radio"] is False # Not pushed to radio on create
# Verify response
assert result.key == expected_key_hex
assert result.name == "#mychannel"
@pytest.mark.asyncio
async def test_create_channel_with_explicit_key(self):
"""Creating channel with explicit key uses provided key."""
from app.routers.channels import create_channel, CreateChannelRequest
with patch("app.routers.channels.ChannelRepository") as mock_repo:
mock_repo.upsert = AsyncMock()
explicit_key = "0123456789abcdef0123456789abcdef" # 32 hex chars = 16 bytes
request = CreateChannelRequest(name="private", key=explicit_key)
result = await create_channel(request)
# Verify key stored in DB correctly (stored as uppercase hex)
mock_repo.upsert.assert_called_once()
call_args = mock_repo.upsert.call_args
assert call_args[1]["key"] == explicit_key.upper()
assert call_args[1]["name"] == "private"
assert call_args[1]["on_radio"] is False
# Verify response
assert result.key == explicit_key.upper()
class TestPacketsEndpoint:
"""Test packet decryption endpoints."""
def test_get_undecrypted_count(self):
"""Get undecrypted packet count returns correct value."""
from fastapi.testclient import TestClient
with patch("app.routers.packets.RawPacketRepository") as mock_repo:
mock_repo.get_undecrypted_count = AsyncMock(return_value=42)
from app.main import app
client = TestClient(app)
response = client.get("/packets/undecrypted/count")
assert response.status_code == 200
assert response.json()["count"] == 42
+302
View File
@@ -0,0 +1,302 @@
"""Tests for the packet decoder module.
These tests verify the cryptographic operations for MeshCore packet decryption,
which is critical for correctly interpreting mesh network messages.
"""
import hashlib
import hmac
import pytest
from Crypto.Cipher import AES
from app.decoder import (
DecryptedGroupText,
PacketInfo,
PayloadType,
RouteType,
calculate_channel_hash,
decrypt_group_text,
parse_packet,
try_decrypt_packet_with_channel_key,
)
class TestChannelKeyDerivation:
"""Test channel key derivation from hashtag names."""
def test_hashtag_key_derivation(self):
"""Hashtag channel keys are derived as SHA256(name)[:16]."""
channel_name = "#test"
expected_key = hashlib.sha256(channel_name.encode("utf-8")).digest()[:16]
# This matches the meshcore_py implementation
assert len(expected_key) == 16
def test_channel_hash_calculation(self):
"""Channel hash is the first byte of SHA256(key) as hex."""
key = bytes(16) # All zeros
expected_hash = format(hashlib.sha256(key).digest()[0], "02x")
result = calculate_channel_hash(key)
assert result == expected_hash
assert len(result) == 2 # Two hex chars
class TestPacketParsing:
"""Test raw packet header parsing."""
def test_parse_flood_packet(self):
"""Parse a FLOOD route type GROUP_TEXT packet."""
# Header: route_type=FLOOD(1), payload_type=GROUP_TEXT(5), version=0
# Header byte = (0 << 6) | (5 << 2) | 1 = 0x15
# Path length = 0
header = bytes([0x15, 0x00]) + b"payload_data"
result = parse_packet(header)
assert result is not None
assert result.route_type == RouteType.FLOOD
assert result.payload_type == PayloadType.GROUP_TEXT
assert result.path_length == 0
assert result.payload == b"payload_data"
def test_parse_direct_packet_with_path(self):
"""Parse a DIRECT route type packet with path data."""
# Header: route_type=DIRECT(2), payload_type=TEXT_MESSAGE(2), version=0
# Header byte = (0 << 6) | (2 << 2) | 2 = 0x0A
# Path length = 3, path = [0x01, 0x02, 0x03]
header = bytes([0x0A, 0x03, 0x01, 0x02, 0x03]) + b"msg"
result = parse_packet(header)
assert result is not None
assert result.route_type == RouteType.DIRECT
assert result.payload_type == PayloadType.TEXT_MESSAGE
assert result.path_length == 3
assert result.payload == b"msg"
def test_parse_transport_flood_skips_transport_code(self):
"""TRANSPORT_FLOOD packets have 4-byte transport code to skip."""
# Header: route_type=TRANSPORT_FLOOD(0), payload_type=GROUP_TEXT(5)
# Header byte = (0 << 6) | (5 << 2) | 0 = 0x14
# Transport code (4 bytes) + path_length + payload
header = bytes([0x14, 0xAA, 0xBB, 0xCC, 0xDD, 0x00]) + b"data"
result = parse_packet(header)
assert result is not None
assert result.route_type == RouteType.TRANSPORT_FLOOD
assert result.payload_type == PayloadType.GROUP_TEXT
assert result.payload == b"data"
def test_parse_empty_packet_returns_none(self):
"""Empty packets return None."""
assert parse_packet(b"") is None
assert parse_packet(b"\x00") is None
def test_parse_truncated_packet_returns_none(self):
"""Truncated packets return None."""
# Packet claiming path_length=10 but no path data
header = bytes([0x15, 0x0A])
assert parse_packet(header) is None
class TestGroupTextDecryption:
"""Test GROUP_TEXT (channel message) decryption."""
def _create_encrypted_payload(
self, channel_key: bytes, timestamp: int, flags: int, message: str
) -> bytes:
"""Helper to create a valid encrypted GROUP_TEXT payload."""
# Build plaintext: timestamp(4) + flags(1) + message + null terminator
plaintext = (
timestamp.to_bytes(4, "little")
+ bytes([flags])
+ message.encode("utf-8")
+ b"\x00"
)
# Pad to 16-byte boundary
pad_len = (16 - len(plaintext) % 16) % 16
if pad_len == 0:
pad_len = 16
plaintext += bytes(pad_len)
# Encrypt with AES-128 ECB
cipher = AES.new(channel_key, AES.MODE_ECB)
ciphertext = cipher.encrypt(plaintext)
# Calculate MAC: HMAC-SHA256(channel_secret, ciphertext)[:2]
channel_secret = channel_key + bytes(16)
mac = hmac.new(channel_secret, ciphertext, hashlib.sha256).digest()[:2]
# Build payload: channel_hash(1) + mac(2) + ciphertext
channel_hash = hashlib.sha256(channel_key).digest()[0:1]
return channel_hash + mac + ciphertext
def test_decrypt_valid_message(self):
"""Decrypt a valid GROUP_TEXT message."""
channel_key = hashlib.sha256(b"#testchannel").digest()[:16]
timestamp = 1700000000
message = "TestUser: Hello world"
payload = self._create_encrypted_payload(channel_key, timestamp, 0, message)
result = decrypt_group_text(payload, channel_key)
assert result is not None
assert result.timestamp == timestamp
assert result.sender == "TestUser"
assert result.message == "Hello world"
def test_decrypt_message_without_sender_prefix(self):
"""Messages without 'sender: ' format have no parsed sender."""
channel_key = hashlib.sha256(b"#test").digest()[:16]
message = "Just a plain message"
payload = self._create_encrypted_payload(channel_key, 1234567890, 0, message)
result = decrypt_group_text(payload, channel_key)
assert result is not None
assert result.sender is None
assert result.message == "Just a plain message"
def test_decrypt_with_wrong_key_fails(self):
"""Decryption with wrong key fails MAC verification."""
correct_key = hashlib.sha256(b"#correct").digest()[:16]
wrong_key = hashlib.sha256(b"#wrong").digest()[:16]
payload = self._create_encrypted_payload(correct_key, 1234567890, 0, "test")
result = decrypt_group_text(payload, wrong_key)
assert result is None
def test_decrypt_corrupted_mac_fails(self):
"""Corrupted MAC causes decryption to fail."""
channel_key = hashlib.sha256(b"#test").digest()[:16]
payload = self._create_encrypted_payload(channel_key, 1234567890, 0, "test")
# Corrupt the MAC (bytes 1-2)
corrupted = payload[:1] + bytes([payload[1] ^ 0xFF, payload[2] ^ 0xFF]) + payload[3:]
result = decrypt_group_text(corrupted, channel_key)
assert result is None
class TestTryDecryptPacket:
"""Test the full packet decryption pipeline."""
def test_only_group_text_packets_decrypted(self):
"""Non-GROUP_TEXT packets return None."""
# TEXT_MESSAGE packet (payload_type=2)
# Header: route_type=FLOOD(1), payload_type=TEXT_MESSAGE(2)
# Header byte = (0 << 6) | (2 << 2) | 1 = 0x09
packet = bytes([0x09, 0x00]) + b"some_data"
key = bytes(16)
result = try_decrypt_packet_with_channel_key(packet, key)
assert result is None
def test_channel_hash_mismatch_returns_none(self):
"""Packets with non-matching channel hash return None early."""
# GROUP_TEXT packet with channel_hash that doesn't match our key
# Header: route_type=FLOOD(1), payload_type=GROUP_TEXT(5)
# Header byte = 0x15
wrong_hash = bytes([0xFF]) # Unlikely to match any real key
packet = bytes([0x15, 0x00]) + wrong_hash + bytes(20)
key = hashlib.sha256(b"#test").digest()[:16]
result = try_decrypt_packet_with_channel_key(packet, key)
assert result is None
class TestRealWorldPackets:
"""Test with real captured packets to ensure decoder matches protocol."""
def test_decrypt_six77_channel_message(self):
"""Decrypt a real packet from #six77 channel."""
# Real packet captured from #six77 hashtag channel
packet_hex = (
"1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D"
"99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D35182"
"83156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF"
"0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D"
"07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818"
)
packet = bytes.fromhex(packet_hex)
# Verify key derivation: SHA256("#six77")[:16]
channel_key = hashlib.sha256(b"#six77").digest()[:16]
assert channel_key.hex() == "7aba109edcf304a84433cb71d0f3ab73"
# Decrypt the packet
result = try_decrypt_packet_with_channel_key(packet, channel_key)
assert result is not None
assert result.sender == "Flightless🥝"
assert "hashtag room is essentially public" in result.message
assert result.channel_hash == "e6"
assert result.timestamp == 1766604717
class TestAdvertisementParsing:
"""Test parsing of advertisement packets."""
def test_parse_real_advertisement(self):
"""Parse a real advertisement packet from 'Flightless 🥝'."""
from app.decoder import try_parse_advertisement
# Real advertisement packet
packet_hex = (
"1200AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A83"
"54B55C6934EAC9C9BD98A99788B1725379BB25863731ADAB605BCD62F0BA0E467483"
"E0A21E81C9279665D117B265B192890B8E0C2AE03E48DA5AA28C3EFB842EF656670B"
"915128D902B72DB5F8466C696768746C65737320F09FA59D"
)
packet = bytes.fromhex(packet_hex)
result = try_parse_advertisement(packet)
assert result is not None
# Public key is the first 32 bytes of payload
assert result.public_key == "ae92564c5c9884854f04f469bbb2bab8871a078053af6cf4aa2c014b18ce8a83"
# Name should be extracted from the end
assert result.name == "Flightless 🥝"
def test_parse_advertisement_extracts_public_key(self):
"""Advertisement parsing extracts the public key correctly."""
from app.decoder import parse_packet, PayloadType
packet_hex = (
"1200AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A83"
"54B55C6934EAC9C9BD98A99788B1725379BB25863731ADAB605BCD62F0BA0E467483"
"E0A21E81C9279665D117B265B192890B8E0C2AE03E48DA5AA28C3EFB842EF656670B"
"915128D902B72DB5F8466C696768746C65737320F09FA59D"
)
packet = bytes.fromhex(packet_hex)
# Verify packet is recognized as ADVERT type
info = parse_packet(packet)
assert info is not None
assert info.payload_type == PayloadType.ADVERT
def test_non_advertisement_returns_none(self):
"""Non-advertisement packets return None from try_parse_advertisement."""
from app.decoder import try_parse_advertisement
# GROUP_TEXT packet, not an advertisement
packet = bytes([0x15, 0x00]) + bytes(50)
result = try_parse_advertisement(packet)
assert result is None
+195
View File
@@ -0,0 +1,195 @@
"""Tests for event handler logic.
These tests verify the ACK tracking and repeat detection mechanisms
that determine message delivery confirmation.
"""
import time
from unittest.mock import AsyncMock, patch
import pytest
from app.event_handlers import (
_cleanup_expired_acks,
_pending_acks,
track_pending_ack,
)
from app.packet_processor import (
_cleanup_expired_repeats,
_pending_repeat_expiry,
_pending_repeats,
track_pending_repeat,
)
@pytest.fixture(autouse=True)
def clear_pending_state():
"""Clear pending ACKs and repeats before each test."""
_pending_acks.clear()
_pending_repeats.clear()
_pending_repeat_expiry.clear()
yield
_pending_acks.clear()
_pending_repeats.clear()
_pending_repeat_expiry.clear()
class TestAckTracking:
"""Test ACK tracking for direct messages."""
def test_track_pending_ack_stores_correctly(self):
"""Pending ACKs are stored with message ID and timeout."""
track_pending_ack("abc123", message_id=42, timeout_ms=5000)
assert "abc123" in _pending_acks
msg_id, created_at, timeout = _pending_acks["abc123"]
assert msg_id == 42
assert timeout == 5000
assert created_at <= time.time()
def test_multiple_acks_tracked_independently(self):
"""Multiple pending ACKs can be tracked simultaneously."""
track_pending_ack("ack1", message_id=1, timeout_ms=1000)
track_pending_ack("ack2", message_id=2, timeout_ms=2000)
track_pending_ack("ack3", message_id=3, timeout_ms=3000)
assert len(_pending_acks) == 3
assert _pending_acks["ack1"][0] == 1
assert _pending_acks["ack2"][0] == 2
assert _pending_acks["ack3"][0] == 3
def test_cleanup_removes_expired_acks(self):
"""Expired ACKs are removed during cleanup."""
# Add an ACK that's "expired" (created in the past with short timeout)
_pending_acks["expired"] = (1, time.time() - 100, 1000) # Created 100s ago, 1s timeout
_pending_acks["valid"] = (2, time.time(), 60000) # Created now, 60s timeout
_cleanup_expired_acks()
assert "expired" not in _pending_acks
assert "valid" in _pending_acks
def test_cleanup_uses_2x_timeout_buffer(self):
"""Cleanup uses 2x timeout as buffer before expiring."""
# ACK created 5 seconds ago with 10 second timeout
# 2x buffer = 20 seconds, so should NOT be expired yet
_pending_acks["recent"] = (1, time.time() - 5, 10000)
_cleanup_expired_acks()
assert "recent" in _pending_acks
class TestRepeatTracking:
"""Test repeat tracking for channel/flood messages."""
def test_track_pending_repeat_stores_correctly(self):
"""Pending repeats are stored with channel key, text hash, and timestamp."""
channel_key = "0123456789ABCDEF0123456789ABCDEF"
track_pending_repeat(channel_key=channel_key, text="Hello", timestamp=1700000000, message_id=99)
# Key is (channel_key, text_hash, timestamp)
text_hash = str(hash("Hello"))
key = (channel_key, text_hash, 1700000000)
assert key in _pending_repeats
assert _pending_repeats[key] == 99
def test_same_message_different_channels_tracked_separately(self):
"""Same message on different channels creates separate entries."""
track_pending_repeat(channel_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1", text="Test", timestamp=1000, message_id=1)
track_pending_repeat(channel_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2", text="Test", timestamp=1000, message_id=2)
assert len(_pending_repeats) == 2
def test_same_message_different_timestamps_tracked_separately(self):
"""Same message with different timestamps creates separate entries."""
channel_key = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
track_pending_repeat(channel_key=channel_key, text="Test", timestamp=1000, message_id=1)
track_pending_repeat(channel_key=channel_key, text="Test", timestamp=1001, message_id=2)
assert len(_pending_repeats) == 2
def test_cleanup_removes_old_repeats(self):
"""Expired repeats are removed during cleanup."""
channel_key = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"
text_hash = str(hash("test"))
old_key = (channel_key, text_hash, 1000)
new_key = (channel_key, text_hash, 2000)
# Set up entries with expiry times
_pending_repeats[old_key] = 1
_pending_repeats[new_key] = 2
_pending_repeat_expiry[old_key] = time.time() - 10 # Already expired
_pending_repeat_expiry[new_key] = time.time() + 30 # Still valid
_cleanup_expired_repeats()
assert old_key not in _pending_repeats
assert new_key in _pending_repeats
class TestAckEventHandler:
"""Test the on_ack event handler."""
@pytest.mark.asyncio
async def test_ack_matches_pending_message(self):
"""Matching ACK code updates message and broadcasts."""
from app.event_handlers import on_ack
# Setup pending ACK
track_pending_ack("deadbeef", message_id=123, timeout_ms=10000)
# Mock dependencies
with patch("app.event_handlers.MessageRepository") as mock_repo, \
patch("app.event_handlers.broadcast_event") as mock_broadcast:
mock_repo.mark_acked = AsyncMock()
# Create mock event
class MockEvent:
payload = {"code": "deadbeef"}
await on_ack(MockEvent())
# Verify message marked as acked
mock_repo.mark_acked.assert_called_once_with(123)
# Verify broadcast sent
mock_broadcast.assert_called_once_with("message_acked", {"message_id": 123})
# Verify pending ACK removed
assert "deadbeef" not in _pending_acks
@pytest.mark.asyncio
async def test_ack_no_match_does_nothing(self):
"""Non-matching ACK code is ignored."""
from app.event_handlers import on_ack
track_pending_ack("expected", message_id=1, timeout_ms=10000)
with patch("app.event_handlers.MessageRepository") as mock_repo, \
patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {"code": "different"}
await on_ack(MockEvent())
mock_repo.mark_acked.assert_not_called()
mock_broadcast.assert_not_called()
assert "expected" in _pending_acks
@pytest.mark.asyncio
async def test_ack_empty_code_ignored(self):
"""ACK with empty code is ignored."""
from app.event_handlers import on_ack
with patch("app.event_handlers.MessageRepository") as mock_repo:
mock_repo.mark_acked = AsyncMock()
class MockEvent:
payload = {"code": ""}
await on_ack(MockEvent())
mock_repo.mark_acked.assert_not_called()
Generated
+1415
View File
File diff suppressed because it is too large Load Diff