Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d825a07f8 | ||
|
|
c5fec61123 | ||
|
|
9437959568 | ||
|
|
d1846b102d | ||
|
|
5cd28bd1d9 | ||
|
|
d48595e082 | ||
|
|
1f3a1e5b3f | ||
|
|
c2bcfbf646 | ||
|
|
28d57924ee | ||
|
|
f83681188c | ||
|
|
3acb0efc62 | ||
|
|
1961b4c9e2 | ||
|
|
de47c7c228 | ||
|
|
4f610a329a | ||
|
|
019092ed7d | ||
|
|
31bccfb957 | ||
|
|
fcbab3bf72 | ||
|
|
878626b440 | ||
|
|
01a97b57c0 | ||
|
|
e48e122bbd | ||
|
|
c6ee92fb66 | ||
|
|
f338ddbc87 | ||
|
|
20255b3edf | ||
|
|
db550faab0 | ||
|
|
7ec4151d6c | ||
|
|
989f47f80d | ||
|
|
f8b05bb34d | ||
|
|
ea5283dd43 | ||
|
|
f004d80cc8 | ||
|
|
08f7407837 | ||
|
|
6ce59eee33 | ||
|
|
63bac7af1b | ||
|
|
b7a06c732e | ||
|
|
86ead4f29f | ||
|
|
67a6a0727f | ||
|
|
cc12997672 | ||
|
|
225c892847 | ||
|
|
bc6cff5d87 | ||
|
|
6bdc1ecefb | ||
|
|
31e31ee7da | ||
|
|
63604aee14 | ||
|
|
f2b685bbf5 | ||
|
|
0b0d14bb20 | ||
|
|
cf1107f736 | ||
|
|
e2b4d7b8fe | ||
|
|
401c7d3c0e | ||
|
|
340143e3e9 | ||
|
|
63e9fbda70 | ||
|
|
be711af607 | ||
|
|
9e47283976 | ||
|
|
b5c1f30b28 | ||
|
|
7fcc510e64 | ||
|
|
456fb7afb4 | ||
|
|
ec9e2c29bb | ||
|
|
9a79bdd27a | ||
|
|
5f9df98a73 | ||
|
|
2dca8519ce | ||
|
|
81973320a3 | ||
|
|
a0abcceec9 | ||
|
|
2f07ee3bd7 | ||
|
|
2f509e7dd5 | ||
|
|
8259202f96 | ||
|
|
2c668c1852 | ||
|
|
e04664d037 | ||
|
|
5da4eac866 | ||
|
|
bffb5e5e6a | ||
|
|
b22a7c0b58 | ||
|
|
d3961f7ef4 |
3
.gitignore
vendored
@@ -13,9 +13,8 @@ frontend/test-results/
|
||||
# Frontend build output (built from source by end users)
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
frontend/.eslintcache
|
||||
|
||||
# reference libraries
|
||||
# reference librarys
|
||||
references/
|
||||
|
||||
# ancillary LLM files
|
||||
|
||||
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
189
AGENTS.md
@@ -4,17 +4,23 @@
|
||||
|
||||
**NEVER make git commits.** A human must make all commits. You may stage files and prepare commit messages, but do not run `git commit`.
|
||||
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, make sure you run the following and that they all pass green:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
uv run ruff check app/ tests/ --fix # check for python violations
|
||||
uv run ruff format app/ tests/ # format python
|
||||
uv run pyright app/ # type check python
|
||||
PYTHONPATH=. uv run pytest tests/ -v # test python
|
||||
|
||||
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green.
|
||||
cd frontend/ # move to frontend directory
|
||||
npm run lint:fix # fix lint violations
|
||||
npm run format # format the code
|
||||
npm run build # run a frontend build
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over Serial, TCP, or BLE and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
|
||||
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/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
|
||||
@@ -48,78 +54,29 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
│ │ RadioManager + Event Handlers │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ Serial / TCP / BLE
|
||||
│ Serial
|
||||
┌──────┴──────┐
|
||||
│ MeshCore │
|
||||
│ Radio │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Feature Priority
|
||||
|
||||
**Primary (must work correctly):**
|
||||
- Sending and receiving direct messages and channel messages
|
||||
- Accurate message display: correct ordering, deduplication, pagination/history loading, and real-time updates without data loss or duplicates
|
||||
- Accurate ACK tracking, repeat/echo counting, and path display
|
||||
- Historical packet decryption (recovering incoming messages using newly-added keys)
|
||||
- Outgoing DMs are stored as plaintext by the send endpoint — no decryption needed
|
||||
|
||||
**Secondary:**
|
||||
- Channel key cracker (WebGPU brute-force)
|
||||
- Repeater management (telemetry, CLI commands, ACL)
|
||||
|
||||
**Tertiary (best-effort, quality-of-life):**
|
||||
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
|
||||
- Map view — visual display of node locations from advertisements
|
||||
- Network visualizer — force-directed graph of mesh topology
|
||||
- Bot system — automated message responses
|
||||
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
|
||||
|
||||
## Error Handling Philosophy
|
||||
|
||||
**Background tasks** (WebSocket broadcasts, periodic sync, contact auto-loading, etc.) use fire-and-forget `asyncio.create_task`. Exceptions in these tasks are logged to the backend logs, which is sufficient for debugging. There is no need to track task references or add done-callbacks purely for error visibility. If there's a convenient way to bubble an error to the frontend (e.g., via `broadcast_error` for user-actionable problems), do so, but this is minor and best-effort.
|
||||
|
||||
## 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; optional MQTT forwarding
|
||||
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
|
||||
|
||||
## Intentional Security Design Decisions
|
||||
|
||||
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
|
||||
|
||||
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
|
||||
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this.
|
||||
|
||||
## Intentional Packet Handling Decision
|
||||
|
||||
Raw packet handling uses two identities by design:
|
||||
- **`id` (DB packet row ID)**: storage identity from payload-hash deduplication (path bytes are excluded), so repeated payloads share one stored raw-packet row.
|
||||
- **`observation_id` (WebSocket only)**: realtime observation identity, unique per RF arrival, so path-diverse repeats are still visible in-session.
|
||||
|
||||
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
|
||||
|
||||
## Contact Advert Path Memory
|
||||
|
||||
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`).
|
||||
|
||||
- This is independent of raw-packet payload deduplication.
|
||||
- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Only the N most recent unique paths are retained per contact (currently 10).
|
||||
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Incoming Messages
|
||||
|
||||
1. Radio receives raw bytes → `packet_processor.py` parses, decrypts, deduplicates, and stores in database (primary path via `RX_LOG_DATA` event)
|
||||
2. `event_handlers.py` handles higher-level events (`CONTACT_MSG_RECV`, `ACK`) as a fallback/supplement
|
||||
3. `broadcast_event()` in `websocket.py` fans out to both WebSocket clients and MQTT
|
||||
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
|
||||
@@ -134,9 +91,7 @@ To improve repeater disambiguation in the network visualizer, the backend stores
|
||||
|
||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||
|
||||
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
|
||||
|
||||
This message-layer echo/path handling is independent of raw-packet storage deduplication.
|
||||
**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
|
||||
|
||||
@@ -144,15 +99,12 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
.
|
||||
├── app/ # FastAPI backend
|
||||
│ ├── AGENTS.md # Backend documentation
|
||||
│ ├── bot.py # Bot execution and outbound bot sends
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings)
|
||||
│ ├── repository.py # Database CRUD
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ ├── websocket.py # Real-time broadcasts
|
||||
│ └── mqtt.py # Optional MQTT publisher
|
||||
│ └── websocket.py # Real-time broadcasts
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
@@ -164,10 +116,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── scripts/
|
||||
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized)
|
||||
│ ├── publish.sh # Version bump, changelog, docker build & push
|
||||
│ └── deploy.sh # Deploy to production server
|
||||
├── references/meshcore_py/ # MeshCore Python library
|
||||
├── tests/ # Backend tests (pytest)
|
||||
├── data/ # SQLite database (runtime)
|
||||
└── pyproject.toml # Python dependencies
|
||||
@@ -203,7 +152,7 @@ Terminal 2: `cd frontend && npm run dev`
|
||||
|
||||
### Production
|
||||
|
||||
In production, the FastAPI backend serves the compiled frontend. Build the frontend first:
|
||||
In production, the FastAPI backend serves the compiled frontend. You must build the frontend first:
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
@@ -212,8 +161,6 @@ 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`.
|
||||
|
||||
If `frontend/dist` (or `frontend/dist/index.html`) is missing, backend startup now logs an explicit error and continues serving API routes. In that case, frontend static routes are not mounted until a frontend build is present.
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend (pytest)
|
||||
@@ -226,13 +173,8 @@ 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_packet_pipeline.py` - End-to-end packet processing
|
||||
- `tests/test_api.py` - API endpoints, read state tracking
|
||||
- `tests/test_migrations.py` - Database migration system
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -243,7 +185,20 @@ npm run test:run
|
||||
|
||||
### Before Completing Changes
|
||||
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors.
|
||||
**Always run both backend and frontend validation before finishing any changes:**
|
||||
|
||||
```bash
|
||||
# From project root - run backend tests
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
# From project root - run frontend tests and build
|
||||
cd frontend && npm run test:run && npm run build
|
||||
```
|
||||
|
||||
This catches:
|
||||
- Type mismatches between frontend and backend (e.g., missing fields in TypeScript interfaces)
|
||||
- Breaking changes to shared types or API contracts
|
||||
- Runtime errors that only surface during compilation
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -254,53 +209,29 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/health` | Connection status |
|
||||
| GET | `/api/radio/config` | Radio configuration |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
|
||||
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
|
||||
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
|
||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
|
||||
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||
| POST | `/api/contacts/{public_key}/reset-path` | Reset contact path to flood |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
||||
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
|
||||
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}` | Get channel by key |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/sync` | Pull from radio |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters |
|
||||
| POST | `/api/messages/direct` | Send direct message |
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
| GET | `/api/packets/decrypt/progress` | Get historical decryption progress |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets (cleanup) |
|
||||
| POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
|
||||
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
|
||||
| GET | `/api/settings` | Get app settings |
|
||||
| PATCH | `/api/settings` | Update app settings |
|
||||
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
|
||||
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
## Key Concepts
|
||||
@@ -334,33 +265,16 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
|
||||
Read state (`last_read_at`) is tracked **server-side** for consistency across devices:
|
||||
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
|
||||
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
||||
- Updated via `POST /api/contacts/{key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
||||
- Bulk update via `POST /api/read-state/mark-all-read`
|
||||
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
|
||||
|
||||
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
|
||||
- Channels: `channel-{channel_key}`
|
||||
- Contacts: `contact-{full-public-key}`
|
||||
- Contacts: `contact-{12-char-pubkey-prefix}`
|
||||
|
||||
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
|
||||
|
||||
### MQTT Publishing
|
||||
|
||||
Optional MQTT integration forwards mesh events to an external broker for home automation, logging, or alerting. All MQTT config is stored in the database (`app_settings`), not env vars — configured from the Settings pane, no server restart needed.
|
||||
|
||||
**Two independent toggles**: publish decrypted messages, publish raw packets.
|
||||
|
||||
**Topic structure** (default prefix `meshcore`):
|
||||
- `meshcore/dm:<contact_public_key>` — decrypted DM
|
||||
- `meshcore/gm:<channel_key>` — decrypted channel message
|
||||
- `meshcore/raw/dm:<contact_key>` — raw packet attributed to a DM contact
|
||||
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
|
||||
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
|
||||
|
||||
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status`.
|
||||
|
||||
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
|
||||
|
||||
### Server-Side Decryption
|
||||
|
||||
The server can decrypt packets using stored keys, both in real-time and for historical packets.
|
||||
@@ -394,16 +308,5 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, and all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used.
|
||||
| `MESHCORE_MAX_RADIO_CONTACTS` | `200` | Max recent contacts to keep on radio for DM ACKs |
|
||||
|
||||
132
CHANGELOG.md
@@ -1,135 +1,3 @@
|
||||
## [2.3.0] - 2026-03-01
|
||||
|
||||
Feature: Click path description to reset to flood
|
||||
Feature: Add MQTT publishing
|
||||
Feature: Visualizer remembers settings
|
||||
Bugfix: Fix prefetch usage
|
||||
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||
Misc: Drop py3.12 requirement
|
||||
Misc: Performance, documentation, test, and file structure optimizations
|
||||
Misc: Add arrows between route nodes on contact info
|
||||
Misc: Show repeater path/type in title bar
|
||||
|
||||
## [2.2.0] - 2026-02-28
|
||||
|
||||
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||
Feature: Contact info pane
|
||||
Feature: Overhaul repeater interface
|
||||
Bugfix: Misc. frontend rendering + perf improvements
|
||||
Bugfix: Better behavior around radio locking and autofetch/polling
|
||||
Bugifx: Clear channel name field on new-channel modal tab change
|
||||
Bugfix: Repeater inforbox can scroll
|
||||
Bugfix: Better handling of historical DM encrypts
|
||||
Bugfix: Handle errors if returned in prefetch phase
|
||||
Misc: Radio event response failure is logged/surfaced better
|
||||
Misc: Improve test coverage and remove dead code
|
||||
Misc: Documentatin and errata improvements
|
||||
Misc: Databse storage optimization
|
||||
|
||||
## [2.1.0] - 2026-02-23
|
||||
|
||||
Feature: Add ability to remember last-used channel on load
|
||||
Feature: Add `docker compose` support (thanks @suymur !)
|
||||
Feature: Better-aligned favicon (lol)
|
||||
Bugfix: Disable autocomplete on message field
|
||||
Bugfix: Legacy hash restoration on page load
|
||||
Bugfix: Align resend buttons in pathing modal
|
||||
Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||
Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||
Bugfix: Improved repeater comms on busy meshes
|
||||
Bugfix: Drain before autofetch from radio
|
||||
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||
Bugfix: Force server-side keystore update when radio key changes
|
||||
Bugfix: Reduce WS churn for incoming message handling
|
||||
Bugfix: Fix content type signalling for irrelevant endpoints
|
||||
Bugfix: Handle stuck post-connect failure state
|
||||
Misc: Documentation & version parsing improvements
|
||||
Misc: Hide char counter on mobile for short messages
|
||||
Misc: Typo fixes in docs and settings
|
||||
Misc: Add dynamic webmanifest for hosts that can support it
|
||||
Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||
Misc: Drop weird rounded bounding box for settings
|
||||
Misc: Move resend buttons to pathing modal
|
||||
Misc: Improved comments around database ownership on *nix systems
|
||||
Misc: Move to SSoT for message dedupe on frontend
|
||||
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||
Misc: Holistic testing overhaul
|
||||
|
||||
## [2.0.1] - 2026-02-16
|
||||
|
||||
Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||
|
||||
## [2.0.0] - 2026-02-16
|
||||
|
||||
Feature: Frontend UX + log overhaul
|
||||
Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||
Bugfix: Fix our of order path WS messages voerwriting each other
|
||||
Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||
Bugfix: Fir repeater command timestamp selection logic
|
||||
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||
Bugfix: Add missing radio operation locks in a few sports
|
||||
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||
Misc: Visualizer layout refinement & option labels
|
||||
|
||||
## [1.10.0] - 2026-02-16
|
||||
|
||||
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||
Feature: 3D mesh visualizer
|
||||
Feature: Statistics pane
|
||||
Feature: Support incoming/outgoing indication for bot invocations
|
||||
Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||
Bugfix: Fix top padding out outgoing message
|
||||
Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||
Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||
Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||
Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||
Misc: s/stopped/idle/ for room finder
|
||||
|
||||
## [1.9.3] - 2026-02-12
|
||||
|
||||
Feature: Upgrade the room finder to support two-word rooms
|
||||
|
||||
## [1.9.2] - 2026-02-12
|
||||
|
||||
Feature: Options dialog sucks less
|
||||
Bugix: Move tests to isolated memory DB
|
||||
Bugfix: Mention case sensitivity
|
||||
Bugfix: Stale header retention on settings page view
|
||||
Bugfix: Non-isolated path writing
|
||||
Bugfix: Nullable contact fields are now passed as real nulls
|
||||
Bugfix: Look at all fields on message reconcile, not just text
|
||||
Bugfix: Make mark-all-as-read atomic
|
||||
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||
Misc: Massive test and AGENTS.md overhauls and additions
|
||||
|
||||
## [1.9.1] - 2026-02-10
|
||||
|
||||
Feature: Contacts and channels use keys, not names
|
||||
Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||
Bugfix: Show message length in bytes, not chars
|
||||
Bugfix: Fix phantom unread badges on focused convos
|
||||
Misc: Bot invocation to async
|
||||
Misc: Use full key, not prefix, where we can
|
||||
|
||||
## [1.9.0] - 2026-02-10
|
||||
|
||||
Feature: Favorited contacts are preferentially loaded onto the radio
|
||||
Feature: Add recent-message caching for fast switching
|
||||
Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||
Frontend: Better styling on echo + message path display
|
||||
Bugfix: Prevent frontend static file serving path traversal vuln
|
||||
Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||
Bugfix: Prevent injection from mentions with special characters
|
||||
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||
Bugfix: App can boot and test without a frontend dir
|
||||
Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||
Misc: Frontend performance and safety enhancements
|
||||
Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||
Misc: Update tests and agent docs
|
||||
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 2: Python runtime
|
||||
@@ -37,5 +35,5 @@ RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application (we retain root for max compatibility)
|
||||
# Run the application
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
143
README.md
@@ -1,36 +1,31 @@
|
||||
# RemoteTerm for MeshCore
|
||||
|
||||
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||
Backend server + browser interface for MeshCore mesh radio networks. Attach your radio over serial, and then you can:
|
||||
|
||||
* Send and receive DMs and channel messages
|
||||
* Send and receive DMs and GroupTexts
|
||||
* Cache all received packets, decrypting as you gain keys
|
||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward decrypted packets to MQTT brokers
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
* Brute force hashtag room names for GroupTexts you don't have keys for yet
|
||||
|
||||
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
|
||||
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI.
|
||||
|
||||

|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
This is entirely vibecoded slop -- no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
|
||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- Node.js 18+ (for frontend development only)
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
|
||||
<details>
|
||||
<summary>Finding your serial port</summary>
|
||||
- MeshCore radio connected via USB serial
|
||||
|
||||
**Find your serial port:**
|
||||
```bash
|
||||
#######
|
||||
# Linux
|
||||
@@ -47,13 +42,16 @@ ls /dev/cu.usbserial-* /dev/cu.usbmodem*
|
||||
######
|
||||
# Run this in an elevated PowerShell (not WSL) window
|
||||
winget install usbipd
|
||||
|
||||
# restart console
|
||||
# then find device ID
|
||||
|
||||
# find device ID (e.g. 3-8)
|
||||
usbipd list
|
||||
|
||||
# attach device to WSL
|
||||
usbipd bind --busid 3-8 # (or whatever the right ID is)
|
||||
usbipd bind --busid 3-8
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -73,65 +71,25 @@ cd frontend && npm install && npm run build && cd ..
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The server auto-detects the serial port. To specify a transport manually:
|
||||
The server auto-detects the serial port. To specify manually:
|
||||
```bash
|
||||
# Serial (explicit port)
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# TCP (e.g. via wifi-enabled firmware)
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# BLE (address and PIN both required)
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access at http://localhost:8000
|
||||
|
||||
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
|
||||
|
||||
## Docker Compose
|
||||
## Docker
|
||||
|
||||
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
|
||||
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run -d \
|
||||
--device=/dev/ttyUSB0 \
|
||||
-v remoteterm-data:/app/data \
|
||||
-p 8000:8000 \
|
||||
jkingsman/remote-terminal-for-meshcore:latest
|
||||
```
|
||||
|
||||
## Development
|
||||
@@ -140,7 +98,7 @@ docker compose down
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --reload # autodetects serial port
|
||||
uv run uvicorn app.main:app --reload
|
||||
|
||||
# Or with explicit serial port
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
|
||||
@@ -159,16 +117,10 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
|
||||
### Code Quality & Tests
|
||||
|
||||
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
|
||||
|
||||
Run everything at once (parallelized):
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the bakend, and a lint + autoformat on the frontend.
|
||||
|
||||
<details>
|
||||
<summary>Or run individual checks</summary>
|
||||
<summary>But how?</summary>
|
||||
|
||||
```bash
|
||||
# python
|
||||
@@ -191,20 +143,15 @@ npm run build # build the frontend
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
|
||||
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Baud rate |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
| `MESHCORE_MAX_RADIO_CONTACTS` | 200 | Max recent contacts to keep on radio for DM ACKs |
|
||||
|
||||
## Additional Setup
|
||||
|
||||
<details>
|
||||
<summary>HTTPS (Required for WebGPU room-finding outside localhost)</summary>
|
||||
<summary>HTTPS (Required for WebGPU Cracking outside localhost)</summary>
|
||||
|
||||
WebGPU requires a secure context. When not on `localhost`, serve over HTTPS:
|
||||
|
||||
@@ -213,21 +160,21 @@ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -node
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
|
||||
```
|
||||
|
||||
For Docker Compose, generate the cert and add the volume mounts and command override to `docker-compose.yaml`:
|
||||
For Docker:
|
||||
|
||||
```bash
|
||||
# generate snakeoil TLS cert
|
||||
# generate TLS cert
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
|
||||
```
|
||||
|
||||
Then add the key and cert to the `remoteterm` service in `docker-compose.yaml`, and add an explicit launch command that uses them:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./cert.pem:/app/cert.pem:ro
|
||||
- ./key.pem:/app/key.pem:ro
|
||||
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
|
||||
# run with cert
|
||||
docker run -d \
|
||||
--device=/dev/ttyUSB0 \
|
||||
-v remoteterm-data:/app/data \
|
||||
-v $(pwd)/cert.pem:/app/cert.pem:ro \
|
||||
-v $(pwd)/key.pem:/app/key.pem:ro \
|
||||
-p 8000:8000 \
|
||||
jkingsman/remote-terminal-for-meshcore:latest \
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
|
||||
```
|
||||
|
||||
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
|
||||
@@ -236,8 +183,6 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
||||
<details>
|
||||
<summary>Systemd Service (Linux)</summary>
|
||||
|
||||
Assumes you're running from `/opt/remoteterm`; update commands and `remoteterm.service` if you're running elsewhere.
|
||||
|
||||
```bash
|
||||
# Create service user
|
||||
sudo useradd -r -m -s /bin/false remoteterm
|
||||
@@ -274,27 +219,15 @@ Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if n
|
||||
<summary>Testing</summary>
|
||||
|
||||
**Backend:**
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
**E2E:**
|
||||
|
||||
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly. E2E tests are generally not necessary to run for normal development work.
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # show the browser window
|
||||
```
|
||||
</details>
|
||||
|
||||
## API Documentation
|
||||
|
||||
859
app/AGENTS.md
@@ -1,310 +1,687 @@
|
||||
# Backend AGENTS.md
|
||||
|
||||
This document is the backend working guide for agents and developers.
|
||||
Keep it aligned with `app/` source files and router behavior.
|
||||
This document provides context for AI assistants and developers working on the FastAPI backend.
|
||||
|
||||
## Stack
|
||||
## Technology Stack
|
||||
|
||||
- FastAPI
|
||||
- aiosqlite
|
||||
- Pydantic
|
||||
- MeshCore Python library (`meshcore` from PyPI)
|
||||
- PyCryptodome
|
||||
- **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
|
||||
- **UV** - Python package manager
|
||||
|
||||
## Backend Map
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
```
|
||||
app/
|
||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings)
|
||||
├── radio.py # RadioManager + auto-reconnect monitor
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
├── decoder.py # Packet parsing/decryption
|
||||
├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── mqtt.py # Optional MQTT publisher (fire-and-forget forwarding)
|
||||
├── bot.py # Bot execution and outbound bot sends
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
├── health.py
|
||||
├── radio.py
|
||||
├── contacts.py
|
||||
├── channels.py
|
||||
├── messages.py
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── settings.py
|
||||
├── repeaters.py
|
||||
├── statistics.py
|
||||
└── ws.py
|
||||
├── main.py # FastAPI app, lifespan, router registration, static file serving
|
||||
├── config.py # Pydantic settings (env vars: MESHCORE_*)
|
||||
├── database.py # SQLite schema, connection management, runs migrations
|
||||
├── migrations.py # Database migrations using SQLite user_version pragma
|
||||
├── 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, radio sync, mark-read
|
||||
├── channels.py # Channel CRUD, radio sync, mark-read
|
||||
├── messages.py # Message list and send (direct/channel)
|
||||
├── packets.py # Raw packet endpoints, historical decryption
|
||||
├── read_state.py # Read state: unread counts, mark-all-read
|
||||
├── settings.py # App settings (max_radio_contacts)
|
||||
└── ws.py # WebSocket endpoint at /api/ws
|
||||
```
|
||||
|
||||
## Core Runtime Flows
|
||||
## Key Architectural Patterns
|
||||
|
||||
### Incoming data
|
||||
### Repository Pattern
|
||||
|
||||
1. Radio emits events.
|
||||
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
|
||||
3. Decrypted messages are inserted into `messages` and broadcast over WS.
|
||||
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
|
||||
All database operations go through repository classes in `repository.py`:
|
||||
|
||||
### Outgoing messages
|
||||
```python
|
||||
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository, AppSettingsRepository
|
||||
|
||||
1. Send endpoints in `routers/messages.py` call MeshCore commands.
|
||||
2. Message is persisted as outgoing.
|
||||
3. Endpoint broadcasts WS `message` event so all live clients update.
|
||||
4. ACK/repeat updates arrive later as `message_acked` events.
|
||||
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend — a safe assumption since name changes require a radio config update and are not something that happens mid-conversation.
|
||||
# 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)
|
||||
|
||||
### Connection lifecycle
|
||||
# App settings (single-row pattern)
|
||||
settings = await AppSettingsRepository.get()
|
||||
await AppSettingsRepository.update(auto_decrypt_dm_on_advert=True)
|
||||
await AppSettingsRepository.add_favorite("contact", public_key)
|
||||
```
|
||||
|
||||
- `RadioManager.start_connection_monitor()` checks health every 5s.
|
||||
- Monitor reconnect path runs `post_connect_setup()` before broadcasting healthy state.
|
||||
- Manual reconnect/reboot endpoints call `reconnect()` then `post_connect_setup()`.
|
||||
- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
|
||||
### Radio Connection
|
||||
|
||||
## Important Behaviors
|
||||
`RadioManager` in `radio.py` handles serial connection:
|
||||
|
||||
### Read/unread state
|
||||
```python
|
||||
from app.radio import radio_manager
|
||||
|
||||
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
|
||||
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
|
||||
# Access meshcore instance
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.commands.send_msg(dst, msg)
|
||||
```
|
||||
|
||||
### Echo/repeat dedup
|
||||
Auto-detection scans common serial ports when `MESHCORE_SERIAL_PORT` is not set.
|
||||
|
||||
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
|
||||
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented **only for outgoing messages**. Incoming repeats add path data but do not change the ACK count.
|
||||
### Event-Driven Architecture
|
||||
|
||||
### Raw packet dedup policy
|
||||
Radio events flow through `event_handlers.py`:
|
||||
|
||||
- Raw packet storage deduplicates by payload hash (`RawPacketRepository.create`), excluding routing/path bytes.
|
||||
- Stored packet `id` is therefore a payload identity, not a per-arrival identity.
|
||||
- Realtime raw-packet WS broadcasts include `observation_id` (unique per RF arrival) in addition to `id`.
|
||||
- Frontend packet-feed features should key/dedupe by `observation_id`; use `id` only as the storage reference.
|
||||
- Message-layer repeat handling (`_handle_duplicate_message` + `MessageRepository.add_path`) is separate from raw-packet storage dedup.
|
||||
| Event | Handler | Actions |
|
||||
|-------|---------|---------|
|
||||
| `CONTACT_MSG_RECV` | `on_contact_message` | **Fallback only** - stores DM if packet processor didn't handle it |
|
||||
| `RX_LOG_DATA` | `on_rx_log_data` | Store packet, decrypt channels/DMs, broadcast via WS |
|
||||
| `PATH_UPDATE` | `on_path_update` | Update contact path info |
|
||||
| `NEW_CONTACT` | `on_new_contact` | Sync contact from radio's internal database |
|
||||
| `ACK` | `on_ack` | Match pending ACKs, mark message acked, broadcast |
|
||||
|
||||
### Contact sync throttle
|
||||
**Note on DM handling**: Direct messages are primarily handled by the packet processor via
|
||||
`RX_LOG_DATA`, which decrypts using the exported private key. The `CONTACT_MSG_RECV` handler
|
||||
exists as a fallback for radios without `ENABLE_PRIVATE_KEY_EXPORT=1` in firmware.
|
||||
|
||||
- `sync_recent_contacts_to_radio()` sets `_last_contact_sync = now` before the sync completes.
|
||||
- This is intentional: if sync fails, the next attempt is still throttled to prevent a retry-storm against a flaky radio. Contacts will resync on the next scheduled cycle or on reconnect.
|
||||
### WebSocket Broadcasting
|
||||
|
||||
### Periodic advertisement
|
||||
Real-time updates use `ws_manager` singleton:
|
||||
|
||||
- Controlled by `app_settings.advert_interval` (seconds).
|
||||
- `0` means disabled.
|
||||
- Last send time tracked in `app_settings.last_advert_time`.
|
||||
```python
|
||||
from app.websocket import ws_manager
|
||||
|
||||
### MQTT publishing
|
||||
# Broadcast to all connected clients
|
||||
await ws_manager.broadcast("message", {"id": 1, "text": "Hello"})
|
||||
```
|
||||
|
||||
- Optional forwarding of mesh events to an external MQTT broker.
|
||||
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
|
||||
- Disabled when `mqtt_broker_host` is empty.
|
||||
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
|
||||
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
|
||||
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
|
||||
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
|
||||
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`).
|
||||
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
|
||||
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
|
||||
Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error`
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
**Note:** The WebSocket initial connect only sends `health`. Contacts and channels are fetched
|
||||
via REST (`GET /api/contacts`, `GET /api/channels`) for faster parallel loading. The WS still
|
||||
broadcasts real-time `contacts`/`channels` updates when data changes.
|
||||
|
||||
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
|
||||
- **Runs full `post_connect_setup()` after successful reconnect** (event handlers, key export, time sync, contact/channel sync, advertisements, message polling)
|
||||
- Resilient to transient errors (logs and continues rather than crashing)
|
||||
- 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()
|
||||
```
|
||||
|
||||
### Message Polling
|
||||
|
||||
Periodic message polling serves as a fallback for platforms where push events are unreliable.
|
||||
Use `pause_polling()` to temporarily suspend polling during operations that need exclusive
|
||||
radio access (e.g., repeater CLI commands):
|
||||
|
||||
```python
|
||||
from app.radio_sync import pause_polling, is_polling_paused
|
||||
|
||||
# Pause polling during sensitive operations (supports nesting)
|
||||
async with pause_polling():
|
||||
# Polling is paused here
|
||||
await do_repeater_operation()
|
||||
|
||||
async with pause_polling():
|
||||
# Still paused (nested)
|
||||
await do_another_operation()
|
||||
|
||||
# Still paused (outer context active)
|
||||
|
||||
# Polling resumes when all contexts exit
|
||||
|
||||
# Check current state
|
||||
if is_polling_paused():
|
||||
print("Polling is currently paused")
|
||||
```
|
||||
|
||||
### Periodic Advertisement
|
||||
|
||||
The server automatically sends an advertisement every hour to announce presence on the mesh.
|
||||
This helps maintain visibility to other nodes and refreshes routing information.
|
||||
|
||||
- Started automatically on radio connection
|
||||
- Interval: 1 hour (3600 seconds)
|
||||
- Uses flood mode for maximum reach
|
||||
|
||||
```python
|
||||
from app.radio_sync import start_periodic_advert, stop_periodic_advert, send_advertisement
|
||||
|
||||
# Start/stop periodic advertising
|
||||
start_periodic_advert() # Started automatically in lifespan
|
||||
await stop_periodic_advert()
|
||||
|
||||
# Manual advertisement
|
||||
await send_advertisement() # Returns True on success
|
||||
```
|
||||
|
||||
## 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
|
||||
last_read_at INTEGER -- Unix timestamp when conversation was last read
|
||||
)
|
||||
|
||||
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,
|
||||
last_read_at INTEGER -- Unix timestamp when channel was last read
|
||||
)
|
||||
|
||||
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 TEXT, -- Hex-encoded routing path (2 chars per hop), null for outgoing
|
||||
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)
|
||||
)
|
||||
|
||||
app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single-row pattern
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
favorites TEXT DEFAULT '[]', -- JSON array of {type, id}
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent', -- 'recent' or 'alpha'
|
||||
last_message_times TEXT DEFAULT '{}', -- JSON object of state_key -> timestamp
|
||||
preferences_migrated INTEGER DEFAULT 0 -- One-time migration flag
|
||||
)
|
||||
```
|
||||
|
||||
## Database Migrations (`migrations.py`)
|
||||
|
||||
Schema migrations use SQLite's `user_version` pragma for version tracking:
|
||||
|
||||
```python
|
||||
from app.migrations import get_version, set_version, run_migrations
|
||||
|
||||
# Check current schema version
|
||||
version = await get_version(conn) # Returns int (0 for new/unmigrated DB)
|
||||
|
||||
# Run pending migrations (called automatically on startup)
|
||||
applied = await run_migrations(conn) # Returns number of migrations applied
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `database.py` calls `run_migrations()` after schema initialization
|
||||
2. Each migration checks `user_version` and runs if needed
|
||||
3. Migrations are idempotent (safe to run multiple times)
|
||||
4. `ALTER TABLE ADD COLUMN` handles existing columns gracefully
|
||||
|
||||
### Adding a New Migration
|
||||
|
||||
```python
|
||||
# In migrations.py
|
||||
async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
version = await get_version(conn)
|
||||
applied = 0
|
||||
|
||||
if version < 1:
|
||||
await _migrate_001_add_last_read_at(conn)
|
||||
await set_version(conn, 1)
|
||||
applied += 1
|
||||
|
||||
# Add new migrations here:
|
||||
# if version < 2:
|
||||
# await _migrate_002_something(conn)
|
||||
# await set_version(conn, 2)
|
||||
# applied += 1
|
||||
|
||||
return applied
|
||||
```
|
||||
|
||||
## 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) for shared secret derivation.
|
||||
|
||||
**Key storage**: The private key is exported from the radio on startup and stored in memory
|
||||
via `keystore.py`. This enables server-side DM decryption even when contacts aren't loaded
|
||||
on the radio.
|
||||
|
||||
**Primary path (packet processor)**: When an `RX_LOG_DATA` event contains a `TEXT_MESSAGE`
|
||||
packet, `packet_processor.py` handles the complete flow:
|
||||
1. Decrypts using known contact public keys and stored private key
|
||||
2. Filters CLI responses (txt_type=1 in flags)
|
||||
3. Stores message in database
|
||||
4. Broadcasts via WebSocket
|
||||
5. Updates contact's last_contacted timestamp
|
||||
6. Triggers bot if enabled
|
||||
|
||||
**Fallback path (event handler)**: If the packet processor can't decrypt (no private key
|
||||
export, unknown contact), `on_contact_message` handles DMs from the MeshCore library's
|
||||
`CONTACT_MSG_RECV` event. DB deduplication prevents double-storage when both paths fire.
|
||||
|
||||
**Historical decryption**: When creating a contact with `try_historical=True`, the server
|
||||
attempts to decrypt all stored `TEXT_MESSAGE` packets for that contact.
|
||||
|
||||
**Direction detection**: The decoder uses the 1-byte dest_hash and src_hash to determine
|
||||
if a message is incoming or outgoing. Edge case: when both bytes match (1/256 chance),
|
||||
defaults to treating as incoming.
|
||||
|
||||
```python
|
||||
from app.decoder import try_decrypt_dm
|
||||
|
||||
result = try_decrypt_dm(raw_bytes, private_key, contact_public_key)
|
||||
if result:
|
||||
print(f"{result.message} (timestamp={result.timestamp})")
|
||||
```
|
||||
|
||||
## Advertisement Parsing (`decoder.py`)
|
||||
|
||||
Advertisement packets contain contact information including optional GPS coordinates.
|
||||
|
||||
### Packet Structure
|
||||
|
||||
```
|
||||
Bytes 0-31: Public key (32 bytes)
|
||||
Bytes 32-35: Timestamp (4 bytes, little-endian Unix timestamp)
|
||||
Bytes 36-99: Signature (64 bytes)
|
||||
Byte 100: App flags
|
||||
Bytes 101+: Optional fields (location, name) based on flags
|
||||
```
|
||||
|
||||
### App Flags (byte 100)
|
||||
|
||||
- Bits 0-3: Device role (1=Chat, 2=Repeater, 3=Room, 4=Sensor)
|
||||
- Bit 4: Has location (lat/lon follow)
|
||||
- Bit 5: Has feature 1
|
||||
- Bit 6: Has feature 2
|
||||
- Bit 7: Has name (null-terminated string at end)
|
||||
|
||||
### GPS Extraction
|
||||
|
||||
When bit 4 is set, latitude and longitude follow as signed int32 little-endian values,
|
||||
divided by 1,000,000 to get decimal degrees:
|
||||
|
||||
```python
|
||||
from app.decoder import parse_advertisement
|
||||
|
||||
advert = parse_advertisement(payload_bytes)
|
||||
if advert:
|
||||
print(f"Device role: {advert.device_role}") # 1=Chat, 2=Repeater
|
||||
if advert.lat and advert.lon:
|
||||
print(f"Location: {advert.lat}, {advert.lon}")
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. `event_handlers.py` receives ADVERTISEMENT event
|
||||
2. `packet_processor.py` calls `parse_advertisement()` to extract data
|
||||
3. Contact is upserted with location data (`lat`, `lon`) and `device_role` as `type`
|
||||
4. Frontend MapView displays contacts with GPS coordinates
|
||||
|
||||
## ACK and Repeat Detection
|
||||
|
||||
The `acked` field is an integer count, not a boolean:
|
||||
- `0` = not acked
|
||||
- `1` = one ACK/echo received
|
||||
- `2+` = multiple flood echoes received
|
||||
|
||||
### 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's ack count is incremented.
|
||||
|
||||
### Channel Message Repeats
|
||||
|
||||
Flood messages echo back through repeaters. Detection uses:
|
||||
- Channel key
|
||||
- Text hash
|
||||
- Timestamp (±5 second window)
|
||||
|
||||
Each repeat increments the ack count. The frontend displays:
|
||||
- `?` = no acks
|
||||
- `✓` = 1 echo
|
||||
- `✓2`, `✓3`, etc. = multiple echoes (real-time updates via WebSocket)
|
||||
|
||||
### 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 /health`
|
||||
- `GET /api/health` - Connection status, serial port
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config`
|
||||
- `PATCH /radio/config`
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
- `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 to radio (write-only)
|
||||
- `POST /api/radio/advertise?flood=true` - Send advertisement
|
||||
- `POST /api/radio/reboot` - Reboot radio or reconnect if disconnected
|
||||
- `POST /api/radio/reconnect` - Manual reconnection attempt
|
||||
|
||||
### Contacts
|
||||
- `GET /contacts`
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `GET /contacts/{public_key}`
|
||||
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
|
||||
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
|
||||
- `POST /contacts`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/sync`
|
||||
- `POST /contacts/{public_key}/add-to-radio`
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/reset-path`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/repeater/neighbors`
|
||||
- `POST /contacts/{public_key}/repeater/acl`
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
- `GET /api/contacts` - List from database
|
||||
- `GET /api/contacts/{key}` - Get by public key or prefix
|
||||
- `POST /api/contacts` - Create contact (optionally trigger historical DM decryption)
|
||||
- `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
|
||||
- `POST /api/contacts/{key}/mark-read` - Mark conversation as read (updates last_read_at)
|
||||
- `POST /api/contacts/{key}/telemetry` - Request telemetry from repeater (see below)
|
||||
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}`
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
- `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
|
||||
- `POST /api/channels/{key}/mark-read` - Mark channel as read (updates last_read_at)
|
||||
- `DELETE /api/channels/{key}` - Delete channel
|
||||
|
||||
### Read State
|
||||
- `GET /api/read-state/unreads?name=X` - Server-computed unread counts, mention flags, and last message times
|
||||
- `POST /api/read-state/mark-all-read` - Mark all contacts and channels as read
|
||||
|
||||
### Messages
|
||||
- `GET /messages`
|
||||
- `POST /messages/direct`
|
||||
- `POST /messages/channel`
|
||||
- `POST /messages/channel/{message_id}/resend`
|
||||
- `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 /packets/undecrypted/count`
|
||||
- `POST /packets/decrypt/historical`
|
||||
- `POST /packets/maintenance`
|
||||
|
||||
### Read state
|
||||
- `GET /read-state/unreads`
|
||||
- `POST /read-state/mark-all-read`
|
||||
- `GET /api/packets/undecrypted/count` - Count of undecrypted packets
|
||||
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
|
||||
|
||||
### Settings
|
||||
- `GET /settings`
|
||||
- `PATCH /settings`
|
||||
- `POST /settings/favorites/toggle`
|
||||
- `POST /settings/migrate`
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
- `GET /api/settings` - Get all app settings
|
||||
- `PATCH /api/settings` - Update settings (max_radio_contacts, auto_decrypt_dm_on_advert, sidebar_sort_order)
|
||||
- `POST /api/settings/favorites` - Add a favorite
|
||||
- `DELETE /api/settings/favorites` - Remove a favorite
|
||||
- `POST /api/settings/favorites/toggle` - Toggle favorite status
|
||||
- `POST /api/settings/migrate` - One-time migration from frontend localStorage
|
||||
|
||||
### WebSocket
|
||||
- `WS /ws`
|
||||
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
- `health` — radio connection status (broadcast on change, personal on connect)
|
||||
- `contact` — single contact upsert (from advertisements and radio sync)
|
||||
- `message` — new message (channel or DM, from packet processor or send endpoints)
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
- `error` — toast notification (reconnect failure, missing private key, etc.)
|
||||
- `success` — toast notification (historical decrypt complete, etc.)
|
||||
|
||||
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
|
||||
## Data Model Notes
|
||||
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking)
|
||||
- `channels`
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authn/authz.
|
||||
- No CORS restriction (`*`).
|
||||
- Bot code executes user-provided Python via `exec()`.
|
||||
|
||||
These are product decisions for trusted-network deployments; do not flag as accidental vulnerabilities.
|
||||
### Static Files (Production)
|
||||
In production, the backend serves the frontend if `frontend/dist` exists. Users must build the
|
||||
frontend first (`cd frontend && npm install && npm run build`):
|
||||
- `/` - Serves `frontend/dist/index.html`
|
||||
- `/assets/*` - Serves compiled JS/CSS from `frontend/dist/assets/`
|
||||
- `/*` - Falls back to `index.html` for SPA routing
|
||||
|
||||
## Testing
|
||||
|
||||
Run backend tests:
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Test suites:
|
||||
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, CLI response filtering
|
||||
- `tests/test_api.py` - API endpoint tests, read state tracking
|
||||
- `tests/test_migrations.py` - Migration system, schema versioning
|
||||
|
||||
```text
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
├── test_decoder.py # Packet parsing/decryption
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||
├── test_frontend_static.py # Frontend static file serving
|
||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
├── test_radio_sync.py # Polling, sync, advertisement
|
||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||
├── test_repository.py # Data access layer
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
## 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)
|
||||
```
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
## Repeater Telemetry
|
||||
|
||||
### Sender timestamps are 1-second resolution (protocol constraint)
|
||||
The `POST /api/contacts/{key}/telemetry` endpoint fetches status, neighbors, and ACL from repeaters (contact type=2).
|
||||
|
||||
The MeshCore radio protocol encodes `sender_timestamp` as a 4-byte little-endian integer (Unix seconds). This is a firmware-level wire format — the radio, the Python library (`commands/messaging.py`), and the decoder (`decoder.py`) all read/write exactly 4 bytes. Millisecond Unix timestamps would overflow 4 bytes, so higher resolution is not possible without a firmware change.
|
||||
### Request Flow
|
||||
|
||||
**Consequence:** The dedup index `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` operates at 1-second granularity. Sending identical text to the same conversation twice within one second will hit the UNIQUE constraint on the second insert, returning HTTP 500 *after* the radio has already transmitted. The message is sent over the air but not stored in the database. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`.
|
||||
1. Verify contact exists and is a repeater (type=2)
|
||||
2. Add contact to radio with stored path data (from advertisements)
|
||||
3. Send login with password
|
||||
4. Request status with retries (3 attempts, 10s timeout)
|
||||
5. Fetch neighbors with `fetch_all_neighbours()` (handles pagination)
|
||||
6. Fetch ACL with `req_acl_sync()`
|
||||
7. Resolve pubkey prefixes to contact names from database
|
||||
|
||||
### Outgoing DM echoes remain undecrypted
|
||||
### ACL Permission Levels
|
||||
|
||||
When our own outgoing DM is heard back via `RX_LOG_DATA` (self-echo, loopback), `_process_direct_message` passes `our_public_key=None` for the outgoing direction, disabling the outbound hash check in the decoder. The decoder's inbound check (`src_hash == their_first_byte`) fails because the source is us, not the contact — so decryption returns `None`. This is by design: outgoing DMs are stored directly by the send endpoint, so no message is lost.
|
||||
```python
|
||||
ACL_PERMISSION_NAMES = {
|
||||
0: "Guest",
|
||||
1: "Read-only",
|
||||
2: "Read-write",
|
||||
3: "Admin",
|
||||
}
|
||||
```
|
||||
|
||||
### Infinite setup retry on connection monitor
|
||||
### Response Models
|
||||
|
||||
When `post_connect_setup()` fails (e.g. `export_and_store_private_key` raises `RuntimeError` because the radio didn't respond), `_setup_complete` is never set to `True`. The connection monitor sees `connected and not setup_complete` and retries every 5 seconds — indefinitely. This is intentional: the radio may be rebooting, waking from sleep, or otherwise temporarily unresponsive. We keep retrying so that setup completes automatically once the radio becomes available, without requiring manual intervention.
|
||||
```python
|
||||
class NeighborInfo(BaseModel):
|
||||
pubkey_prefix: str # 4-12 char prefix
|
||||
name: str | None # Resolved contact name
|
||||
snr: float # Signal-to-noise ratio in dB
|
||||
last_heard_seconds: int # Seconds since last heard
|
||||
|
||||
### DELETE channel returns 200 for non-existent keys
|
||||
class AclEntry(BaseModel):
|
||||
pubkey_prefix: str # 12 char prefix
|
||||
name: str | None # Resolved contact name
|
||||
permission: int # 0-3
|
||||
permission_name: str # Human-readable name
|
||||
|
||||
`DELETE /api/channels/{key}` returns `{"status": "ok"}` even if the key didn't exist. This is intentional — the postcondition is "channel doesn't exist," which is satisfied regardless of whether it existed before. No 404 needed.
|
||||
class TelemetryResponse(BaseModel):
|
||||
# Status fields
|
||||
pubkey_prefix: str
|
||||
battery_volts: float # Converted from mV
|
||||
uptime_seconds: int
|
||||
# ... signal quality, packet counts, etc.
|
||||
|
||||
### Contact lat/lon 0.0 vs NULL
|
||||
# Related data
|
||||
neighbors: list[NeighborInfo]
|
||||
acl: list[AclEntry]
|
||||
```
|
||||
|
||||
MeshCore uses `0.0` as the sentinel for "no GPS coordinates" (see `models.py` `to_radio_dict`). The upsert SQL uses `COALESCE(excluded.lat, contacts.lat)`, which preserves existing values when the new value is `NULL` — but `0.0` is not `NULL`, so it overwrites previously valid coordinates. This is intentional: we always want the most recent location data. If a device stops broadcasting GPS, the old coordinates are presumably stale/wrong, so overwriting with "not available" (`0.0`) is the correct behavior.
|
||||
## Repeater CLI Commands
|
||||
|
||||
## Editing Checklist
|
||||
After login via telemetry endpoint, you can send CLI commands to repeaters:
|
||||
|
||||
When changing backend behavior:
|
||||
1. Update/add router and repository tests.
|
||||
2. Confirm WS event contracts when payload shape changes.
|
||||
3. Run `PYTHONPATH=. uv run pytest tests/ -v`.
|
||||
4. If API contract changed, update frontend types and AGENTS docs.
|
||||
### Endpoint
|
||||
|
||||
`POST /api/contacts/{key}/command` - Send a CLI command (assumes already logged in)
|
||||
|
||||
### Request/Response
|
||||
|
||||
```python
|
||||
class CommandRequest(BaseModel):
|
||||
command: str # CLI command to send
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
command: str # Echo of sent command
|
||||
response: str # Response from repeater
|
||||
sender_timestamp: int | None # Timestamp from response
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```
|
||||
get name / set name <value> # Repeater name
|
||||
get tx / set tx <dbm> # TX power
|
||||
get radio / set radio <freq,bw,sf,cr> # Radio params
|
||||
tempradio <freq,bw,sf,cr,mins> # Temporary radio change
|
||||
setperm <pubkey> <0-3> # ACL: 0=guest, 1=ro, 2=rw, 3=admin
|
||||
clock / clock sync # Get/sync time
|
||||
ver # Firmware version
|
||||
reboot # Restart repeater
|
||||
```
|
||||
|
||||
### CLI Response Filtering
|
||||
|
||||
CLI responses have `txt_type=1` (vs `txt_type=0` for normal messages). Both DM handling
|
||||
paths filter these to prevent storage—the command endpoint returns the response directly.
|
||||
|
||||
**Packet processor path** (primary):
|
||||
```python
|
||||
# In create_dm_message_from_decrypted()
|
||||
txt_type = decrypted.flags & 0x0F
|
||||
if txt_type == 1:
|
||||
return None # Skip CLI responses
|
||||
```
|
||||
|
||||
**Event handler path** (fallback):
|
||||
```python
|
||||
# In on_contact_message()
|
||||
txt_type = payload.get("txt_type", 0)
|
||||
if txt_type == 1:
|
||||
return # Skip CLI responses
|
||||
```
|
||||
|
||||
### Helper Function
|
||||
|
||||
`prepare_repeater_connection()` handles the login dance:
|
||||
1. Add contact to radio with stored path from DB (`out_path`, `out_path_len`)
|
||||
2. Send login with password
|
||||
3. Wait for key exchange to complete
|
||||
|
||||
### Contact Path Tracking
|
||||
|
||||
When advertisements are received, path data is extracted and stored:
|
||||
- `last_path`: Hex string of routing path bytes
|
||||
- `last_path_len`: Number of hops (-1=flood/unknown, 0=direct, >0=hops through repeaters)
|
||||
|
||||
**Shortest path selection**: When receiving echoed advertisements within 60 seconds, the shortest path is kept. This ensures we use the most efficient route when multiple paths exist.
|
||||
|
||||
85
app/bot.py
@@ -11,7 +11,6 @@ the security implications.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -49,19 +48,15 @@ def execute_bot_code(
|
||||
channel_name: str | None,
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Execute user-provided bot code with message context.
|
||||
|
||||
The code should define a function:
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path)`
|
||||
that returns either None (no response), a string (single response message),
|
||||
or a list of strings (multiple messages sent in order).
|
||||
|
||||
Legacy bot functions with 8 parameters (without is_outgoing) are detected
|
||||
via inspect and called without the new parameter for backward compatibility.
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
sender_name: Display name of the sender (may be None)
|
||||
@@ -72,7 +67,6 @@ def execute_bot_code(
|
||||
channel_name: Channel name (e.g. "#general" with hash), None for DMs
|
||||
sender_timestamp: Sender's timestamp from the message (may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
|
||||
Returns:
|
||||
Response string, list of strings, or None.
|
||||
@@ -101,63 +95,18 @@ def execute_bot_code(
|
||||
|
||||
bot_func = namespace["bot"]
|
||||
|
||||
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
|
||||
# or uses the legacy 8-param signature, for backward compatibility.
|
||||
# Three cases: explicit is_outgoing param or 9+ params (positional),
|
||||
# **kwargs (pass as keyword), or legacy 8-param (omit).
|
||||
call_style = "legacy" # "positional", "keyword", or "legacy"
|
||||
try:
|
||||
sig = inspect.signature(bot_func)
|
||||
params = sig.parameters
|
||||
non_variadic = [
|
||||
p
|
||||
for p in params.values()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
if "is_outgoing" in params or len(non_variadic) >= 9:
|
||||
call_style = "positional"
|
||||
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
call_style = "keyword"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# Call the bot function with appropriate signature
|
||||
if call_style == "positional":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
)
|
||||
elif call_style == "keyword":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing=is_outgoing,
|
||||
)
|
||||
else:
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
)
|
||||
# Call the bot function with message context
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
)
|
||||
|
||||
# Validate result
|
||||
if result is None:
|
||||
@@ -224,6 +173,7 @@ async def _send_single_bot_message(
|
||||
|
||||
from app.models import SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.routers.messages import send_channel_message, send_direct_message
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
# Serialize bot sends and enforce minimum spacing
|
||||
async with _bot_send_lock:
|
||||
@@ -240,11 +190,15 @@ async def _send_single_bot_message(
|
||||
if is_dm:
|
||||
logger.info("Bot sending DM reply to %s", sender_key[:12])
|
||||
request = SendDirectMessageRequest(destination=sender_key, text=message_text)
|
||||
await send_direct_message(request)
|
||||
message = await send_direct_message(request)
|
||||
# Broadcast to WebSocket (endpoint returns to HTTP caller, bot needs explicit broadcast)
|
||||
broadcast_event("message", message.model_dump())
|
||||
elif channel_key:
|
||||
logger.info("Bot sending channel reply to %s", channel_key[:8])
|
||||
request = SendChannelMessageRequest(channel_key=channel_key, text=message_text)
|
||||
await send_channel_message(request)
|
||||
message = await send_channel_message(request)
|
||||
# Broadcast to WebSocket
|
||||
broadcast_event("message", message.model_dump())
|
||||
else:
|
||||
logger.warning("Cannot send bot response: no destination")
|
||||
return # Don't update timestamp if we didn't send
|
||||
@@ -332,7 +286,6 @@ async def run_bot_for_message(
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -10,44 +9,9 @@ class Settings(BaseSettings):
|
||||
|
||||
serial_port: str = "" # Empty string triggers auto-detection
|
||||
serial_baudrate: int = 115200
|
||||
tcp_host: str = ""
|
||||
tcp_port: int = 4000
|
||||
ble_address: str = ""
|
||||
ble_pin: str = ""
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
database_path: str = "data/meshcore.db"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
transports_set = sum(
|
||||
[
|
||||
bool(self.serial_port),
|
||||
bool(self.tcp_host),
|
||||
bool(self.ble_address),
|
||||
]
|
||||
)
|
||||
if transports_set > 1:
|
||||
raise ValueError(
|
||||
"Only one transport may be configured at a time. "
|
||||
"Set exactly one of MESHCORE_SERIAL_PORT, MESHCORE_TCP_HOST, or MESHCORE_BLE_ADDRESS."
|
||||
)
|
||||
if self.ble_address and not self.ble_pin:
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
return self
|
||||
|
||||
@property
|
||||
def loopback_eligible(self) -> bool:
|
||||
"""True when no explicit transport env var is set."""
|
||||
return not self.serial_port and not self.tcp_host and not self.ble_address
|
||||
|
||||
@property
|
||||
def connection_type(self) -> Literal["serial", "tcp", "ble"]:
|
||||
if self.tcp_host:
|
||||
return "tcp"
|
||||
if self.ble_address:
|
||||
return "ble"
|
||||
return "serial"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
last_contacted INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
@@ -43,14 +42,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
acked INTEGER DEFAULT 0,
|
||||
sender_name TEXT,
|
||||
sender_key TEXT
|
||||
-- Deduplication: identical text + timestamp in the same conversation is treated as a
|
||||
-- mesh echo/repeat. Second-precision timestamps mean two intentional identical messages
|
||||
-- within the same second would collide, but this is not feasible in practice — LoRa
|
||||
-- transmission takes several seconds per message, and the UI clears the input on send.
|
||||
-- Enforced via idx_messages_dedup_null_safe (unique index) rather than a table constraint
|
||||
-- to avoid the storage overhead of SQLite's autoindex duplicating every message text.
|
||||
UNIQUE(type, conversation_key, text, sender_timestamp)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
@@ -58,43 +50,15 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash BLOB,
|
||||
payload_hash TEXT,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
);
|
||||
|
||||
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 UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0));
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||
ON contact_advert_paths(public_key, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
||||
ON contact_name_history(public_key, last_seen DESC);
|
||||
"""
|
||||
|
||||
|
||||
@@ -108,17 +72,6 @@ class Database:
|
||||
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
|
||||
|
||||
# WAL mode: faster writes, concurrent readers during writes, no journal file churn.
|
||||
# Persists in the DB file but we set it explicitly on every connection.
|
||||
await self._connection.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
# Incremental auto-vacuum: freed pages are reclaimable via
|
||||
# PRAGMA incremental_vacuum without a full VACUUM. Must be set before
|
||||
# the first table is created (for new databases); for existing databases
|
||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
|
||||
await self._connection.executescript(SCHEMA)
|
||||
await self._connection.commit()
|
||||
logger.debug("Database schema initialized")
|
||||
|
||||
@@ -327,6 +327,7 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
|
||||
# Parse fixed-position fields
|
||||
public_key = payload[0:32].hex()
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
# signature = payload[36:100] # Not currently verified
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
|
||||
@@ -10,8 +10,6 @@ def require_connected():
|
||||
|
||||
Raises HTTPException 503 if radio is not connected.
|
||||
"""
|
||||
if getattr(radio_manager, "is_setup_in_progress", False) is True:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
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
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, Message, MessagePath
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.repository import ContactRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -40,7 +34,7 @@ def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> No
|
||||
)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
def _cleanup_expired_acks() -> None:
|
||||
"""Remove expired pending ACKs."""
|
||||
now = time.time()
|
||||
expired = []
|
||||
@@ -79,14 +73,7 @@ async def on_contact_message(event: "Event") -> None:
|
||||
|
||||
# Look up contact from database - use prefix lookup only if needed
|
||||
# (get_by_key_or_prefix does exact match first, then prefix fallback)
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
|
||||
sender_pubkey,
|
||||
)
|
||||
contact = None
|
||||
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
|
||||
if contact:
|
||||
sender_pubkey = contact.public_key.lower()
|
||||
|
||||
@@ -126,22 +113,24 @@ async def on_contact_message(event: "Event") -> None:
|
||||
|
||||
# Build paths array for broadcast
|
||||
path = payload.get("path")
|
||||
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
|
||||
paths = [{"path": path or "", "received_at": received_at}] if path is not None else None
|
||||
|
||||
# Broadcast the new message
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="PRIV",
|
||||
conversation_key=sender_pubkey,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=payload.get("sender_timestamp") or received_at,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
).model_dump(),
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": sender_pubkey,
|
||||
"text": payload.get("text", ""),
|
||||
"sender_timestamp": payload.get("sender_timestamp"),
|
||||
"received_at": received_at,
|
||||
"paths": paths,
|
||||
"txt_type": txt_type,
|
||||
"signature": payload.get("signature"),
|
||||
"outgoing": False,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Update contact last_contacted (contact was already fetched above)
|
||||
@@ -151,18 +140,16 @@ async def on_contact_message(event: "Event") -> None:
|
||||
# Run bot if enabled
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=contact.name if contact else None,
|
||||
sender_key=sender_pubkey,
|
||||
message_text=payload.get("text", ""),
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=payload.get("sender_timestamp"),
|
||||
path=payload.get("path"),
|
||||
is_outgoing=False,
|
||||
)
|
||||
await run_bot_for_message(
|
||||
sender_name=contact.name if contact else None,
|
||||
sender_key=sender_pubkey,
|
||||
message_text=payload.get("text", ""),
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=payload.get("sender_timestamp"),
|
||||
path=payload.get("path"),
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -192,45 +179,15 @@ async def on_rx_log_data(event: "Event") -> None:
|
||||
async def on_path_update(event: "Event") -> None:
|
||||
"""Handle path update events."""
|
||||
payload = event.payload
|
||||
public_key = str(payload.get("public_key", "")).lower()
|
||||
pubkey_prefix = str(payload.get("pubkey_prefix", "")).lower()
|
||||
logger.debug("Path update for %s", payload.get("pubkey_prefix"))
|
||||
|
||||
contact: Contact | None = None
|
||||
if public_key:
|
||||
logger.debug("Path update for %s", public_key[:12])
|
||||
contact = await ContactRepository.get_by_key(public_key)
|
||||
elif pubkey_prefix:
|
||||
# Legacy compatibility: older payloads may only include a prefix.
|
||||
logger.debug("Path update for prefix %s", pubkey_prefix)
|
||||
contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
else:
|
||||
logger.debug("PATH_UPDATE missing public_key/pubkey_prefix, skipping")
|
||||
return
|
||||
pubkey_prefix = payload.get("pubkey_prefix", "")
|
||||
path = payload.get("path", "")
|
||||
path_len = payload.get("path_len", -1)
|
||||
|
||||
if not contact:
|
||||
return
|
||||
|
||||
# PATH_UPDATE is a serial control push event from firmware (not an RF packet).
|
||||
# Current meshcore payloads only include public_key for this event.
|
||||
# RF route/path bytes are handled via RX_LOG_DATA -> process_raw_packet,
|
||||
# so if path fields are absent here we treat this as informational only.
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
if path is None or path_len is None:
|
||||
logger.debug(
|
||||
"PATH_UPDATE for %s has no path payload, skipping DB update", contact.public_key[:12]
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
normalized_path_len = int(path_len)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid path_len in PATH_UPDATE for %s: %r", contact.public_key[:12], path_len
|
||||
)
|
||||
return
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, str(path), normalized_path_len)
|
||||
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:
|
||||
@@ -249,22 +206,12 @@ async def on_new_contact(event: "Event") -> None:
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_data = {
|
||||
**Contact.from_radio_dict(public_key.lower(), payload, on_radio=True),
|
||||
**Contact.from_radio_dict(public_key, payload, on_radio=True),
|
||||
"last_seen": int(time.time()),
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
|
||||
# Record name history if contact has a name
|
||||
adv_name = payload.get("adv_name")
|
||||
if adv_name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key.lower(), adv_name, int(time.time())
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, etc.) matching the REST Contact shape exactly.
|
||||
db_contact = await ContactRepository.get_by_key(public_key)
|
||||
broadcast_event("contact", (db_contact.model_dump() if db_contact else contact_data))
|
||||
broadcast_event("contact", contact_data)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
@@ -278,16 +225,13 @@ async def on_ack(event: "Event") -> None:
|
||||
|
||||
logger.debug("Received ACK with code %s", ack_code)
|
||||
|
||||
cleanup_expired_acks()
|
||||
_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)
|
||||
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
# DM ACKs don't carry path data, so paths is intentionally omitted.
|
||||
# The frontend's mergePendingAck handles the missing field correctly,
|
||||
# preserving any previously known paths.
|
||||
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
|
||||
if forwarded_proto and forwarded_host:
|
||||
proto = forwarded_proto.split(",")[0].strip()
|
||||
host = forwarded_host.split(",")[0].strip()
|
||||
if proto and host:
|
||||
return f"{proto}://{host}"
|
||||
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"""Register frontend static file routes if a built frontend is available.
|
||||
|
||||
Returns True when routes are registered, False when frontend files are
|
||||
missing/incomplete. Missing frontend files are logged but are not fatal.
|
||||
"""
|
||||
frontend_dir = frontend_dir.resolve()
|
||||
index_file = frontend_dir / "index.html"
|
||||
assets_dir = frontend_dir / "assets"
|
||||
|
||||
if not frontend_dir.exists():
|
||||
logger.error(
|
||||
"Frontend build directory not found at %s. "
|
||||
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
|
||||
frontend_dir,
|
||||
)
|
||||
return False
|
||||
|
||||
if not frontend_dir.is_dir():
|
||||
logger.error(
|
||||
"Frontend build path is not a directory: %s. "
|
||||
"API will continue without frontend routes.",
|
||||
frontend_dir,
|
||||
)
|
||||
return False
|
||||
|
||||
if not index_file.exists():
|
||||
logger.error(
|
||||
"Frontend index file not found at %s. "
|
||||
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
|
||||
index_file,
|
||||
)
|
||||
return False
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
else:
|
||||
logger.warning(
|
||||
"Frontend assets directory missing at %s; /assets files will not be served",
|
||||
assets_dir,
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(index_file)
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
"""Serve a dynamic web manifest using the active request origin."""
|
||||
origin = _resolve_request_origin(request)
|
||||
manifest = {
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"id": f"{origin}/",
|
||||
"start_url": f"{origin}/",
|
||||
"scope": f"{origin}/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
|
||||
"theme_color": "#111419",
|
||||
"background_color": "#111419",
|
||||
"icons": [
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
}
|
||||
return JSONResponse(
|
||||
manifest,
|
||||
media_type="application/manifest+json",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@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).resolve()
|
||||
try:
|
||||
file_path.relative_to(frontend_dir)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found") from None
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
|
||||
return FileResponse(index_file)
|
||||
|
||||
logger.info("Serving frontend from %s", frontend_dir)
|
||||
return True
|
||||
|
||||
|
||||
def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
"""Register a fallback route that tells the user to build the frontend."""
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def frontend_not_built():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
|
||||
},
|
||||
)
|
||||
@@ -18,13 +18,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_EVENT_RECEIVED_GUIDANCE = (
|
||||
"Radio command channel is unresponsive (no_event_received). Ensure that your firmware is not "
|
||||
"incompatible, outdated, or wrong-mode (e.g. repeater, not client), and that"
|
||||
"serial/TCP/BLE connectivity is successful (try another app and see if that one works?). The app cannot proceed because it cannot "
|
||||
"issue commands to the radio."
|
||||
)
|
||||
|
||||
# In-memory storage for the private key and derived public key
|
||||
_private_key: bytes | None = None
|
||||
_public_key: bytes | None = None
|
||||
@@ -98,14 +91,8 @@ async def export_and_store_private_key(mc: "MeshCore") -> bool:
|
||||
)
|
||||
return False
|
||||
else:
|
||||
reason = result.payload.get("reason") if isinstance(result.payload, dict) else None
|
||||
if result.type == EventType.ERROR and reason == "no_event_received":
|
||||
logger.error("%s Raw response: %s", NO_EVENT_RECEIVED_GUIDANCE, result.payload)
|
||||
raise RuntimeError(NO_EVENT_RECEIVED_GUIDANCE)
|
||||
logger.error("Failed to export private key: %s", result.payload)
|
||||
return False
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error exporting private key: %s", e)
|
||||
return False
|
||||
|
||||
125
app/loopback.py
@@ -1,125 +0,0 @@
|
||||
"""Loopback transport: bridges a browser-side serial/BLE connection over WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from starlette.websockets import WebSocket, WebSocketState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoopbackTransport:
|
||||
"""ConnectionProtocol implementation that tunnels bytes over a WebSocket.
|
||||
|
||||
For serial mode, applies the same 0x3c + 2-byte LE size framing that
|
||||
meshcore's SerialConnection uses. For BLE mode, passes raw bytes through
|
||||
(matching BLEConnection behaviour).
|
||||
"""
|
||||
|
||||
def __init__(self, websocket: WebSocket, mode: Literal["serial", "ble"]) -> None:
|
||||
self._ws = websocket
|
||||
self._mode = mode
|
||||
self._reader: Any = None
|
||||
self._disconnect_callback: Any = None
|
||||
|
||||
# Serial framing state (mirrors meshcore serial_cx.py handle_rx)
|
||||
self._header = b""
|
||||
self._inframe = b""
|
||||
self._frame_started = False
|
||||
self._frame_size = 0
|
||||
|
||||
# -- ConnectionProtocol methods ------------------------------------------
|
||||
|
||||
async def connect(self) -> str:
|
||||
"""No-op — the WebSocket is already established."""
|
||||
info = f"Loopback ({self._mode})"
|
||||
logger.info("Loopback transport connected: %s", info)
|
||||
return info
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Ask the browser to release the hardware and close the WS."""
|
||||
try:
|
||||
if self._ws.client_state == WebSocketState.CONNECTED:
|
||||
await self._ws.send_json({"type": "disconnect"})
|
||||
except Exception:
|
||||
pass # WS may already be closed
|
||||
|
||||
async def send(self, data: Any) -> None:
|
||||
"""Send data to the browser (which writes it to the physical radio).
|
||||
|
||||
Serial mode: prepend 0x3c + 2-byte LE size header.
|
||||
BLE mode: send raw bytes.
|
||||
"""
|
||||
try:
|
||||
if self._ws.client_state != WebSocketState.CONNECTED:
|
||||
return
|
||||
if self._mode == "serial":
|
||||
size = len(data)
|
||||
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + bytes(data)
|
||||
await self._ws.send_bytes(pkt)
|
||||
else:
|
||||
await self._ws.send_bytes(bytes(data))
|
||||
except Exception as e:
|
||||
logger.debug("Loopback send error: %s", e)
|
||||
|
||||
def set_reader(self, reader: Any) -> None:
|
||||
self._reader = reader
|
||||
|
||||
def set_disconnect_callback(self, callback: Any) -> None:
|
||||
self._disconnect_callback = callback
|
||||
|
||||
# -- Incoming data from browser ------------------------------------------
|
||||
|
||||
def handle_rx(self, data: bytes) -> None:
|
||||
"""Process bytes received from the browser.
|
||||
|
||||
Serial mode: accumulate bytes, strip framing, deliver payload.
|
||||
BLE mode: deliver raw bytes directly.
|
||||
"""
|
||||
if self._mode == "serial":
|
||||
self._handle_rx_serial(data)
|
||||
else:
|
||||
self._handle_rx_ble(data)
|
||||
|
||||
def _handle_rx_ble(self, data: bytes) -> None:
|
||||
if self._reader is not None:
|
||||
asyncio.create_task(self._reader.handle_rx(data))
|
||||
|
||||
def _handle_rx_serial(self, data: bytes) -> None:
|
||||
"""Mirror meshcore's SerialConnection.handle_rx state machine."""
|
||||
raw = bytes(data)
|
||||
headerlen = len(self._header)
|
||||
|
||||
if not self._frame_started:
|
||||
if len(raw) >= 3 - headerlen:
|
||||
self._header = self._header + raw[: 3 - headerlen]
|
||||
self._frame_started = True
|
||||
self._frame_size = int.from_bytes(self._header[1:], byteorder="little")
|
||||
remainder = raw[3 - headerlen :]
|
||||
# Reset header for next frame
|
||||
self._header = b""
|
||||
if remainder:
|
||||
self._handle_rx_serial(remainder)
|
||||
else:
|
||||
self._header = self._header + raw
|
||||
else:
|
||||
framelen = len(self._inframe)
|
||||
if framelen + len(raw) < self._frame_size:
|
||||
self._inframe = self._inframe + raw
|
||||
else:
|
||||
self._inframe = self._inframe + raw[: self._frame_size - framelen]
|
||||
if self._reader is not None:
|
||||
asyncio.create_task(self._reader.handle_rx(self._inframe))
|
||||
remainder = raw[self._frame_size - framelen :]
|
||||
self._frame_started = False
|
||||
self._inframe = b""
|
||||
if remainder:
|
||||
self._handle_rx_serial(remainder)
|
||||
|
||||
def reset_framing(self) -> None:
|
||||
"""Reset the serial framing state machine."""
|
||||
self._header = b""
|
||||
self._inframe = b""
|
||||
self._frame_started = False
|
||||
self._frame_size = 0
|
||||
74
app/main.py
@@ -2,14 +2,14 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.radio import RadioDisconnectedError, radio_manager
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
@@ -19,14 +19,11 @@ from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
health,
|
||||
loopback,
|
||||
messages,
|
||||
packets,
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
|
||||
@@ -40,13 +37,6 @@ async def lifespan(app: FastAPI):
|
||||
await db.connect()
|
||||
logger.info("Database connected")
|
||||
|
||||
# Ensure default channels exist in the database even before the radio
|
||||
# connects. Without this, a fresh or disconnected instance would return
|
||||
# zero channels from GET /channels until the first successful radio sync.
|
||||
from app.radio_sync import ensure_default_channels
|
||||
|
||||
await ensure_default_channels()
|
||||
|
||||
try:
|
||||
await radio_manager.connect()
|
||||
logger.info("Connected to radio")
|
||||
@@ -57,20 +47,9 @@ async def lifespan(app: FastAPI):
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
# Start MQTT publisher if configured
|
||||
from app.mqtt import mqtt_publisher
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
try:
|
||||
mqtt_settings = await AppSettingsRepository.get()
|
||||
await mqtt_publisher.start(mqtt_settings)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start MQTT publisher: %s", e)
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
await mqtt_publisher.stop()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_message_polling()
|
||||
await stop_periodic_advert()
|
||||
@@ -81,22 +60,10 @@ async def lifespan(app: FastAPI):
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Read version from pyproject.toml so it stays in sync automatically."""
|
||||
try:
|
||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
for line in pyproject.read_text().splitlines():
|
||||
if line.startswith("version = "):
|
||||
return line.split('"')[1]
|
||||
except Exception:
|
||||
pass
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="RemoteTerm for MeshCore API",
|
||||
description="API for interacting with MeshCore mesh radio networks",
|
||||
version=_get_version(),
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -108,28 +75,35 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RadioDisconnectedError)
|
||||
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
|
||||
"""Return 503 when a radio disconnect race occurs during an operation."""
|
||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
||||
|
||||
|
||||
# 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(repeaters.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(read_state.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
app.include_router(loopback.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
if not register_frontend_static_routes(app, FRONTEND_DIR):
|
||||
register_frontend_missing_fallback(app)
|
||||
|
||||
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")
|
||||
|
||||
@@ -142,118 +142,6 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 15)
|
||||
applied += 1
|
||||
|
||||
# Migration 16: Add experimental_channel_double_send setting
|
||||
if version < 16:
|
||||
logger.info("Applying migration 16: add experimental_channel_double_send column")
|
||||
await _migrate_016_add_experimental_channel_double_send(conn)
|
||||
await set_version(conn, 16)
|
||||
applied += 1
|
||||
|
||||
# Migration 17: Drop experimental_channel_double_send column (replaced by user-triggered resend)
|
||||
if version < 17:
|
||||
logger.info("Applying migration 17: drop experimental_channel_double_send column")
|
||||
await _migrate_017_drop_experimental_channel_double_send(conn)
|
||||
await set_version(conn, 17)
|
||||
applied += 1
|
||||
|
||||
# Migration 18: Drop UNIQUE(data) constraint on raw_packets (redundant with payload_hash)
|
||||
if version < 18:
|
||||
logger.info("Applying migration 18: drop raw_packets UNIQUE(data) constraint")
|
||||
await _migrate_018_drop_raw_packets_data_unique(conn)
|
||||
await set_version(conn, 18)
|
||||
applied += 1
|
||||
|
||||
# Migration 19: Drop UNIQUE constraint on messages (redundant with dedup_null_safe index)
|
||||
if version < 19:
|
||||
logger.info("Applying migration 19: drop messages UNIQUE constraint")
|
||||
await _migrate_019_drop_messages_unique_constraint(conn)
|
||||
await set_version(conn, 19)
|
||||
applied += 1
|
||||
|
||||
# Migration 20: Enable WAL journal mode and incremental auto-vacuum
|
||||
if version < 20:
|
||||
logger.info("Applying migration 20: enable WAL mode and incremental auto-vacuum")
|
||||
await _migrate_020_enable_wal_and_auto_vacuum(conn)
|
||||
await set_version(conn, 20)
|
||||
applied += 1
|
||||
|
||||
# Migration 21: Enforce minimum 1-hour advert interval
|
||||
if version < 21:
|
||||
logger.info("Applying migration 21: enforce minimum 1-hour advert interval")
|
||||
await _migrate_021_enforce_min_advert_interval(conn)
|
||||
await set_version(conn, 21)
|
||||
applied += 1
|
||||
|
||||
# Migration 22: Track recent unique advert paths per repeater
|
||||
if version < 22:
|
||||
logger.info("Applying migration 22: add repeater_advert_paths table")
|
||||
await _migrate_022_add_repeater_advert_paths(conn)
|
||||
await set_version(conn, 22)
|
||||
applied += 1
|
||||
|
||||
# Migration 23: Add first_seen column to contacts
|
||||
if version < 23:
|
||||
logger.info("Applying migration 23: add first_seen column to contacts")
|
||||
await _migrate_023_add_first_seen(conn)
|
||||
await set_version(conn, 23)
|
||||
applied += 1
|
||||
|
||||
# Migration 24: Create contact_name_history table
|
||||
if version < 24:
|
||||
logger.info("Applying migration 24: create contact_name_history table")
|
||||
await _migrate_024_create_contact_name_history(conn)
|
||||
await set_version(conn, 24)
|
||||
applied += 1
|
||||
|
||||
# Migration 25: Add sender_name and sender_key to messages
|
||||
if version < 25:
|
||||
logger.info("Applying migration 25: add sender_name and sender_key to messages")
|
||||
await _migrate_025_add_sender_columns(conn)
|
||||
await set_version(conn, 25)
|
||||
applied += 1
|
||||
|
||||
# Migration 26: Rename repeater_advert_paths to contact_advert_paths
|
||||
if version < 26:
|
||||
logger.info("Applying migration 26: rename repeater_advert_paths to contact_advert_paths")
|
||||
await _migrate_026_rename_advert_paths_table(conn)
|
||||
await set_version(conn, 26)
|
||||
applied += 1
|
||||
|
||||
# Migration 27: Backfill first_seen from advert paths
|
||||
if version < 27:
|
||||
logger.info("Applying migration 27: backfill first_seen from advert paths")
|
||||
await _migrate_027_backfill_first_seen_from_advert_paths(conn)
|
||||
await set_version(conn, 27)
|
||||
applied += 1
|
||||
|
||||
# Migration 28: Convert payload_hash from 64-char hex TEXT to 32-byte BLOB
|
||||
if version < 28:
|
||||
logger.info("Applying migration 28: convert payload_hash from TEXT to BLOB")
|
||||
await _migrate_028_payload_hash_text_to_blob(conn)
|
||||
await set_version(conn, 28)
|
||||
applied += 1
|
||||
|
||||
# Migration 29: Add covering index for unread counts query
|
||||
if version < 29:
|
||||
logger.info("Applying migration 29: add covering index for unread counts query")
|
||||
await _migrate_029_add_unread_covering_index(conn)
|
||||
await set_version(conn, 29)
|
||||
applied += 1
|
||||
|
||||
# Migration 30: Add pagination index, drop redundant idx_messages_conversation
|
||||
if version < 30:
|
||||
logger.info("Applying migration 30: add pagination index for message queries")
|
||||
await _migrate_030_add_pagination_index(conn)
|
||||
await set_version(conn, 30)
|
||||
applied += 1
|
||||
|
||||
# Migration 31: Add MQTT configuration columns to app_settings
|
||||
if version < 31:
|
||||
logger.info("Applying migration 31: add MQTT columns to app_settings")
|
||||
await _migrate_031_add_mqtt_columns(conn)
|
||||
await set_version(conn, 31)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -1105,789 +993,3 @@ async def _migrate_015_fix_null_sender_timestamp(conn: aiosqlite.Connection) ->
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_016_add_experimental_channel_double_send(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Add experimental_channel_double_send column to app_settings table.
|
||||
|
||||
When enabled, channel sends perform an immediate byte-perfect duplicate send
|
||||
using the same timestamp bytes.
|
||||
"""
|
||||
try:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN experimental_channel_double_send INTEGER DEFAULT 0"
|
||||
)
|
||||
logger.debug("Added experimental_channel_double_send column to app_settings")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
logger.debug("experimental_channel_double_send column already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_017_drop_experimental_channel_double_send(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Drop experimental_channel_double_send column from app_settings.
|
||||
|
||||
This feature is replaced by a user-triggered resend button.
|
||||
SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
|
||||
we silently skip (the column will remain but is unused).
|
||||
"""
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings DROP COLUMN experimental_channel_double_send")
|
||||
logger.debug("Dropped experimental_channel_double_send from app_settings")
|
||||
except aiosqlite.OperationalError as e:
|
||||
error_msg = str(e).lower()
|
||||
if "no such column" in error_msg:
|
||||
logger.debug("app_settings.experimental_channel_double_send already dropped, skipping")
|
||||
elif "syntax error" in error_msg or "drop column" in error_msg:
|
||||
logger.debug(
|
||||
"SQLite doesn't support DROP COLUMN, "
|
||||
"experimental_channel_double_send column will remain"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_018_drop_raw_packets_data_unique(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Drop the UNIQUE constraint on raw_packets.data via table rebuild.
|
||||
|
||||
This constraint creates a large autoindex (~30 MB on a 340K-row database) that
|
||||
stores a complete copy of every raw packet BLOB in a B-tree. Deduplication is
|
||||
already handled by the unique index on payload_hash, making the data UNIQUE
|
||||
constraint pure storage overhead.
|
||||
|
||||
Requires table recreation since SQLite doesn't support DROP CONSTRAINT.
|
||||
"""
|
||||
# Check if the autoindex exists (indicates UNIQUE constraint on data)
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' "
|
||||
"AND name='sqlite_autoindex_raw_packets_1'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
logger.debug("raw_packets.data UNIQUE constraint already absent, skipping rebuild")
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
logger.info("Rebuilding raw_packets table to remove UNIQUE(data) constraint...")
|
||||
|
||||
# Get current columns from the existing table
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
old_cols = {col[1] for col in await cursor.fetchall()}
|
||||
|
||||
# Target schema without UNIQUE on data
|
||||
await conn.execute("""
|
||||
CREATE TABLE raw_packets_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash TEXT,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy only columns that exist in both old and new tables
|
||||
new_cols = {"id", "timestamp", "data", "message_id", "payload_hash"}
|
||||
copy_cols = ", ".join(sorted(c for c in new_cols if c in old_cols))
|
||||
|
||||
await conn.execute(
|
||||
f"INSERT INTO raw_packets_new ({copy_cols}) SELECT {copy_cols} FROM raw_packets"
|
||||
)
|
||||
await conn.execute("DROP TABLE raw_packets")
|
||||
await conn.execute("ALTER TABLE raw_packets_new RENAME TO raw_packets")
|
||||
|
||||
# Recreate indexes
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
|
||||
)
|
||||
await conn.execute("CREATE INDEX idx_raw_packets_message_id ON raw_packets(message_id)")
|
||||
|
||||
await conn.commit()
|
||||
logger.info("raw_packets table rebuilt without UNIQUE(data) constraint")
|
||||
|
||||
|
||||
async def _migrate_019_drop_messages_unique_constraint(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Drop the UNIQUE(type, conversation_key, text, sender_timestamp) constraint on messages.
|
||||
|
||||
This constraint creates a large autoindex (~13 MB on a 112K-row database) that
|
||||
stores the full message text in a B-tree. The idx_messages_dedup_null_safe unique
|
||||
index already provides identical dedup protection — no rows have NULL
|
||||
sender_timestamp since migration 15 backfilled them all.
|
||||
|
||||
INSERT OR IGNORE still works correctly because it checks all unique constraints,
|
||||
including unique indexes like idx_messages_dedup_null_safe.
|
||||
|
||||
Requires table recreation since SQLite doesn't support DROP CONSTRAINT.
|
||||
"""
|
||||
# Check if the autoindex exists (indicates UNIQUE constraint)
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='sqlite_autoindex_messages_1'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
logger.debug("messages UNIQUE constraint already absent, skipping rebuild")
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
logger.info("Rebuilding messages table to remove UNIQUE constraint...")
|
||||
|
||||
# Get current columns from the existing table
|
||||
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
old_cols = {col[1] for col in await cursor.fetchall()}
|
||||
|
||||
# Target schema without the UNIQUE table constraint
|
||||
await conn.execute("""
|
||||
CREATE TABLE messages_new (
|
||||
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,
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
acked INTEGER DEFAULT 0,
|
||||
paths TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy only columns that exist in both old and new tables
|
||||
new_cols = {
|
||||
"id",
|
||||
"type",
|
||||
"conversation_key",
|
||||
"text",
|
||||
"sender_timestamp",
|
||||
"received_at",
|
||||
"txt_type",
|
||||
"signature",
|
||||
"outgoing",
|
||||
"acked",
|
||||
"paths",
|
||||
}
|
||||
copy_cols = ", ".join(sorted(c for c in new_cols if c in old_cols))
|
||||
|
||||
await conn.execute(f"INSERT INTO messages_new ({copy_cols}) SELECT {copy_cols} FROM messages")
|
||||
await conn.execute("DROP TABLE messages")
|
||||
await conn.execute("ALTER TABLE messages_new RENAME TO messages")
|
||||
|
||||
# Recreate indexes
|
||||
await conn.execute("CREATE INDEX idx_messages_conversation ON messages(type, conversation_key)")
|
||||
await conn.execute("CREATE INDEX idx_messages_received ON messages(received_at)")
|
||||
await conn.execute(
|
||||
"""CREATE UNIQUE INDEX idx_messages_dedup_null_safe
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))"""
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
logger.info("messages table rebuilt without UNIQUE constraint")
|
||||
|
||||
|
||||
async def _migrate_020_enable_wal_and_auto_vacuum(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Enable WAL journal mode and incremental auto-vacuum.
|
||||
|
||||
WAL (Write-Ahead Logging):
|
||||
- Faster writes: appends to a WAL file instead of rewriting the main DB
|
||||
- Concurrent reads during writes (readers don't block writers)
|
||||
- No journal file create/delete churn on every commit
|
||||
|
||||
Incremental auto-vacuum:
|
||||
- Pages freed by DELETE become reclaimable without a full VACUUM
|
||||
- Call PRAGMA incremental_vacuum to reclaim on demand
|
||||
- Less overhead than FULL auto-vacuum (which reorganizes on every commit)
|
||||
|
||||
auto_vacuum mode change requires a VACUUM to restructure the file.
|
||||
The VACUUM is performed before switching to WAL so it runs under the
|
||||
current journal mode; WAL is then set as the final step.
|
||||
"""
|
||||
# Check current auto_vacuum mode
|
||||
cursor = await conn.execute("PRAGMA auto_vacuum")
|
||||
row = await cursor.fetchone()
|
||||
current_auto_vacuum = row[0] if row else 0
|
||||
|
||||
if current_auto_vacuum != 2: # 2 = INCREMENTAL
|
||||
logger.info("Switching auto_vacuum to INCREMENTAL (requires VACUUM)...")
|
||||
await conn.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
await conn.execute("VACUUM")
|
||||
logger.info("VACUUM complete, auto_vacuum set to INCREMENTAL")
|
||||
else:
|
||||
logger.debug("auto_vacuum already INCREMENTAL, skipping VACUUM")
|
||||
|
||||
# Enable WAL mode (idempotent — returns current mode)
|
||||
cursor = await conn.execute("PRAGMA journal_mode = WAL")
|
||||
row = await cursor.fetchone()
|
||||
mode = row[0] if row else "unknown"
|
||||
logger.info("Journal mode set to %s", mode)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_021_enforce_min_advert_interval(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Enforce minimum 1-hour advert interval.
|
||||
|
||||
Any advert_interval between 1 and 3599 is clamped up to 3600 (1 hour).
|
||||
Zero (disabled) is left unchanged.
|
||||
"""
|
||||
# Guard: app_settings table may not exist if running against a very old schema
|
||||
# (it's created in migration 9). The UPDATE is harmless if the table exists
|
||||
# but has no rows, but will error if the table itself is missing.
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
logger.debug("app_settings table does not exist yet, skipping advert_interval clamp")
|
||||
return
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE app_settings SET advert_interval = 3600 WHERE advert_interval > 0 AND advert_interval < 3600"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Clamped advert_interval to minimum 3600 seconds")
|
||||
|
||||
|
||||
async def _migrate_022_add_repeater_advert_paths(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Create table for recent unique advert paths per repeater.
|
||||
|
||||
This keeps path diversity for repeater advertisements without changing the
|
||||
existing payload-hash raw packet dedup policy.
|
||||
"""
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS repeater_advert_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repeater_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(repeater_key, path_hex),
|
||||
FOREIGN KEY (repeater_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
""")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent "
|
||||
"ON repeater_advert_paths(repeater_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Ensured repeater_advert_paths table and indexes exist")
|
||||
|
||||
|
||||
async def _migrate_023_add_first_seen(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Add first_seen column to contacts table.
|
||||
|
||||
Backfill strategy:
|
||||
1. Set first_seen = last_seen for all contacts (baseline).
|
||||
2. For contacts with PRIV messages, set first_seen = MIN(messages.received_at)
|
||||
if that timestamp is earlier.
|
||||
"""
|
||||
# Guard: skip if contacts table doesn't exist (e.g. partial test schemas)
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
return
|
||||
|
||||
try:
|
||||
await conn.execute("ALTER TABLE contacts ADD COLUMN first_seen INTEGER")
|
||||
logger.debug("Added first_seen to contacts table")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("contacts.first_seen already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Baseline: set first_seen = last_seen for all contacts
|
||||
# Check if last_seen column exists (should in production, may not in minimal test schemas)
|
||||
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
if "last_seen" in columns:
|
||||
await conn.execute("UPDATE contacts SET first_seen = last_seen WHERE first_seen IS NULL")
|
||||
|
||||
# Refine: for contacts with PRIV messages, use earliest message timestamp if earlier
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
|
||||
)
|
||||
if await cursor.fetchone():
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE contacts SET first_seen = (
|
||||
SELECT MIN(m.received_at) FROM messages m
|
||||
WHERE m.type = 'PRIV' AND m.conversation_key = contacts.public_key
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM messages m
|
||||
WHERE m.type = 'PRIV' AND m.conversation_key = contacts.public_key
|
||||
AND m.received_at < COALESCE(contacts.first_seen, 9999999999)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
logger.debug("Added and backfilled first_seen column")
|
||||
|
||||
|
||||
async def _migrate_024_create_contact_name_history(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Create contact_name_history table and seed with current contact names.
|
||||
"""
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
|
||||
"ON contact_name_history(public_key, last_seen DESC)"
|
||||
)
|
||||
|
||||
# Seed: one row per contact from current data (skip if contacts table doesn't exist
|
||||
# or lacks needed columns)
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone():
|
||||
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
cols = {row[1] for row in await cursor.fetchall()}
|
||||
if "name" in cols and "public_key" in cols:
|
||||
first_seen_expr = "first_seen" if "first_seen" in cols else "0"
|
||||
last_seen_expr = "last_seen" if "last_seen" in cols else "0"
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT OR IGNORE INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||
SELECT public_key, name,
|
||||
COALESCE({first_seen_expr}, {last_seen_expr}, 0),
|
||||
COALESCE({last_seen_expr}, 0)
|
||||
FROM contacts
|
||||
WHERE name IS NOT NULL AND name != ''
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
logger.debug("Created contact_name_history table and seeded from contacts")
|
||||
|
||||
|
||||
async def _migrate_025_add_sender_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Add sender_name and sender_key columns to messages table.
|
||||
|
||||
Backfill:
|
||||
- sender_name for CHAN messages: extract from "Name: message" format
|
||||
- sender_key for CHAN messages: match name to contact (skip ambiguous)
|
||||
- sender_key for incoming PRIV messages: set to conversation_key
|
||||
"""
|
||||
# Guard: skip if messages table doesn't exist
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
return
|
||||
|
||||
for column in ["sender_name", "sender_key"]:
|
||||
try:
|
||||
await conn.execute(f"ALTER TABLE messages ADD COLUMN {column} TEXT")
|
||||
logger.debug("Added %s to messages table", column)
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("messages.%s already exists, skipping", column)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Check which columns the messages table has (may be minimal in test environments)
|
||||
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
msg_cols = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
# Only backfill if the required columns exist
|
||||
if "type" in msg_cols and "text" in msg_cols:
|
||||
# Count messages to backfill for progress reporting
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE type = 'CHAN' AND sender_name IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
chan_count = row[0] if row else 0
|
||||
if chan_count > 0:
|
||||
logger.info("Backfilling sender_name for %d channel messages...", chan_count)
|
||||
|
||||
# Backfill sender_name for CHAN messages from "Name: message" format
|
||||
# Only extract if colon position is valid (> 1 and < 51, i.e. name is 1-50 chars)
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
UPDATE messages SET sender_name = SUBSTR(text, 1, INSTR(text, ': ') - 1)
|
||||
WHERE type = 'CHAN' AND sender_name IS NULL
|
||||
AND INSTR(text, ': ') > 1 AND INSTR(text, ': ') < 52
|
||||
"""
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
logger.info("Backfilled sender_name for %d channel messages", cursor.rowcount)
|
||||
|
||||
# Backfill sender_key for incoming PRIV messages
|
||||
if "outgoing" in msg_cols and "conversation_key" in msg_cols:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
UPDATE messages SET sender_key = conversation_key
|
||||
WHERE type = 'PRIV' AND outgoing = 0 AND sender_key IS NULL
|
||||
"""
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
logger.info("Backfilled sender_key for %d DM messages", cursor.rowcount)
|
||||
|
||||
# Backfill sender_key for CHAN messages: match sender_name to contacts
|
||||
# Build name->key map, skip ambiguous names (multiple contacts with same name)
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone():
|
||||
cursor = await conn.execute(
|
||||
"SELECT public_key, name FROM contacts WHERE name IS NOT NULL AND name != ''"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
name_to_keys: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
key = row["public_key"]
|
||||
if name not in name_to_keys:
|
||||
name_to_keys[name] = []
|
||||
name_to_keys[name].append(key)
|
||||
|
||||
# Only use unambiguous names (single contact per name)
|
||||
unambiguous = {n: ks[0] for n, ks in name_to_keys.items() if len(ks) == 1}
|
||||
if unambiguous:
|
||||
logger.info(
|
||||
"Matching sender_key for %d unique contact names...",
|
||||
len(unambiguous),
|
||||
)
|
||||
# Use a temp table for a single bulk UPDATE instead of N individual queries
|
||||
await conn.execute(
|
||||
"CREATE TEMP TABLE _name_key_map (name TEXT PRIMARY KEY, public_key TEXT NOT NULL)"
|
||||
)
|
||||
await conn.executemany(
|
||||
"INSERT INTO _name_key_map (name, public_key) VALUES (?, ?)",
|
||||
list(unambiguous.items()),
|
||||
)
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
UPDATE messages SET sender_key = (
|
||||
SELECT public_key FROM _name_key_map WHERE _name_key_map.name = messages.sender_name
|
||||
)
|
||||
WHERE type = 'CHAN' AND sender_key IS NULL
|
||||
AND sender_name IN (SELECT name FROM _name_key_map)
|
||||
"""
|
||||
)
|
||||
updated = cursor.rowcount
|
||||
await conn.execute("DROP TABLE _name_key_map")
|
||||
if updated > 0:
|
||||
logger.info("Backfilled sender_key for %d channel messages", updated)
|
||||
|
||||
# Create index on sender_key for per-contact channel message counts
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key)")
|
||||
|
||||
await conn.commit()
|
||||
logger.debug("Added sender_name and sender_key columns with backfill")
|
||||
|
||||
|
||||
async def _migrate_026_rename_advert_paths_table(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Rename repeater_advert_paths to contact_advert_paths with column
|
||||
repeater_key -> public_key.
|
||||
|
||||
Uses table rebuild since ALTER TABLE RENAME COLUMN may not be available
|
||||
in older SQLite versions.
|
||||
"""
|
||||
# Check if old table exists
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='repeater_advert_paths'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
# Already renamed or doesn't exist — ensure new table exists
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
|
||||
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("contact_advert_paths already exists or old table missing, skipping rename")
|
||||
return
|
||||
|
||||
# Create new table (IF NOT EXISTS in case SCHEMA already created it)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Copy data (INSERT OR IGNORE in case of duplicates)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO contact_advert_paths (public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
SELECT repeater_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM repeater_advert_paths
|
||||
"""
|
||||
)
|
||||
|
||||
# Drop old table
|
||||
await conn.execute("DROP TABLE repeater_advert_paths")
|
||||
|
||||
# Create index
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
|
||||
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
logger.info("Renamed repeater_advert_paths to contact_advert_paths")
|
||||
|
||||
|
||||
async def _migrate_027_backfill_first_seen_from_advert_paths(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Backfill contacts.first_seen from contact_advert_paths where advert path
|
||||
first_seen is earlier than the contact's current first_seen.
|
||||
"""
|
||||
# Guard: skip if either table doesn't exist
|
||||
for table in ("contacts", "contact_advert_paths"):
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
return
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE contacts SET first_seen = (
|
||||
SELECT MIN(cap.first_seen) FROM contact_advert_paths cap
|
||||
WHERE cap.public_key = contacts.public_key
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM contact_advert_paths cap
|
||||
WHERE cap.public_key = contacts.public_key
|
||||
AND cap.first_seen < COALESCE(contacts.first_seen, 9999999999)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
logger.debug("Backfilled first_seen from contact_advert_paths")
|
||||
|
||||
|
||||
async def _migrate_028_payload_hash_text_to_blob(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Convert payload_hash from 64-char hex TEXT to 32-byte BLOB.
|
||||
|
||||
Halves storage for both the column data and its UNIQUE index.
|
||||
Uses Python bytes.fromhex() for the conversion since SQLite's unhex()
|
||||
requires 3.41.0+ which may not be available on all deployments.
|
||||
"""
|
||||
# Guard: skip if raw_packets table doesn't exist
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='raw_packets'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
logger.debug("raw_packets table does not exist, skipping payload_hash conversion")
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
# Check column types — skip if payload_hash doesn't exist or is already BLOB
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
cols = {row[1]: row[2] for row in await cursor.fetchall()}
|
||||
if "payload_hash" not in cols:
|
||||
logger.debug("payload_hash column does not exist, skipping conversion")
|
||||
await conn.commit()
|
||||
return
|
||||
if cols["payload_hash"].upper() == "BLOB":
|
||||
logger.debug("payload_hash is already BLOB, skipping conversion")
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
logger.info("Rebuilding raw_packets to convert payload_hash TEXT → BLOB...")
|
||||
|
||||
# Create new table with BLOB type
|
||||
await conn.execute("""
|
||||
CREATE TABLE raw_packets_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash BLOB,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Batch-convert rows: read TEXT hashes, convert to bytes, insert into new table
|
||||
batch_size = 5000
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, timestamp, data, message_id, payload_hash FROM raw_packets ORDER BY id"
|
||||
)
|
||||
|
||||
total = 0
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch: list[tuple[int, int, bytes, int | None, bytes | None]] = []
|
||||
for row in rows:
|
||||
rid, ts, data, mid, ph = row[0], row[1], row[2], row[3], row[4]
|
||||
if ph is not None and isinstance(ph, str):
|
||||
try:
|
||||
ph = bytes.fromhex(ph)
|
||||
except ValueError:
|
||||
# Not a valid hex string — hash the value to produce a valid BLOB
|
||||
ph = sha256(ph.encode()).digest()
|
||||
batch.append((rid, ts, data, mid, ph))
|
||||
|
||||
await conn.executemany(
|
||||
"INSERT INTO raw_packets_new (id, timestamp, data, message_id, payload_hash) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
total += len(batch)
|
||||
|
||||
if total % 50000 == 0:
|
||||
logger.info("Converted %d rows...", total)
|
||||
|
||||
# Preserve autoincrement sequence
|
||||
cursor = await conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'raw_packets'")
|
||||
seq_row = await cursor.fetchone()
|
||||
if seq_row is not None:
|
||||
await conn.execute(
|
||||
"INSERT OR REPLACE INTO sqlite_sequence (name, seq) VALUES ('raw_packets_new', ?)",
|
||||
(seq_row[0],),
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE raw_packets")
|
||||
await conn.execute("ALTER TABLE raw_packets_new RENAME TO raw_packets")
|
||||
|
||||
# Clean up the sqlite_sequence entry for the old temp name
|
||||
await conn.execute("DELETE FROM sqlite_sequence WHERE name = 'raw_packets_new'")
|
||||
|
||||
# Recreate indexes
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
|
||||
)
|
||||
await conn.execute("CREATE INDEX idx_raw_packets_message_id ON raw_packets(message_id)")
|
||||
|
||||
await conn.commit()
|
||||
logger.info("Converted %d payload_hash values from TEXT to BLOB", total)
|
||||
|
||||
|
||||
async def _migrate_029_add_unread_covering_index(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Add a covering index for the unread counts query.
|
||||
|
||||
The /api/read-state/unreads endpoint runs three queries against messages.
|
||||
The last-message-times query (GROUP BY type, conversation_key + MAX(received_at))
|
||||
was doing a full table scan. This covering index lets SQLite resolve the
|
||||
grouping and MAX entirely from the index without touching the table.
|
||||
It also improves the unread count queries which filter on outgoing and received_at.
|
||||
"""
|
||||
# Guard: table or columns may not exist in partial-schema test setups
|
||||
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
required = {"type", "conversation_key", "outgoing", "received_at"}
|
||||
if required <= columns:
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_unread_covering "
|
||||
"ON messages(type, conversation_key, outgoing, received_at)"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_030_add_pagination_index(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Add a composite index for message pagination and drop the now-redundant
|
||||
idx_messages_conversation.
|
||||
|
||||
The pagination query (ORDER BY received_at DESC, id DESC LIMIT N) hits a
|
||||
temp B-tree sort without this index. With it, SQLite walks the index in
|
||||
order and stops after N rows — critical for channels with 30K+ messages.
|
||||
|
||||
idx_messages_conversation(type, conversation_key) is a strict prefix of
|
||||
both this index and idx_messages_unread_covering, so SQLite never picks it.
|
||||
Dropping it saves ~6 MB and one index to maintain per INSERT.
|
||||
"""
|
||||
# Guard: table or columns may not exist in partial-schema test setups
|
||||
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
required = {"type", "conversation_key", "received_at", "id"}
|
||||
if required <= columns:
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_pagination "
|
||||
"ON messages(type, conversation_key, received_at DESC, id DESC)"
|
||||
)
|
||||
await conn.execute("DROP INDEX IF EXISTS idx_messages_conversation")
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_031_add_mqtt_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Add MQTT configuration columns to app_settings."""
|
||||
# Guard: app_settings may not exist in partial-schema test setups
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
new_columns = [
|
||||
("mqtt_broker_host", "TEXT DEFAULT ''"),
|
||||
("mqtt_broker_port", "INTEGER DEFAULT 1883"),
|
||||
("mqtt_username", "TEXT DEFAULT ''"),
|
||||
("mqtt_password", "TEXT DEFAULT ''"),
|
||||
("mqtt_use_tls", "INTEGER DEFAULT 0"),
|
||||
("mqtt_tls_insecure", "INTEGER DEFAULT 0"),
|
||||
("mqtt_topic_prefix", "TEXT DEFAULT 'meshcore'"),
|
||||
("mqtt_publish_messages", "INTEGER DEFAULT 0"),
|
||||
("mqtt_publish_raw_packets", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
|
||||
for col_name, col_def in new_columns:
|
||||
if col_name not in columns:
|
||||
await conn.execute(f"ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
259
app/models.py
@@ -17,7 +17,6 @@ class Contact(BaseModel):
|
||||
on_radio: bool = False
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
@@ -32,9 +31,9 @@ class Contact(BaseModel):
|
||||
"flags": self.flags,
|
||||
"out_path": self.last_path or "",
|
||||
"out_path_len": self.last_path_len,
|
||||
"adv_lat": self.lat if self.lat is not None else 0.0,
|
||||
"adv_lon": self.lon if self.lon is not None else 0.0,
|
||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||
"adv_lat": self.lat or 0.0,
|
||||
"adv_lon": self.lon or 0.0,
|
||||
"last_advert": self.last_advert or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -73,70 +72,6 @@ class CreateContactRequest(BaseModel):
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
"""A unique advert path observed for a contact."""
|
||||
|
||||
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
|
||||
path_len: int = Field(description="Number of hops in the path")
|
||||
next_hop: str | None = Field(
|
||||
default=None, description="First hop toward us (2-char hex), or null for direct"
|
||||
)
|
||||
first_seen: int = Field(description="Unix timestamp of first observation")
|
||||
last_seen: int = Field(description="Unix timestamp of most recent observation")
|
||||
heard_count: int = Field(description="Number of times this unique path was heard")
|
||||
|
||||
|
||||
class ContactAdvertPathSummary(BaseModel):
|
||||
"""Recent unique advertisement paths for a single contact."""
|
||||
|
||||
public_key: str = Field(description="Contact public key (64-char hex)")
|
||||
paths: list[ContactAdvertPath] = Field(
|
||||
default_factory=list, description="Most recent unique advert paths"
|
||||
)
|
||||
|
||||
|
||||
class ContactNameHistory(BaseModel):
|
||||
"""A historical name used by a contact."""
|
||||
|
||||
name: str
|
||||
first_seen: int
|
||||
last_seen: int
|
||||
|
||||
|
||||
class ContactActiveRoom(BaseModel):
|
||||
"""A channel/room where a contact has been active."""
|
||||
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class NearestRepeater(BaseModel):
|
||||
"""A repeater that has relayed a contact's advertisements."""
|
||||
|
||||
public_key: str
|
||||
name: str | None = None
|
||||
path_len: int
|
||||
last_seen: int
|
||||
heard_count: int
|
||||
|
||||
|
||||
class ContactDetail(BaseModel):
|
||||
"""Comprehensive contact profile data."""
|
||||
|
||||
contact: Contact
|
||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
||||
dm_message_count: int = 0
|
||||
channel_message_count: int = 0
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
||||
advert_frequency: float | None = Field(
|
||||
default=None,
|
||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
||||
)
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
key: str = Field(description="Channel key (32-char hex)")
|
||||
name: str
|
||||
@@ -173,8 +108,6 @@ class RawPacketDecryptedInfo(BaseModel):
|
||||
|
||||
channel_name: str | None = None
|
||||
sender: str | None = None
|
||||
channel_key: str | None = None
|
||||
contact_key: str | None = None
|
||||
|
||||
|
||||
class RawPacketBroadcast(BaseModel):
|
||||
@@ -185,11 +118,6 @@ class RawPacketBroadcast(BaseModel):
|
||||
"""
|
||||
|
||||
id: int
|
||||
observation_id: int = Field(
|
||||
description=(
|
||||
"Monotonic per-process ID for this RF observation (distinct from the DB packet row ID)"
|
||||
)
|
||||
)
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g., GROUP_TEXT, ADVERT)")
|
||||
@@ -204,96 +132,19 @@ class SendMessageRequest(BaseModel):
|
||||
|
||||
|
||||
class SendDirectMessageRequest(SendMessageRequest):
|
||||
destination: str = Field(
|
||||
description="Recipient public key (64-char hex preferred; prefix must resolve uniquely)"
|
||||
)
|
||||
destination: str = Field(description="Public key or prefix of recipient")
|
||||
|
||||
|
||||
class SendChannelMessageRequest(SendMessageRequest):
|
||||
channel_key: str = Field(description="Channel key (32-char hex)")
|
||||
|
||||
|
||||
class RepeaterLoginRequest(BaseModel):
|
||||
"""Request to log in to a repeater."""
|
||||
|
||||
class TelemetryRequest(BaseModel):
|
||||
password: str = Field(
|
||||
default="", description="Repeater password (empty string for guest login)"
|
||||
default="", description="Repeater password (empty string for no password)"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterLoginResponse(BaseModel):
|
||||
"""Response from repeater login."""
|
||||
|
||||
status: str = Field(description="Login result status")
|
||||
|
||||
|
||||
class RepeaterStatusResponse(BaseModel):
|
||||
"""Status telemetry from a repeater (single attempt, no retries)."""
|
||||
|
||||
battery_volts: float = Field(description="Battery voltage in volts")
|
||||
tx_queue_len: int = Field(description="Transmit queue length")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
|
||||
last_snr_db: float = Field(description="Last SNR in dB")
|
||||
packets_received: int = Field(description="Total packets received")
|
||||
packets_sent: int = Field(description="Total packets sent")
|
||||
airtime_seconds: int = Field(description="TX airtime in seconds")
|
||||
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
|
||||
uptime_seconds: int = Field(description="Uptime in seconds")
|
||||
sent_flood: int = Field(description="Flood packets sent")
|
||||
sent_direct: int = Field(description="Direct packets sent")
|
||||
recv_flood: int = Field(description="Flood packets received")
|
||||
recv_direct: int = Field(description="Direct packets received")
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
|
||||
|
||||
class RepeaterRadioSettingsResponse(BaseModel):
|
||||
"""Radio settings from a repeater (batch CLI get commands)."""
|
||||
|
||||
firmware_version: str | None = Field(default=None, description="Firmware version string")
|
||||
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
|
||||
tx_power: str | None = Field(default=None, description="TX power in dBm")
|
||||
airtime_factor: str | None = Field(default=None, description="Airtime factor")
|
||||
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
|
||||
flood_max: str | None = Field(default=None, description="Max flood hops")
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterAdvertIntervalsResponse(BaseModel):
|
||||
"""Advertisement intervals from a repeater."""
|
||||
|
||||
advert_interval: str | None = Field(default=None, description="Local advert interval")
|
||||
flood_advert_interval: str | None = Field(default=None, description="Flood advert interval")
|
||||
|
||||
|
||||
class RepeaterOwnerInfoResponse(BaseModel):
|
||||
"""Owner info and guest password from a repeater."""
|
||||
|
||||
owner_info: str | None = Field(default=None, description="Owner info string")
|
||||
guest_password: str | None = Field(default=None, description="Guest password")
|
||||
|
||||
|
||||
class LppSensor(BaseModel):
|
||||
"""A single CayenneLPP sensor reading from req_telemetry_sync."""
|
||||
|
||||
channel: int = Field(description="LPP channel number")
|
||||
type_name: str = Field(description="Sensor type name (e.g. temperature, humidity)")
|
||||
value: float | dict = Field(
|
||||
description="Scalar value or dict for multi-value sensors (GPS, accel)"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterLppTelemetryResponse(BaseModel):
|
||||
"""CayenneLPP sensor telemetry from a repeater."""
|
||||
|
||||
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
|
||||
|
||||
|
||||
class NeighborInfo(BaseModel):
|
||||
"""Information about a neighbor seen by a repeater."""
|
||||
|
||||
@@ -314,18 +165,34 @@ class AclEntry(BaseModel):
|
||||
permission_name: str = Field(description="Human-readable permission name")
|
||||
|
||||
|
||||
class RepeaterNeighborsResponse(BaseModel):
|
||||
"""Neighbors list from a repeater."""
|
||||
class TelemetryResponse(BaseModel):
|
||||
"""Telemetry data from a repeater, formatted for human readability."""
|
||||
|
||||
pubkey_prefix: str = Field(description="12-char public key prefix")
|
||||
battery_volts: float = Field(description="Battery voltage in volts")
|
||||
tx_queue_len: int = Field(description="Transmit queue length")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
|
||||
last_snr_db: float = Field(description="Last SNR in dB")
|
||||
packets_received: int = Field(description="Total packets received")
|
||||
packets_sent: int = Field(description="Total packets sent")
|
||||
airtime_seconds: int = Field(description="TX airtime in seconds")
|
||||
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
|
||||
uptime_seconds: int = Field(description="Uptime in seconds")
|
||||
sent_flood: int = Field(description="Flood packets sent")
|
||||
sent_direct: int = Field(description="Direct packets sent")
|
||||
recv_flood: int = Field(description="Flood packets received")
|
||||
recv_direct: int = Field(description="Direct packets received")
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
neighbors: list[NeighborInfo] = Field(
|
||||
default_factory=list, description="List of neighbors seen by repeater"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterAclResponse(BaseModel):
|
||||
"""ACL list from a repeater."""
|
||||
|
||||
acl: list[AclEntry] = Field(default_factory=list, description="Access control list")
|
||||
clock_output: str | None = Field(
|
||||
default=None, description="Output from 'clock' command (or error message)"
|
||||
)
|
||||
|
||||
|
||||
class TraceResponse(BaseModel):
|
||||
@@ -391,10 +258,7 @@ class AppSettings(BaseModel):
|
||||
|
||||
max_radio_contacts: int = Field(
|
||||
default=200,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio for DM ACKs "
|
||||
"(favorite contacts first, then recent non-repeaters)"
|
||||
),
|
||||
description="Maximum non-repeater contacts to keep on radio for DM ACKs",
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
@@ -427,66 +291,3 @@ class AppSettings(BaseModel):
|
||||
default_factory=list,
|
||||
description="List of bot configurations",
|
||||
)
|
||||
mqtt_broker_host: str = Field(
|
||||
default="",
|
||||
description="MQTT broker hostname (empty = disabled)",
|
||||
)
|
||||
mqtt_broker_port: int = Field(
|
||||
default=1883,
|
||||
description="MQTT broker port",
|
||||
)
|
||||
mqtt_username: str = Field(
|
||||
default="",
|
||||
description="MQTT username (optional)",
|
||||
)
|
||||
mqtt_password: str = Field(
|
||||
default="",
|
||||
description="MQTT password (optional)",
|
||||
)
|
||||
mqtt_use_tls: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use TLS for MQTT connection",
|
||||
)
|
||||
mqtt_tls_insecure: bool = Field(
|
||||
default=False,
|
||||
description="Skip TLS certificate verification (for self-signed certs)",
|
||||
)
|
||||
mqtt_topic_prefix: str = Field(
|
||||
default="meshcore",
|
||||
description="MQTT topic prefix",
|
||||
)
|
||||
mqtt_publish_messages: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish decrypted messages to MQTT",
|
||||
)
|
||||
mqtt_publish_raw_packets: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class ContactActivityCounts(BaseModel):
|
||||
last_hour: int
|
||||
last_24_hours: int
|
||||
last_week: int
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
repeater_count: int
|
||||
channel_count: int
|
||||
total_packets: int
|
||||
decrypted_packets: int
|
||||
undecrypted_packets: int
|
||||
total_dms: int
|
||||
total_channel_messages: int
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
|
||||
223
app/mqtt.py
@@ -1,223 +0,0 @@
|
||||
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from app.models import AppSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reconnect backoff: start at 5s, cap at 30s
|
||||
_BACKOFF_MIN = 5
|
||||
_BACKOFF_MAX = 30
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: AppSettings | None = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background task and disconnect."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
|
||||
async def restart(self, settings: AppSettings) -> None:
|
||||
"""Called when MQTT settings change — stop + start."""
|
||||
await self.stop()
|
||||
await self.start(settings)
|
||||
|
||||
async def publish(self, topic: str, payload: dict[str, Any]) -> None:
|
||||
"""Publish a JSON payload. Drops silently if not connected."""
|
||||
if self._client is None or not self.connected:
|
||||
return
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.warning("MQTT publish failed on %s: %s", topic, e)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
|
||||
def _mqtt_configured(self) -> bool:
|
||||
"""Check if MQTT is configured (broker host is set)."""
|
||||
return bool(self._settings and self._settings.mqtt_broker_host)
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Background loop: connect, wait, reconnect on failure."""
|
||||
from app.websocket import broadcast_error, broadcast_success
|
||||
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
while True:
|
||||
if not self._mqtt_configured():
|
||||
self.connected = False
|
||||
self._client = None
|
||||
# Wait until settings change (which might configure MQTT)
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await self._version_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
continue
|
||||
|
||||
settings = self._settings
|
||||
assert settings is not None # guaranteed by _mqtt_configured()
|
||||
version_at_connect = self._settings_version
|
||||
|
||||
try:
|
||||
tls_context = self._build_tls_context(settings)
|
||||
|
||||
async with aiomqtt.Client(
|
||||
hostname=settings.mqtt_broker_host,
|
||||
port=settings.mqtt_broker_port,
|
||||
username=settings.mqtt_username or None,
|
||||
password=settings.mqtt_password or None,
|
||||
tls_context=tls_context,
|
||||
) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
broadcast_success(
|
||||
"MQTT connected",
|
||||
f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}",
|
||||
)
|
||||
_broadcast_mqtt_health()
|
||||
|
||||
# Wait until cancelled or settings version changes.
|
||||
# The 60s timeout is a housekeeping wake-up; actual connection
|
||||
# liveness is handled by paho-mqtt's keepalive mechanism.
|
||||
while self._settings_version == version_at_connect:
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
|
||||
broadcast_error(
|
||||
"MQTT connection failure",
|
||||
"Please correct the settings or disable.",
|
||||
)
|
||||
_broadcast_mqtt_health()
|
||||
logger.warning("MQTT connection error: %s (reconnecting in %ds)", e, backoff)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
backoff = min(backoff * 2, _BACKOFF_MAX)
|
||||
|
||||
@staticmethod
|
||||
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
|
||||
"""Build TLS context from settings, or None if TLS is disabled."""
|
||||
if not settings.mqtt_use_tls:
|
||||
return None
|
||||
ctx = ssl.create_default_context()
|
||||
if settings.mqtt_tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
mqtt_publisher = MqttPublisher()
|
||||
|
||||
|
||||
def _broadcast_mqtt_health() -> None:
|
||||
"""Push updated health (including mqtt_status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
|
||||
if event_type not in ("message", "raw_packet"):
|
||||
return
|
||||
if not mqtt_publisher.connected or mqtt_publisher._settings is None:
|
||||
return
|
||||
asyncio.create_task(_mqtt_maybe_publish(event_type, data))
|
||||
|
||||
|
||||
async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Check settings and build topic, then publish."""
|
||||
settings = mqtt_publisher._settings
|
||||
if settings is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if event_type == "message" and settings.mqtt_publish_messages:
|
||||
topic = _build_message_topic(settings.mqtt_topic_prefix, data)
|
||||
await mqtt_publisher.publish(topic, data)
|
||||
|
||||
elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
|
||||
topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
|
||||
await mqtt_publisher.publish(topic, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MQTT broadcast error: %s", e)
|
||||
|
||||
|
||||
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a decrypted message."""
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "unknown")
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"{prefix}/dm:{conversation_key}"
|
||||
elif msg_type == "CHAN":
|
||||
return f"{prefix}/gm:{conversation_key}"
|
||||
return f"{prefix}/message:{conversation_key}"
|
||||
|
||||
|
||||
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a raw packet."""
|
||||
info = data.get("decrypted_info")
|
||||
if info and isinstance(info, dict):
|
||||
contact_key = info.get("contact_key")
|
||||
channel_key = info.get("channel_key")
|
||||
if contact_key:
|
||||
return f"{prefix}/raw/dm:{contact_key}"
|
||||
if channel_key:
|
||||
return f"{prefix}/raw/gm:{channel_key}"
|
||||
return f"{prefix}/raw/unrouted"
|
||||
@@ -15,7 +15,6 @@ are offloaded from the radio to the server.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from itertools import count
|
||||
|
||||
from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
@@ -28,17 +27,9 @@ from app.decoder import (
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
Message,
|
||||
MessagePath,
|
||||
RawPacketBroadcast,
|
||||
RawPacketDecryptedInfo,
|
||||
)
|
||||
from app.models import CONTACT_TYPE_REPEATER, RawPacketBroadcast, RawPacketDecryptedInfo
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
@@ -47,8 +38,6 @@ from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_raw_observation_counter = count(1)
|
||||
|
||||
|
||||
async def _handle_duplicate_message(
|
||||
packet_id: int,
|
||||
@@ -99,22 +88,17 @@ async def _handle_duplicate_message(
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
ack_count = await MessageRepository.get_ack_count(existing_msg.id)
|
||||
|
||||
# Only broadcast when something actually changed:
|
||||
# - outgoing: ack count was incremented
|
||||
# - path provided: a new path entry was appended
|
||||
# The path=None case happens for direct-delivery DMs (0-hop, no routing bytes).
|
||||
# A non-outgoing duplicate with no new path changes nothing in the DB, so skip.
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_event(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": existing_msg.id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [p.model_dump() for p in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
# Broadcast updated paths
|
||||
broadcast_event(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": existing_msg.id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [p.model_dump() for p in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
# Mark this packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
@@ -157,13 +141,6 @@ async def create_message_from_decrypted(
|
||||
# Normalize channel key to uppercase for consistency
|
||||
channel_key_normalized = channel_key.upper()
|
||||
|
||||
# Resolve sender_key: look up contact by exact name match
|
||||
resolved_sender_key: str | None = None
|
||||
if sender:
|
||||
candidates = await ContactRepository.get_by_name(sender)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
@@ -172,8 +149,6 @@ async def create_message_from_decrypted(
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
@@ -193,38 +168,40 @@ async def create_message_from_decrypted(
|
||||
|
||||
# Build paths array for broadcast
|
||||
# Use "is not None" to include empty string (direct/0-hop messages)
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
paths = [{"path": path or "", "received_at": received}] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
).model_dump(),
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "CHAN",
|
||||
"conversation_key": channel_key_normalized,
|
||||
"text": text,
|
||||
"sender_timestamp": timestamp,
|
||||
"received_at": received,
|
||||
"paths": paths,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": False,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Run bot if enabled (for incoming channel messages, not historical decryption)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=sender,
|
||||
sender_key=None, # Channel messages don't have a sender public key
|
||||
message_text=message_text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_normalized,
|
||||
channel_name=channel_name,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
is_outgoing=False,
|
||||
)
|
||||
await run_bot_for_message(
|
||||
sender_name=sender,
|
||||
sender_key=None, # Channel messages don't have a sender public key
|
||||
message_text=message_text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_normalized,
|
||||
channel_name=channel_name,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
is_outgoing=False,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
@@ -259,7 +236,7 @@ async def create_dm_message_from_decrypted(
|
||||
"""
|
||||
# Check if sender is a repeater - repeaters only send CLI responses, not chat messages.
|
||||
# CLI responses are handled by the command endpoint, not stored in chat history.
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
contact = await ContactRepository.get_by_key_or_prefix(their_public_key)
|
||||
if contact and contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
@@ -282,7 +259,6 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received,
|
||||
path=path,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
@@ -309,21 +285,24 @@ async def create_dm_message_from_decrypted(
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
paths = [{"path": path or "", "received_at": received}] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
outgoing=outgoing,
|
||||
).model_dump(),
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "PRIV",
|
||||
"conversation_key": conversation_key,
|
||||
"text": decrypted.message,
|
||||
"sender_timestamp": decrypted.timestamp,
|
||||
"received_at": received,
|
||||
"paths": paths,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": outgoing,
|
||||
"acked": 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
@@ -337,18 +316,16 @@ async def create_dm_message_from_decrypted(
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
sender_name = contact.name if contact else None
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=sender_name,
|
||||
sender_key=their_public_key,
|
||||
message_text=decrypted.message,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
is_outgoing=outgoing,
|
||||
)
|
||||
await run_bot_for_message(
|
||||
sender_name=sender_name,
|
||||
sender_key=their_public_key,
|
||||
message_text=decrypted.message,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
is_outgoing=outgoing,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
@@ -377,14 +354,11 @@ async def run_historical_dm_decryption(
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
# Note: passing our_public_key=None disables the outbound hash check in
|
||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||
# For the 255/256 case where our first byte differs from the contact's,
|
||||
# outgoing packets fail the inbound check and are skipped — which is correct
|
||||
# since outgoing DMs are stored directly by the send endpoint.
|
||||
# For the 1/256 case where bytes match, an outgoing packet may decrypt
|
||||
# successfully, but the dual-hash direction check below correctly identifies
|
||||
# it and the DB dedup constraint prevents a duplicate insert.
|
||||
# Note: passing our_public_key=None means outgoing DMs won't be matched
|
||||
# by try_decrypt_dm (the inbound check requires src_hash == their_first_byte,
|
||||
# which fails for our outgoing packets). This is acceptable because outgoing
|
||||
# DMs are stored directly by the send endpoint. Historical decryption only
|
||||
# recovers incoming messages.
|
||||
result = try_decrypt_dm(
|
||||
packet_data,
|
||||
private_key_bytes,
|
||||
@@ -393,20 +367,10 @@ async def run_historical_dm_decryption(
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Determine direction using both hashes (mirrors _process_direct_message
|
||||
# logic at lines 806-818) to handle the 1/256 case where our first
|
||||
# public key byte matches the contact's.
|
||||
# Determine direction by checking src_hash
|
||||
src_hash = result.src_hash.lower()
|
||||
dest_hash = result.dest_hash.lower()
|
||||
our_first_byte = format(our_public_key_bytes[0], "02x").lower()
|
||||
|
||||
if src_hash == our_first_byte and dest_hash != our_first_byte:
|
||||
outgoing = True
|
||||
else:
|
||||
# Incoming, ambiguous (both match), or neither matches.
|
||||
# Default to incoming — outgoing DMs are stored by the send
|
||||
# endpoint, so historical decryption only recovers incoming.
|
||||
outgoing = False
|
||||
outgoing = src_hash == our_first_byte
|
||||
|
||||
# Extract path from the raw packet for storage
|
||||
packet_info = parse_packet(packet_data)
|
||||
@@ -508,7 +472,6 @@ async def process_raw_packet(
|
||||
since the original packet was already processed.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
observation_id = next(_raw_observation_counter)
|
||||
|
||||
packet_id, is_new_packet = await RawPacketRepository.create(raw_bytes, ts)
|
||||
raw_hex = raw_bytes.hex()
|
||||
@@ -550,10 +513,8 @@ async def process_raw_packet(
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.ADVERT:
|
||||
# Process all advert arrivals (even payload-hash duplicates) so the
|
||||
# path-freshness logic in _process_advertisement can pick the shortest
|
||||
# path heard within the freshness window.
|
||||
elif payload_type == PayloadType.ADVERT and is_new_packet:
|
||||
# Only process new advertisements (duplicates don't add value)
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
@@ -566,7 +527,6 @@ async def process_raw_packet(
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
broadcast_payload = RawPacketBroadcast(
|
||||
id=packet_id,
|
||||
observation_id=observation_id,
|
||||
timestamp=ts,
|
||||
data=raw_hex,
|
||||
payload_type=payload_type_name,
|
||||
@@ -576,8 +536,6 @@ async def process_raw_packet(
|
||||
decrypted_info=RawPacketDecryptedInfo(
|
||||
channel_name=result["channel_name"],
|
||||
sender=result["sender"],
|
||||
channel_key=result.get("channel_key"),
|
||||
contact_key=result.get("contact_key"),
|
||||
)
|
||||
if result["decrypted"]
|
||||
else None,
|
||||
@@ -634,7 +592,6 @@ async def _process_group_text(
|
||||
"channel_name": channel.name,
|
||||
"sender": decrypted.sender,
|
||||
"message_id": msg_id, # None if duplicate, msg_id if new
|
||||
"channel_key": channel.key,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known key
|
||||
@@ -715,22 +672,6 @@ async def _process_advertisement(
|
||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||
)
|
||||
|
||||
# Keep recent unique advert paths for all contacts.
|
||||
await ContactAdvertPathRepository.record_observation(
|
||||
public_key=advert.public_key.lower(),
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
)
|
||||
|
||||
# Record name history
|
||||
if advert.name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
contact_data = {
|
||||
"public_key": advert.public_key.lower(),
|
||||
"name": advert.name,
|
||||
@@ -741,25 +682,27 @@ async def _process_advertisement(
|
||||
"last_seen": timestamp,
|
||||
"last_path": path_hex,
|
||||
"last_path_len": path_len,
|
||||
"first_seen": timestamp, # COALESCE in upsert preserves existing value
|
||||
}
|
||||
|
||||
await ContactRepository.upsert(contact_data)
|
||||
claimed = await MessageRepository.claim_prefix_messages(advert.public_key.lower())
|
||||
if claimed > 0:
|
||||
logger.info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
advert.public_key[:12],
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly.
|
||||
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
if db_contact:
|
||||
broadcast_event("contact", db_contact.model_dump())
|
||||
else:
|
||||
broadcast_event("contact", contact_data)
|
||||
# Broadcast contact update to connected clients
|
||||
broadcast_event(
|
||||
"contact",
|
||||
{
|
||||
"public_key": advert.public_key.lower(),
|
||||
"name": advert.name,
|
||||
"type": contact_type,
|
||||
"flags": existing.flags if existing else 0,
|
||||
"last_path": path_hex,
|
||||
"last_path_len": path_len,
|
||||
"last_advert": advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_seen": timestamp,
|
||||
"on_radio": existing.on_radio if existing else False,
|
||||
},
|
||||
)
|
||||
|
||||
# For new contacts, optionally attempt to decrypt any historical DMs we may have stored
|
||||
# This is controlled by the auto_decrypt_dm_on_advert setting
|
||||
@@ -891,7 +834,6 @@ async def _process_direct_message(
|
||||
"contact_name": contact.name,
|
||||
"sender": contact.name or contact.public_key[:12],
|
||||
"message_id": msg_id,
|
||||
"contact_key": contact.public_key,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known contact
|
||||
|
||||
328
app/radio.py
@@ -2,7 +2,6 @@ import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import platform
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore import MeshCore
|
||||
@@ -12,18 +11,6 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RadioOperationError(RuntimeError):
|
||||
"""Base class for shared radio operation lock errors."""
|
||||
|
||||
|
||||
class RadioOperationBusyError(RadioOperationError):
|
||||
"""Raised when a non-blocking radio operation cannot acquire the lock."""
|
||||
|
||||
|
||||
class RadioDisconnectedError(RadioOperationError):
|
||||
"""Raised when the radio disconnects between pre-check and lock acquisition."""
|
||||
|
||||
|
||||
def detect_serial_devices() -> list[str]:
|
||||
"""Detect available serial devices based on platform."""
|
||||
devices: list[str] = []
|
||||
@@ -68,7 +55,6 @@ def detect_serial_devices() -> list[str]:
|
||||
|
||||
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."""
|
||||
mc = None
|
||||
try:
|
||||
logger.debug("Testing serial device %s", port)
|
||||
mc = await asyncio.wait_for(
|
||||
@@ -79,8 +65,10 @@ async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) ->
|
||||
# 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)
|
||||
@@ -88,12 +76,6 @@ async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) ->
|
||||
except Exception as e:
|
||||
logger.debug("Device %s failed: %s", port, e)
|
||||
return False
|
||||
finally:
|
||||
if mc is not None:
|
||||
try:
|
||||
await mc.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def find_radio_port(baudrate: int) -> str | None:
|
||||
@@ -120,100 +102,10 @@ class RadioManager:
|
||||
|
||||
def __init__(self):
|
||||
self._meshcore: MeshCore | None = None
|
||||
self._connection_info: str | None = None
|
||||
self._port: str | None = None
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._last_connected: bool = False
|
||||
self._reconnect_lock: asyncio.Lock | None = None
|
||||
self._operation_lock: asyncio.Lock | None = None
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self._loopback_active: bool = False
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
blocking: bool,
|
||||
) -> None:
|
||||
"""Acquire the shared radio operation lock."""
|
||||
|
||||
if self._operation_lock is None:
|
||||
self._operation_lock = asyncio.Lock()
|
||||
|
||||
if not blocking:
|
||||
if self._operation_lock.locked():
|
||||
raise RadioOperationBusyError(f"Radio is busy (operation: {name})")
|
||||
await self._operation_lock.acquire()
|
||||
else:
|
||||
await self._operation_lock.acquire()
|
||||
|
||||
logger.debug("Acquired radio operation lock (%s)", name)
|
||||
|
||||
def _release_operation_lock(self, name: str) -> None:
|
||||
"""Release the shared radio operation lock."""
|
||||
if self._operation_lock and self._operation_lock.locked():
|
||||
self._operation_lock.release()
|
||||
logger.debug("Released radio operation lock (%s)", name)
|
||||
else:
|
||||
logger.error("Attempted to release unlocked radio operation lock (%s)", name)
|
||||
|
||||
@asynccontextmanager
|
||||
async def radio_operation(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
pause_polling: bool = False,
|
||||
suspend_auto_fetch: bool = False,
|
||||
blocking: bool = True,
|
||||
):
|
||||
"""Acquire shared radio lock and optionally pause polling / auto-fetch.
|
||||
|
||||
After acquiring the lock, resolves the current MeshCore instance and
|
||||
yields it. Callers get a fresh reference via ``async with ... as mc:``,
|
||||
avoiding stale-reference bugs when a reconnect swaps ``_meshcore``
|
||||
between the pre-check and the lock acquisition.
|
||||
|
||||
Args:
|
||||
name: Human-readable operation name for logs/errors.
|
||||
pause_polling: Pause fallback message polling while held.
|
||||
suspend_auto_fetch: Stop MeshCore auto message fetching while held.
|
||||
blocking: If False, fail immediately when lock is held.
|
||||
|
||||
Raises:
|
||||
RadioDisconnectedError: If the radio disconnected before the lock
|
||||
was acquired (``_meshcore`` is ``None``).
|
||||
"""
|
||||
await self._acquire_operation_lock(name, blocking=blocking)
|
||||
|
||||
mc = self._meshcore
|
||||
if mc is None:
|
||||
self._release_operation_lock(name)
|
||||
raise RadioDisconnectedError("Radio disconnected")
|
||||
|
||||
poll_context = nullcontext()
|
||||
if pause_polling:
|
||||
from app.radio_sync import pause_polling as pause_polling_context
|
||||
|
||||
poll_context = pause_polling_context()
|
||||
|
||||
auto_fetch_paused = False
|
||||
|
||||
try:
|
||||
async with poll_context:
|
||||
if suspend_auto_fetch:
|
||||
await mc.stop_auto_message_fetching()
|
||||
auto_fetch_paused = True
|
||||
yield mc
|
||||
finally:
|
||||
try:
|
||||
if auto_fetch_paused:
|
||||
try:
|
||||
await mc.start_auto_message_fetching()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to restart auto message fetching (%s): %s", name, e)
|
||||
finally:
|
||||
self._release_operation_lock(name)
|
||||
|
||||
async def post_connect_setup(self) -> None:
|
||||
"""Full post-connection setup: handlers, key export, sync, advertisements, polling.
|
||||
@@ -236,61 +128,39 @@ class RadioManager:
|
||||
if not self._meshcore:
|
||||
return
|
||||
|
||||
if self._setup_lock is None:
|
||||
self._setup_lock = asyncio.Lock()
|
||||
register_event_handlers(self._meshcore)
|
||||
await export_and_store_private_key(self._meshcore)
|
||||
|
||||
async with self._setup_lock:
|
||||
if not self._meshcore:
|
||||
return
|
||||
self._setup_in_progress = True
|
||||
self._setup_complete = False
|
||||
mc = self._meshcore
|
||||
try:
|
||||
# Register event handlers (no radio I/O, just callback setup)
|
||||
register_event_handlers(mc)
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time()
|
||||
|
||||
# Hold the operation lock for all radio I/O during setup.
|
||||
# This prevents user-initiated operations (send message, etc.)
|
||||
# from interleaving commands on the serial link.
|
||||
await self._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
await export_and_store_private_key(mc)
|
||||
# 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)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
# Start periodic sync (idempotent)
|
||||
start_periodic_sync()
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement():
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
# Start periodic advertisement (idempotent)
|
||||
start_periodic_advert()
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
await self._meshcore.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
self._release_operation_lock("post_connect_setup")
|
||||
# Drain any messages that were queued before we connected
|
||||
drained = await drain_pending_messages()
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
|
||||
self._setup_complete = True
|
||||
finally:
|
||||
self._setup_in_progress = False
|
||||
# Start periodic message polling as fallback (idempotent)
|
||||
start_message_polling()
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
|
||||
@@ -299,8 +169,8 @@ class RadioManager:
|
||||
return self._meshcore
|
||||
|
||||
@property
|
||||
def connection_info(self) -> str | None:
|
||||
return self._connection_info
|
||||
def port(self) -> str | None:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
@@ -310,59 +180,11 @@ class RadioManager:
|
||||
def is_reconnecting(self) -> bool:
|
||||
return self._reconnect_lock is not None and self._reconnect_lock.locked()
|
||||
|
||||
@property
|
||||
def is_setup_in_progress(self) -> bool:
|
||||
return self._setup_in_progress
|
||||
|
||||
@property
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self._setup_complete
|
||||
|
||||
@property
|
||||
def loopback_active(self) -> bool:
|
||||
return self._loopback_active
|
||||
|
||||
def connect_loopback(self, mc: MeshCore, connection_info: str) -> None:
|
||||
"""Adopt a MeshCore instance created by the loopback WebSocket endpoint."""
|
||||
self._meshcore = mc
|
||||
self._connection_info = connection_info
|
||||
self._loopback_active = True
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
|
||||
async def disconnect_loopback(self) -> None:
|
||||
"""Tear down a loopback session and resume normal auto-detect."""
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
mc = self._meshcore
|
||||
self._meshcore = None
|
||||
self._loopback_active = False
|
||||
self._connection_info = None
|
||||
self._setup_complete = False
|
||||
|
||||
if mc is not None:
|
||||
try:
|
||||
await mc.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
broadcast_health(False, None)
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
"""Connect to the radio over serial."""
|
||||
if self._meshcore is not None:
|
||||
await self.disconnect()
|
||||
|
||||
connection_type = settings.connection_type
|
||||
if connection_type == "tcp":
|
||||
await self._connect_tcp()
|
||||
elif connection_type == "ble":
|
||||
await self._connect_ble()
|
||||
else:
|
||||
await self._connect_serial()
|
||||
|
||||
async def _connect_serial(self) -> None:
|
||||
"""Connect to the radio over serial."""
|
||||
port = settings.serial_port
|
||||
|
||||
# Auto-detect if no port specified
|
||||
@@ -383,55 +205,19 @@ class RadioManager:
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=10,
|
||||
)
|
||||
self._connection_info = f"Serial: {port}"
|
||||
self._port = port
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
logger.debug("Serial connection established")
|
||||
|
||||
async def _connect_tcp(self) -> None:
|
||||
"""Connect to the radio over TCP."""
|
||||
host = settings.tcp_host
|
||||
port = settings.tcp_port
|
||||
|
||||
logger.debug("Connecting to radio at %s:%d (TCP)", host, port)
|
||||
self._meshcore = await MeshCore.create_tcp(
|
||||
host=host,
|
||||
port=port,
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=10,
|
||||
)
|
||||
self._connection_info = f"TCP: {host}:{port}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
logger.debug("TCP connection established")
|
||||
|
||||
async def _connect_ble(self) -> None:
|
||||
"""Connect to the radio over BLE."""
|
||||
address = settings.ble_address
|
||||
pin = settings.ble_pin
|
||||
|
||||
logger.debug("Connecting to radio at %s (BLE)", address)
|
||||
self._meshcore = await MeshCore.create_ble(
|
||||
address=address,
|
||||
pin=pin,
|
||||
auto_reconnect=True,
|
||||
max_reconnect_attempts=15,
|
||||
)
|
||||
self._connection_info = f"BLE: {address}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
logger.debug("BLE 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
|
||||
self._setup_complete = False
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
async def reconnect(self) -> bool:
|
||||
"""Attempt to reconnect to the radio.
|
||||
|
||||
Returns True if reconnection was successful, False otherwise.
|
||||
@@ -443,13 +229,12 @@ class RadioManager:
|
||||
if self._reconnect_lock is None:
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
|
||||
async with self._reconnect_lock:
|
||||
# If we became connected while waiting for the lock (another
|
||||
# reconnect succeeded ahead of us), skip the redundant attempt.
|
||||
if self.is_connected:
|
||||
logger.debug("Already connected after acquiring lock, skipping reconnect")
|
||||
return True
|
||||
# Try to acquire lock without blocking to check if reconnect is in progress
|
||||
if self._reconnect_lock.locked():
|
||||
logger.debug("Reconnection already in progress")
|
||||
return False
|
||||
|
||||
async with self._reconnect_lock:
|
||||
logger.info("Attempting to reconnect to radio...")
|
||||
|
||||
try:
|
||||
@@ -465,9 +250,8 @@ class RadioManager:
|
||||
await self.connect()
|
||||
|
||||
if self.is_connected:
|
||||
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, self._connection_info)
|
||||
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()")
|
||||
@@ -486,15 +270,9 @@ class RadioManager:
|
||||
async def monitor_loop():
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# Skip auto-detect/reconnect while loopback is active
|
||||
if self._loopback_active:
|
||||
continue
|
||||
await asyncio.sleep(5) # Check every 5 seconds
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
@@ -502,32 +280,20 @@ class RadioManager:
|
||||
if self._last_connected and not current_connected:
|
||||
# Connection lost
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, self._connection_info)
|
||||
broadcast_health(False, self._port)
|
||||
self._last_connected = False
|
||||
|
||||
if not current_connected:
|
||||
# Attempt reconnection on every loop while disconnected
|
||||
if not self.is_reconnecting and await self.reconnect(
|
||||
broadcast_on_success=False
|
||||
):
|
||||
# Attempt reconnection
|
||||
await asyncio.sleep(3) # Wait a bit before trying
|
||||
if await self.reconnect():
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically).
|
||||
# Always run setup before reporting healthy.
|
||||
# Connection restored (might have reconnected automatically)
|
||||
logger.info("Radio connection restored")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
broadcast_health(True, self._port)
|
||||
self._last_connected = True
|
||||
|
||||
elif current_connected and not self._setup_complete:
|
||||
# Transport connected but setup incomplete — retry
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task is being cancelled, exit cleanly
|
||||
break
|
||||
|
||||
@@ -14,53 +14,19 @@ import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from meshcore import EventType, MeshCore
|
||||
from meshcore import EventType
|
||||
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.models import Contact
|
||||
from app.radio import RadioOperationBusyError, radio_manager
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> str | None:
|
||||
"""Parse a radio channel-slot payload and upsert to the database.
|
||||
|
||||
Returns the uppercase hex key if a channel was upserted, or None if the
|
||||
slot was empty/invalid.
|
||||
"""
|
||||
name = payload.get("channel_name", "")
|
||||
secret = payload.get("channel_secret", b"")
|
||||
|
||||
# Skip empty channels
|
||||
if not name or name == "\x00" * len(name):
|
||||
return None
|
||||
|
||||
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=on_radio,
|
||||
)
|
||||
return key_hex
|
||||
|
||||
|
||||
# Message poll task handle
|
||||
_message_poll_task: asyncio.Task | None = None
|
||||
|
||||
# Message poll interval in seconds (10s gives DM ACKs plenty of time to arrive)
|
||||
MESSAGE_POLL_INTERVAL = 10
|
||||
# Message poll interval in seconds
|
||||
MESSAGE_POLL_INTERVAL = 5
|
||||
|
||||
# Periodic advertisement task handle
|
||||
_advert_task: asyncio.Task | None = None
|
||||
@@ -69,11 +35,6 @@ _advert_task: asyncio.Task | None = None
|
||||
# We still need to periodically check if it's been enabled
|
||||
ADVERT_CHECK_INTERVAL = 60
|
||||
|
||||
# Minimum allowed advertisement interval (1 hour).
|
||||
# Even if the database has a shorter value, we silently refuse to advertise
|
||||
# more frequently than this.
|
||||
MIN_ADVERT_INTERVAL = 3600
|
||||
|
||||
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||
_polling_pause_count: int = 0
|
||||
|
||||
@@ -104,11 +65,16 @@ _sync_task: asyncio.Task | None = None
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
|
||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
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
|
||||
|
||||
@@ -129,13 +95,6 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
claimed = await MessageRepository.claim_prefix_messages(public_key.lower())
|
||||
if claimed > 0:
|
||||
logger.info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
public_key[:12],
|
||||
)
|
||||
synced += 1
|
||||
|
||||
# Remove from radio
|
||||
@@ -143,30 +102,6 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
||||
# commands.remove_contact() sends the remove command over
|
||||
# the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the
|
||||
# library — there's no public API to clear a single
|
||||
# contact from the cache, and the library only refreshes
|
||||
# it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
||||
# mc.get_contact_by_key_prefix() to check whether a
|
||||
# contact is already loaded on the radio. That method
|
||||
# searches mc._contacts. If we don't evict the removed
|
||||
# contact from the cache here, get_contact_by_key_prefix()
|
||||
# will still find it and skip the add_contact() call —
|
||||
# meaning contacts never get loaded back onto the radio
|
||||
# after offload. The result: no DM ACKs, degraded routing
|
||||
# for potentially minutes until the next periodic sync
|
||||
# refreshes the cache from the (now-empty) radio.
|
||||
#
|
||||
# We access mc._contacts directly because the library
|
||||
# exposes it as a read-only property (mc.contacts) with
|
||||
# no removal API. The dict is keyed by public_key string.
|
||||
mc._contacts.pop(public_key, None)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||
@@ -183,11 +118,16 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
return {"synced": synced, "removed": removed}
|
||||
|
||||
|
||||
async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
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
|
||||
|
||||
@@ -199,15 +139,29 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
continue
|
||||
|
||||
key_hex = await upsert_channel_from_radio_slot(
|
||||
result.payload,
|
||||
on_radio=False, # We're about to clear it
|
||||
)
|
||||
if key_hex is None:
|
||||
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], result.payload.get("channel_name"))
|
||||
logger.debug("Synced channel %s: %s", key_hex[:8], name)
|
||||
|
||||
# Clear from radio (set empty name and zero key)
|
||||
try:
|
||||
@@ -254,36 +208,33 @@ async def ensure_default_channels() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
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(mc)
|
||||
channels_result = await sync_and_offload_channels(mc)
|
||||
contacts_result = await sync_and_offload_contacts()
|
||||
channels_result = await sync_and_offload_channels()
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites and recent contacts back onto the radio immediately
|
||||
# so favorited contacts don't stay in the on_radio=False limbo until the
|
||||
# next advertisement arrives. Pass mc directly since the caller already
|
||||
# holds the radio operation lock (asyncio.Lock is not reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
"contacts": contacts_result,
|
||||
"channels": channels_result,
|
||||
"reloaded": reload_result,
|
||||
}
|
||||
|
||||
|
||||
async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
async def drain_pending_messages() -> int:
|
||||
"""
|
||||
Drain all pending messages from the radio.
|
||||
|
||||
Calls get_msg() repeatedly until NO_MORE_MSGS is received.
|
||||
Returns the count of messages retrieved.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
return 0
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
count = 0
|
||||
max_iterations = 100 # Safety limit
|
||||
|
||||
@@ -311,7 +262,7 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
return count
|
||||
|
||||
|
||||
async def poll_for_messages(mc: MeshCore) -> int:
|
||||
async def poll_for_messages() -> int:
|
||||
"""
|
||||
Poll the radio for any pending messages (single pass).
|
||||
|
||||
@@ -320,6 +271,10 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
|
||||
Returns the count of messages retrieved.
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
return 0
|
||||
|
||||
mc = radio_manager.meshcore
|
||||
count = 0
|
||||
|
||||
try:
|
||||
@@ -334,7 +289,7 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
count += 1
|
||||
# If we got a message, there might be more - drain them
|
||||
count += await drain_pending_messages(mc)
|
||||
count += await drain_pending_messages()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
@@ -350,20 +305,8 @@ async def _message_poll_loop():
|
||||
try:
|
||||
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
||||
|
||||
# Clean up expired pending ACKs every poll cycle so they don't
|
||||
# accumulate when no ACKs arrive (e.g. all recipients out of range).
|
||||
cleanup_expired_acks()
|
||||
|
||||
if radio_manager.is_connected and not is_polling_paused():
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"message_poll_loop",
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await poll_for_messages(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
await poll_for_messages()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
@@ -392,18 +335,21 @@ async def stop_message_polling():
|
||||
logger.info("Stopped periodic message polling")
|
||||
|
||||
|
||||
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
||||
async def send_advertisement(force: bool = False) -> bool:
|
||||
"""Send an advertisement to announce presence on the mesh.
|
||||
|
||||
Respects the configured advert_interval - won't send if not enough time
|
||||
has elapsed since the last advertisement, unless force=True.
|
||||
|
||||
Args:
|
||||
mc: The MeshCore instance to use for the advertisement.
|
||||
force: If True, send immediately regardless of interval.
|
||||
|
||||
Returns True if successful, False otherwise (including if throttled).
|
||||
"""
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot send advertisement: radio not connected")
|
||||
return False
|
||||
|
||||
# Check if enough time has elapsed (unless forced)
|
||||
if not force:
|
||||
settings = await AppSettingsRepository.get()
|
||||
@@ -416,9 +362,6 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
||||
logger.debug("Advertisement skipped: periodic advertising is disabled")
|
||||
return False
|
||||
|
||||
# Enforce minimum interval floor
|
||||
interval = max(interval, MIN_ADVERT_INTERVAL)
|
||||
|
||||
# Check if enough time has passed
|
||||
elapsed = now - last_time
|
||||
if elapsed < interval:
|
||||
@@ -432,7 +375,7 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = await mc.commands.send_advert(flood=True)
|
||||
result = await radio_manager.meshcore.commands.send_advert(flood=True)
|
||||
if result.type == EventType.OK:
|
||||
# Update last_advert_time in database
|
||||
now = int(time.time())
|
||||
@@ -456,19 +399,13 @@ async def _periodic_advert_loop():
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
# Try to send - send_advertisement() handles all checks
|
||||
# (disabled, throttled, not connected)
|
||||
if radio_manager.is_connected:
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"periodic_advertisement",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
await send_advertisement(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic advertisement: radio busy")
|
||||
await send_advertisement()
|
||||
|
||||
# Sleep before next check
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic advertisement task cancelled")
|
||||
@@ -503,11 +440,16 @@ async def stop_periodic_advert():
|
||||
logger.info("Stopped periodic advertisement")
|
||||
|
||||
|
||||
async def sync_radio_time(mc: MeshCore) -> bool:
|
||||
async def sync_radio_time() -> bool:
|
||||
"""Sync the radio's clock with the system time.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
mc = radio_manager.meshcore
|
||||
if not mc:
|
||||
logger.debug("Cannot sync time: radio not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
now = int(time.time())
|
||||
await mc.commands.set_time(now)
|
||||
@@ -523,19 +465,9 @@ async def _periodic_sync_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_INTERVAL)
|
||||
if not radio_manager.is_connected:
|
||||
continue
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"periodic_sync",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all(mc)
|
||||
await sync_radio_time(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic sync: radio busy")
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all()
|
||||
await sync_radio_time()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic sync task cancelled")
|
||||
break
|
||||
@@ -569,119 +501,13 @@ _last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
|
||||
|
||||
async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
async def sync_recent_contacts_to_radio(force: bool = False) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
Load recent non-repeater contacts to the radio for DM ACK support.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
favorite_contacts_loaded = 0
|
||||
for favorite in app_settings.favorites:
|
||||
if favorite.type != "contact":
|
||||
continue
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
favorite_contacts_loaded += 1
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < max_contacts:
|
||||
recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
|
||||
for contact in recent_contacts:
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
max_contacts,
|
||||
)
|
||||
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in selected_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,
|
||||
}
|
||||
|
||||
|
||||
async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None = None) -> dict:
|
||||
"""
|
||||
Load contacts to the radio for DM ACK support.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
This ensures the radio can auto-ACK incoming DMs from recent contacts.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Args:
|
||||
force: Skip the throttle check.
|
||||
mc: Optional MeshCore instance. When provided, the caller already holds
|
||||
the radio operation lock and the inner logic runs directly.
|
||||
When None, this function acquires its own lock.
|
||||
|
||||
Returns counts of contacts loaded.
|
||||
"""
|
||||
global _last_contact_sync
|
||||
@@ -692,25 +518,62 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
logger.debug("Contact sync throttled (last sync %ds ago)", int(now - _last_contact_sync))
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
# If caller provided a MeshCore instance, use it directly (caller holds the lock)
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
|
||||
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:
|
||||
async with radio_manager.radio_operation(
|
||||
"sync_recent_contacts_to_radio",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping contact sync to radio: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
# Get recent non-repeater contacts from database
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_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)
|
||||
|
||||
918
app/repository.py
Normal file
@@ -0,0 +1,918 @@
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from hashlib import sha256
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
from app.models import (
|
||||
AppSettings,
|
||||
BotConfig,
|
||||
Channel,
|
||||
Contact,
|
||||
Favorite,
|
||||
Message,
|
||||
MessagePath,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
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", "").lower(),
|
||||
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"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(public_key: str) -> Contact | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key = ?", (public_key.lower(),)
|
||||
)
|
||||
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.lower()}%",),
|
||||
)
|
||||
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.lower()),
|
||||
)
|
||||
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.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contacts WHERE public_key = ?",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
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.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(public_key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a contact.
|
||||
|
||||
Returns True if a row was updated, False if contact not found.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE contacts SET last_read_at = ? WHERE public_key = ?",
|
||||
(ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (timestamp,))
|
||||
|
||||
@staticmethod
|
||||
async def get_by_pubkey_first_byte(hex_byte: str) -> list[Contact]:
|
||||
"""Get contacts whose public key starts with the given hex byte (2 chars)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE substr(public_key, 1, 2) = ?",
|
||||
(hex_byte.lower(),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
|
||||
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, last_read_at 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"]),
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio, last_read_at 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"]),
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@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()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a channel.
|
||||
|
||||
Returns True if a row was updated, False if channel not found.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET last_read_at = ? WHERE key = ?",
|
||||
(ts, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE channels SET last_read_at = ?", (timestamp,))
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@staticmethod
|
||||
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
|
||||
"""Parse paths JSON string to list of MessagePath objects."""
|
||||
if not paths_json:
|
||||
return None
|
||||
try:
|
||||
paths_data = json.loads(paths_json)
|
||||
return [MessagePath(**p) for p in paths_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
msg_type: str,
|
||||
text: str,
|
||||
received_at: int,
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | 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.
|
||||
|
||||
The path parameter is converted to the paths JSON array format.
|
||||
"""
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
paths_json = json.dumps([{"path": path, "received_at": received_at}])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
received_at, paths, txt_type, signature, outgoing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
msg_type,
|
||||
conversation_key,
|
||||
text,
|
||||
sender_timestamp,
|
||||
received_at,
|
||||
paths_json,
|
||||
txt_type,
|
||||
signature,
|
||||
outgoing,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
# rowcount is 0 if INSERT was ignored due to UNIQUE constraint violation
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
return cursor.lastrowid
|
||||
|
||||
@staticmethod
|
||||
async def add_path(
|
||||
message_id: int, path: str, received_at: int | None = None
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
This is used when a repeat/echo of a message arrives via a different route.
|
||||
Returns the updated list of paths.
|
||||
"""
|
||||
ts = received_at or int(time.time())
|
||||
|
||||
# Get current paths
|
||||
cursor = await db.conn.execute("SELECT paths FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return []
|
||||
|
||||
# Parse existing paths or start with empty list
|
||||
existing_paths = []
|
||||
if row["paths"]:
|
||||
try:
|
||||
existing_paths = json.loads(row["paths"])
|
||||
except json.JSONDecodeError:
|
||||
existing_paths = []
|
||||
|
||||
# Add new path
|
||||
existing_paths.append({"path": path, "received_at": ts})
|
||||
|
||||
# Update database
|
||||
paths_json = json.dumps(existing_paths)
|
||||
await db.conn.execute(
|
||||
"UPDATE messages SET paths = ? WHERE id = ?",
|
||||
(paths_json, message_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
return [MessagePath(**p) for p in existing_paths]
|
||||
|
||||
@staticmethod
|
||||
async def claim_prefix_messages(full_key: str) -> int:
|
||||
"""Promote prefix-stored messages to the full conversation key.
|
||||
|
||||
When a full key becomes known for a contact, any messages stored with
|
||||
only a prefix as conversation_key are updated to use the full key.
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
before: int | None = None,
|
||||
before_id: int | 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}%")
|
||||
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
params.append(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"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
"""Increment ack count and return the new value."""
|
||||
await db.conn.execute("UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 1
|
||||
|
||||
@staticmethod
|
||||
async def get_ack_count(message_id: int) -> int:
|
||||
"""Get the current ack count for a message."""
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
""",
|
||||
(msg_type, conversation_key, text, sender_timestamp, sender_timestamp),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
paths = None
|
||||
if row["paths"]:
|
||||
try:
|
||||
paths_data = json.loads(row["paths"])
|
||||
paths = [
|
||||
MessagePath(path=p["path"], received_at=p["received_at"]) for p in paths_data
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
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"],
|
||||
paths=paths,
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_counts(name: str | None = None) -> dict:
|
||||
"""Get unread message counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Args:
|
||||
name: User's display name for @[name] mention detection. If None, mentions are skipped.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
mention_flags: dict[str, bool] = {}
|
||||
last_message_times: dict[str, int] = {}
|
||||
|
||||
mention_pattern = f"%@[{name}]%" if name else None
|
||||
|
||||
# Channel unreads
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
MAX(m.received_at) as last_message_time,
|
||||
SUM(CASE WHEN m.text LIKE ? THEN 1 ELSE 0 END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_pattern or "",),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"channel-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_pattern and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Contact unreads
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
MAX(m.received_at) as last_message_time,
|
||||
SUM(CASE WHEN m.text LIKE ? THEN 1 ELSE 0 END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_pattern or "",),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"contact-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_pattern and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
prefix = "channel" if row["type"] == "CHAN" else "contact"
|
||||
state_key = f"{prefix}-{row['conversation_key']}"
|
||||
last_message_times[state_key] = row["last_message_time"]
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
"last_message_times": last_message_times,
|
||||
}
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
async def create(data: bytes, timestamp: int | None = None) -> tuple[int, bool]:
|
||||
"""
|
||||
Create a raw packet with payload-based deduplication.
|
||||
|
||||
Returns (packet_id, is_new) tuple:
|
||||
- is_new=True: New packet stored, packet_id is the new row ID
|
||||
- is_new=False: Duplicate payload detected, packet_id is the existing row ID
|
||||
|
||||
Deduplication is based on the SHA-256 hash of the packet payload
|
||||
(excluding routing/path information).
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
|
||||
# Compute payload hash for deduplication
|
||||
payload = extract_payload(data)
|
||||
if payload:
|
||||
payload_hash = sha256(payload).hexdigest()
|
||||
else:
|
||||
# For malformed packets, hash the full data
|
||||
payload_hash = sha256(data).hexdigest()
|
||||
|
||||
# Check if this payload already exists
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Duplicate - return existing packet ID
|
||||
logger.debug(
|
||||
"Duplicate payload detected (hash=%s..., existing_id=%d)",
|
||||
payload_hash[:12],
|
||||
existing["id"],
|
||||
)
|
||||
return (existing["id"], False)
|
||||
|
||||
# New packet - insert with hash
|
||||
try:
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(ts, data, payload_hash),
|
||||
)
|
||||
await db.conn.commit()
|
||||
assert cursor.lastrowid is not None # INSERT always returns a row ID
|
||||
return (cursor.lastrowid, True)
|
||||
except sqlite3.IntegrityError:
|
||||
# Race condition: another insert with same payload_hash happened between
|
||||
# our SELECT and INSERT. This is expected for duplicate packets arriving
|
||||
# close together. Query again to get the existing ID.
|
||||
logger.debug(
|
||||
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
|
||||
payload_hash[:16],
|
||||
)
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
return (existing["id"], False)
|
||||
# This shouldn't happen, but if it does, re-raise
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
"""Get count of undecrypted packets (those without a linked message)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as count FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_oldest_undecrypted() -> int | None:
|
||||
"""Get timestamp of oldest undecrypted packet, or None if none exist."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(timestamp) as oldest FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["oldest"] if row and row["oldest"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
"""Link a raw packet to its decrypted message."""
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET message_id = ? WHERE id = ?",
|
||||
(message_id, packet_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
cutoff = int(time.time()) - (max_age_days * 86400)
|
||||
cursor = await db.conn.execute(
|
||||
"DELETE FROM raw_packets WHERE message_id IS NULL AND timestamp < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
||||
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Filter for TEXT_MESSAGE packets
|
||||
result = []
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
result.append((row["id"], data, row["timestamp"]))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
"""Repository for app_settings table (single-row pattern)."""
|
||||
|
||||
@staticmethod
|
||||
async def get() -> AppSettings:
|
||||
"""Get the current app settings.
|
||||
|
||||
Always returns settings - creates default row if needed (migration handles initial row).
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, bots
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
# Should not happen after migration, but handle gracefully
|
||||
return AppSettings()
|
||||
|
||||
# Parse favorites JSON
|
||||
favorites = []
|
||||
if row["favorites"]:
|
||||
try:
|
||||
favorites_data = json.loads(row["favorites"])
|
||||
favorites = [Favorite(**f) for f in favorites_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["favorites"][:100] if row["favorites"] else None,
|
||||
)
|
||||
favorites = []
|
||||
|
||||
# Parse last_message_times JSON
|
||||
last_message_times: dict[str, int] = {}
|
||||
if row["last_message_times"]:
|
||||
try:
|
||||
last_message_times = json.loads(row["last_message_times"])
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse last_message_times JSON, using empty dict: %s",
|
||||
e,
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse bots JSON
|
||||
bots: list[BotConfig] = []
|
||||
if row["bots"]:
|
||||
try:
|
||||
bots_data = json.loads(row["bots"])
|
||||
bots = [BotConfig(**b) for b in bots_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse bots JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["bots"][:100] if row["bots"] else None,
|
||||
)
|
||||
bots = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
sort_order = "recent"
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
sidebar_sort_order=sort_order,
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
bots=bots,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
sidebar_sort_order: str | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
bots: list[BotConfig] | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
if max_radio_contacts is not None:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if favorites is not None:
|
||||
updates.append("favorites = ?")
|
||||
favorites_json = json.dumps([f.model_dump() for f in favorites])
|
||||
params.append(favorites_json)
|
||||
|
||||
if auto_decrypt_dm_on_advert is not None:
|
||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||
|
||||
if sidebar_sort_order is not None:
|
||||
updates.append("sidebar_sort_order = ?")
|
||||
params.append(sidebar_sort_order)
|
||||
|
||||
if last_message_times is not None:
|
||||
updates.append("last_message_times = ?")
|
||||
params.append(json.dumps(last_message_times))
|
||||
|
||||
if preferences_migrated is not None:
|
||||
updates.append("preferences_migrated = ?")
|
||||
params.append(1 if preferences_migrated else 0)
|
||||
|
||||
if advert_interval is not None:
|
||||
updates.append("advert_interval = ?")
|
||||
params.append(advert_interval)
|
||||
|
||||
if last_advert_time is not None:
|
||||
updates.append("last_advert_time = ?")
|
||||
params.append(last_advert_time)
|
||||
|
||||
if bots is not None:
|
||||
updates.append("bots = ?")
|
||||
bots_json = json.dumps([b.model_dump() for b in bots])
|
||||
params.append(bots_json)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
await db.conn.commit()
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
# Check if already favorited
|
||||
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
|
||||
return settings
|
||||
|
||||
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
new_favorites = [
|
||||
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
sort_order: str,
|
||||
last_message_times: dict[str, int],
|
||||
) -> tuple[AppSettings, bool]:
|
||||
"""Migrate all preferences from frontend localStorage.
|
||||
|
||||
This is a one-time migration. If already migrated, returns current settings
|
||||
without overwriting. Returns (settings, did_migrate) tuple.
|
||||
"""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
if settings.preferences_migrated:
|
||||
# Already migrated, don't overwrite
|
||||
return settings, False
|
||||
|
||||
# Convert frontend favorites format to Favorite objects
|
||||
new_favorites = []
|
||||
for f in favorites:
|
||||
if f.get("type") in ("channel", "contact") and f.get("id"):
|
||||
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
|
||||
|
||||
# Update with migrated preferences and mark as migrated
|
||||
settings = await AppSettingsRepository.update(
|
||||
favorites=new_favorites,
|
||||
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=True,
|
||||
)
|
||||
|
||||
return settings, True
|
||||
@@ -1,22 +0,0 @@
|
||||
from app.repository.channels import ChannelRepository
|
||||
from app.repository.contacts import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
)
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
|
||||
__all__ = [
|
||||
"AmbiguousPublicKeyPrefixError",
|
||||
"AppSettingsRepository",
|
||||
"ChannelRepository",
|
||||
"ContactAdvertPathRepository",
|
||||
"ContactNameHistoryRepository",
|
||||
"ContactRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"StatisticsRepository",
|
||||
]
|
||||
@@ -1,86 +0,0 @@
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
from app.models import Channel
|
||||
|
||||
|
||||
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, last_read_at 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"]),
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio, last_read_at 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"]),
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@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()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a channel.
|
||||
|
||||
Returns True if a row was updated, False if channel not found.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET last_read_at = ? WHERE key = ?",
|
||||
(ts, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE channels SET last_read_at = ?", (timestamp,))
|
||||
await db.conn.commit()
|
||||
@@ -1,412 +0,0 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
)
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
"""Raised when a public key prefix matches multiple contacts."""
|
||||
|
||||
def __init__(self, prefix: str, matches: list[str]):
|
||||
self.prefix = prefix.lower()
|
||||
self.matches = matches
|
||||
super().__init__(f"Ambiguous public key prefix '{self.prefix}'")
|
||||
|
||||
|
||||
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,
|
||||
first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
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 = COALESCE(excluded.on_radio, contacts.on_radio),
|
||||
last_contacted = COALESCE(excluded.last_contacted, contacts.last_contacted),
|
||||
first_seen = COALESCE(contacts.first_seen, excluded.first_seen)
|
||||
""",
|
||||
(
|
||||
contact.get("public_key", "").lower(),
|
||||
contact.get("name"),
|
||||
contact.get("type", 0),
|
||||
contact.get("flags", 0),
|
||||
contact.get("last_path"),
|
||||
contact.get("last_path_len", -1),
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat"),
|
||||
contact.get("lon"),
|
||||
contact.get("last_seen", int(time.time())),
|
||||
contact.get("on_radio"),
|
||||
contact.get("last_contacted"),
|
||||
contact.get("first_seen"),
|
||||
),
|
||||
)
|
||||
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"],
|
||||
last_read_at=row["last_read_at"],
|
||||
first_seen=row["first_seen"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(public_key: str) -> Contact | None:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key = ?", (public_key.lower(),)
|
||||
)
|
||||
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:
|
||||
"""Get a contact by key prefix only if it resolves uniquely.
|
||||
|
||||
Returns None when no contacts match OR when multiple contacts match
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
return ContactRepository._row_to_contact(rows[0])
|
||||
|
||||
@staticmethod
|
||||
async def _get_prefix_matches(prefix: str, limit: int = 2) -> list[Contact]:
|
||||
"""Get contacts matching a key prefix, up to limit."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT ?",
|
||||
(f"{prefix.lower()}%", limit),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@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 contact:
|
||||
return contact
|
||||
|
||||
matches = await ContactRepository._get_prefix_matches(key_or_prefix, limit=2)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise AmbiguousPublicKeyPrefixError(
|
||||
key_or_prefix,
|
||||
[m.public_key for m in matches],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_by_name(name: str) -> list[Contact]:
|
||||
"""Get all contacts with the given exact name."""
|
||||
cursor = await db.conn.execute("SELECT * FROM contacts WHERE name = ?", (name,))
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def resolve_prefixes(prefixes: list[str]) -> dict[str, Contact]:
|
||||
"""Resolve multiple key prefixes to contacts in a single query.
|
||||
|
||||
Returns a dict mapping each prefix to its Contact, only for prefixes
|
||||
that resolve uniquely (exactly one match). Ambiguous or unmatched
|
||||
prefixes are omitted.
|
||||
"""
|
||||
if not prefixes:
|
||||
return {}
|
||||
normalized = [p.lower() for p in prefixes]
|
||||
conditions = " OR ".join(["public_key LIKE ?"] * len(normalized))
|
||||
params = [f"{p}%" for p in normalized]
|
||||
cursor = await db.conn.execute(f"SELECT * FROM contacts WHERE {conditions}", params)
|
||||
rows = await cursor.fetchall()
|
||||
# Group by which prefix each row matches
|
||||
prefix_to_rows: dict[str, list] = {p: [] for p in normalized}
|
||||
for row in rows:
|
||||
pk = row["public_key"]
|
||||
for p in normalized:
|
||||
if pk.startswith(p):
|
||||
prefix_to_rows[p].append(row)
|
||||
# Only include uniquely-resolved prefixes
|
||||
result: dict[str, Contact] = {}
|
||||
for p in normalized:
|
||||
if len(prefix_to_rows[p]) == 1:
|
||||
result[p] = ContactRepository._row_to_contact(prefix_to_rows[p][0])
|
||||
return result
|
||||
|
||||
@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.lower()),
|
||||
)
|
||||
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.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
normalized = public_key.lower()
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||
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 if timestamp is not None else int(time.time())
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET last_contacted = ?, last_seen = ? WHERE public_key = ?",
|
||||
(ts, ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def update_last_read_at(public_key: str, timestamp: int | None = None) -> bool:
|
||||
"""Update the last_read_at timestamp for a contact.
|
||||
|
||||
Returns True if a row was updated, False if contact not found.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE contacts SET last_read_at = ? WHERE public_key = ?",
|
||||
(ts, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
await db.conn.execute("UPDATE contacts SET last_read_at = ?", (timestamp,))
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_pubkey_first_byte(hex_byte: str) -> list[Contact]:
|
||||
"""Get contacts whose public key starts with the given hex byte (2 chars)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE substr(public_key, 1, 2) = ?",
|
||||
(hex_byte.lower(),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
|
||||
class ContactAdvertPathRepository:
|
||||
"""Repository for recent unique advertisement paths per contact."""
|
||||
|
||||
@staticmethod
|
||||
def _row_to_path(row) -> ContactAdvertPath:
|
||||
path = row["path_hex"] or ""
|
||||
next_hop = path[:2].lower() if len(path) >= 2 else None
|
||||
return ContactAdvertPath(
|
||||
path=path,
|
||||
path_len=row["path_len"],
|
||||
next_hop=next_hop,
|
||||
first_seen=row["first_seen"],
|
||||
last_seen=row["last_seen"],
|
||||
heard_count=row["heard_count"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def record_observation(
|
||||
public_key: str,
|
||||
path_hex: str,
|
||||
timestamp: int,
|
||||
max_paths: int = 10,
|
||||
) -> None:
|
||||
"""
|
||||
Upsert a unique advert path observation for a contact and prune to N most recent.
|
||||
"""
|
||||
if max_paths < 1:
|
||||
max_paths = 1
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
normalized_path = path_hex.lower()
|
||||
path_len = len(normalized_path) // 2
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(public_key, path_hex) DO UPDATE SET
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
path_len = excluded.path_len,
|
||||
heard_count = contact_advert_paths.heard_count + 1
|
||||
""",
|
||||
(normalized_key, normalized_path, path_len, timestamp, timestamp),
|
||||
)
|
||||
|
||||
# Keep only the N most recent unique paths per contact.
|
||||
await db.conn.execute(
|
||||
"""
|
||||
DELETE FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
AND path_hex NOT IN (
|
||||
SELECT path_hex
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(normalized_key, normalized_key, max_paths),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_for_contact(public_key: str, limit: int = 10) -> list[ContactAdvertPath]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(public_key.lower(), limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactAdvertPathRepository._row_to_path(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_for_all_contacts(
|
||||
limit_per_contact: int = 10,
|
||||
) -> list[ContactAdvertPathSummary]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
grouped: dict[str, list[ContactAdvertPath]] = {}
|
||||
for row in rows:
|
||||
key = row["public_key"]
|
||||
paths = grouped.get(key)
|
||||
if paths is None:
|
||||
paths = []
|
||||
grouped[key] = paths
|
||||
if len(paths) >= limit_per_contact:
|
||||
continue
|
||||
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
||||
|
||||
return [
|
||||
ContactAdvertPathSummary(public_key=key, paths=paths) for key, paths in grouped.items()
|
||||
]
|
||||
|
||||
|
||||
class ContactNameHistoryRepository:
|
||||
"""Repository for contact name change history."""
|
||||
|
||||
@staticmethod
|
||||
async def record_name(public_key: str, name: str, timestamp: int) -> None:
|
||||
"""Record a name observation. Upserts: updates last_seen if name already known."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||
""",
|
||||
(public_key.lower(), name, timestamp, timestamp),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str) -> list[ContactNameHistory]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT name, first_seen, last_seen
|
||||
FROM contact_name_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC
|
||||
""",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
ContactNameHistory(
|
||||
name=row["name"], first_seen=row["first_seen"], last_seen=row["last_seen"]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -1,411 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import Message, MessagePath
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@staticmethod
|
||||
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
|
||||
"""Parse paths JSON string to list of MessagePath objects."""
|
||||
if not paths_json:
|
||||
return None
|
||||
try:
|
||||
paths_data = json.loads(paths_json)
|
||||
return [MessagePath(**p) for p in paths_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
msg_type: str,
|
||||
text: str,
|
||||
received_at: int,
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
sender_name: str | None = None,
|
||||
sender_key: str | None = None,
|
||||
) -> 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.
|
||||
|
||||
The path parameter is converted to the paths JSON array format.
|
||||
"""
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
paths_json = json.dumps([{"path": path, "received_at": received_at}])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
received_at, paths, txt_type, signature, outgoing,
|
||||
sender_name, sender_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
msg_type,
|
||||
conversation_key,
|
||||
text,
|
||||
sender_timestamp,
|
||||
received_at,
|
||||
paths_json,
|
||||
txt_type,
|
||||
signature,
|
||||
outgoing,
|
||||
sender_name,
|
||||
sender_key,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
# rowcount is 0 if INSERT was ignored due to UNIQUE constraint violation
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
return cursor.lastrowid
|
||||
|
||||
@staticmethod
|
||||
async def add_path(
|
||||
message_id: int, path: str, received_at: int | None = None
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
This is used when a repeat/echo of a message arrives via a different route.
|
||||
Returns the updated list of paths.
|
||||
"""
|
||||
ts = received_at if received_at is not None else int(time.time())
|
||||
|
||||
# Atomic append: use json_insert to avoid read-modify-write race when
|
||||
# multiple duplicate packets arrive concurrently for the same message.
|
||||
new_entry = json.dumps({"path": path, "received_at": ts})
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
COALESCE(paths, '[]'), '$[#]', json(?)
|
||||
) WHERE id = ?""",
|
||||
(new_entry, message_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
# Read back the full list for the return value
|
||||
cursor = await db.conn.execute("SELECT paths FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row["paths"]:
|
||||
return []
|
||||
|
||||
try:
|
||||
all_paths = json.loads(row["paths"])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
return [MessagePath(**p) for p in all_paths]
|
||||
|
||||
@staticmethod
|
||||
async def claim_prefix_messages(full_key: str) -> int:
|
||||
"""Promote prefix-stored messages to the full conversation key.
|
||||
|
||||
When a full key becomes known for a contact, any messages stored with
|
||||
only a prefix as conversation_key are updated to use the full key.
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
before: int | None = None,
|
||||
before_id: int | 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:
|
||||
normalized_key = conversation_key
|
||||
# Prefer exact matching for full keys.
|
||||
if len(conversation_key) == 64:
|
||||
normalized_key = conversation_key.lower()
|
||||
query += " AND conversation_key = ?"
|
||||
params.append(normalized_key)
|
||||
elif len(conversation_key) == 32:
|
||||
normalized_key = conversation_key.upper()
|
||||
query += " AND conversation_key = ?"
|
||||
params.append(normalized_key)
|
||||
else:
|
||||
# Prefix match is only for legacy/partial key callers.
|
||||
query += " AND conversation_key LIKE ?"
|
||||
params.append(f"{conversation_key}%")
|
||||
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
params.append(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"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
"""Increment ack count and return the new value."""
|
||||
await db.conn.execute("UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row["acked"] if row else 1
|
||||
|
||||
@staticmethod
|
||||
async def get_ack_and_paths(message_id: int) -> tuple[int, list[MessagePath] | None]:
|
||||
"""Get the current ack count and paths for a message."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT acked, paths FROM messages WHERE id = ?", (message_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return 0, None
|
||||
return row["acked"], MessageRepository._parse_paths(row["paths"])
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE id = ?
|
||||
""",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
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"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
""",
|
||||
(msg_type, conversation_key, text, sender_timestamp, sender_timestamp),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
paths = None
|
||||
if row["paths"]:
|
||||
try:
|
||||
paths_data = json.loads(row["paths"])
|
||||
paths = [
|
||||
MessagePath(path=p["path"], received_at=p["received_at"]) for p in paths_data
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
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"],
|
||||
paths=paths,
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_counts(name: str | None = None) -> dict:
|
||||
"""Get unread message counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Args:
|
||||
name: User's display name for @[name] mention detection. If None, mentions are skipped.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
mention_flags: dict[str, bool] = {}
|
||||
last_message_times: dict[str, int] = {}
|
||||
|
||||
mention_token = f"@[{name}]" if name else None
|
||||
|
||||
# Channel unreads
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
SUM(CASE
|
||||
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or ""),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"channel-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Contact unreads
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
SUM(CASE
|
||||
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or ""),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
state_key = f"contact-{row['conversation_key']}"
|
||||
counts[state_key] = row["unread_count"]
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
prefix = "channel" if row["type"] == "CHAN" else "contact"
|
||||
state_key = f"{prefix}-{row['conversation_key']}"
|
||||
last_message_times[state_key] = row["last_message_time"]
|
||||
|
||||
return {
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
"last_message_times": last_message_times,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def count_dm_messages(contact_key: str) -> int:
|
||||
"""Count total DM messages for a contact."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'PRIV' AND conversation_key = ?",
|
||||
(contact_key.lower(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def count_channel_messages_by_sender(sender_key: str) -> int:
|
||||
"""Count channel messages sent by a specific contact."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_key = ?",
|
||||
(sender_key.lower(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a contact has sent the most messages.
|
||||
|
||||
Returns list of (channel_key, channel_name, message_count) tuples.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
|
||||
COUNT(*) AS cnt
|
||||
FROM messages m
|
||||
LEFT JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.sender_key = ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY cnt DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(sender_key.lower(), limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
|
||||
@@ -1,150 +0,0 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from hashlib import sha256
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
async def create(data: bytes, timestamp: int | None = None) -> tuple[int, bool]:
|
||||
"""
|
||||
Create a raw packet with payload-based deduplication.
|
||||
|
||||
Returns (packet_id, is_new) tuple:
|
||||
- is_new=True: New packet stored, packet_id is the new row ID
|
||||
- is_new=False: Duplicate payload detected, packet_id is the existing row ID
|
||||
|
||||
Deduplication is based on the SHA-256 hash of the packet payload
|
||||
(excluding routing/path information).
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else int(time.time())
|
||||
|
||||
# Compute payload hash for deduplication
|
||||
payload = extract_payload(data)
|
||||
if payload:
|
||||
payload_hash = sha256(payload).digest()
|
||||
else:
|
||||
# For malformed packets, hash the full data
|
||||
payload_hash = sha256(data).digest()
|
||||
|
||||
# Check if this payload already exists
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Duplicate - return existing packet ID
|
||||
logger.debug(
|
||||
"Duplicate payload detected (hash=%s..., existing_id=%d)",
|
||||
payload_hash.hex()[:12],
|
||||
existing["id"],
|
||||
)
|
||||
return (existing["id"], False)
|
||||
|
||||
# New packet - insert with hash
|
||||
try:
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(ts, data, payload_hash),
|
||||
)
|
||||
await db.conn.commit()
|
||||
assert cursor.lastrowid is not None # INSERT always returns a row ID
|
||||
return (cursor.lastrowid, True)
|
||||
except sqlite3.IntegrityError:
|
||||
# Race condition: another insert with same payload_hash happened between
|
||||
# our SELECT and INSERT. This is expected for duplicate packets arriving
|
||||
# close together. Query again to get the existing ID.
|
||||
logger.debug(
|
||||
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
|
||||
payload_hash.hex()[:16],
|
||||
)
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
return (existing["id"], False)
|
||||
# This shouldn't happen, but if it does, re-raise
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
"""Get count of undecrypted packets (those without a linked message)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as count FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_oldest_undecrypted() -> int | None:
|
||||
"""Get timestamp of oldest undecrypted packet, or None if none exist."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(timestamp) as oldest FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["oldest"] if row and row["oldest"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
"""Link a raw packet to its decrypted message."""
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET message_id = ? WHERE id = ?",
|
||||
(message_id, packet_id),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
cutoff = int(time.time()) - (max_age_days * 86400)
|
||||
cursor = await db.conn.execute(
|
||||
"DELETE FROM raw_packets WHERE message_id IS NULL AND timestamp < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def purge_linked_to_messages() -> int:
|
||||
"""Delete raw packets that are already linked to a stored message."""
|
||||
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
||||
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Filter for TEXT_MESSAGE packets
|
||||
result = []
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
result.append((row["id"], data, row["timestamp"]))
|
||||
|
||||
return result
|
||||
@@ -1,387 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, BotConfig, Favorite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_7D = 604800
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
"""Repository for app_settings table (single-row pattern)."""
|
||||
|
||||
@staticmethod
|
||||
async def get() -> AppSettings:
|
||||
"""Get the current app settings.
|
||||
|
||||
Always returns settings - creates default row if needed (migration handles initial row).
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, bots,
|
||||
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
|
||||
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
# Should not happen after migration, but handle gracefully
|
||||
return AppSettings()
|
||||
|
||||
# Parse favorites JSON
|
||||
favorites = []
|
||||
if row["favorites"]:
|
||||
try:
|
||||
favorites_data = json.loads(row["favorites"])
|
||||
favorites = [Favorite(**f) for f in favorites_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["favorites"][:100] if row["favorites"] else None,
|
||||
)
|
||||
favorites = []
|
||||
|
||||
# Parse last_message_times JSON
|
||||
last_message_times: dict[str, int] = {}
|
||||
if row["last_message_times"]:
|
||||
try:
|
||||
last_message_times = json.loads(row["last_message_times"])
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse last_message_times JSON, using empty dict: %s",
|
||||
e,
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse bots JSON
|
||||
bots: list[BotConfig] = []
|
||||
if row["bots"]:
|
||||
try:
|
||||
bots_data = json.loads(row["bots"])
|
||||
bots = [BotConfig(**b) for b in bots_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse bots JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["bots"][:100] if row["bots"] else None,
|
||||
)
|
||||
bots = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
sort_order = "recent"
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
sidebar_sort_order=sort_order,
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
bots=bots,
|
||||
mqtt_broker_host=row["mqtt_broker_host"] or "",
|
||||
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
|
||||
mqtt_username=row["mqtt_username"] or "",
|
||||
mqtt_password=row["mqtt_password"] or "",
|
||||
mqtt_use_tls=bool(row["mqtt_use_tls"]),
|
||||
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
|
||||
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
|
||||
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
|
||||
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
sidebar_sort_order: str | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
bots: list[BotConfig] | None = None,
|
||||
mqtt_broker_host: str | None = None,
|
||||
mqtt_broker_port: int | None = None,
|
||||
mqtt_username: str | None = None,
|
||||
mqtt_password: str | None = None,
|
||||
mqtt_use_tls: bool | None = None,
|
||||
mqtt_tls_insecure: bool | None = None,
|
||||
mqtt_topic_prefix: str | None = None,
|
||||
mqtt_publish_messages: bool | None = None,
|
||||
mqtt_publish_raw_packets: bool | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
if max_radio_contacts is not None:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if favorites is not None:
|
||||
updates.append("favorites = ?")
|
||||
favorites_json = json.dumps([f.model_dump() for f in favorites])
|
||||
params.append(favorites_json)
|
||||
|
||||
if auto_decrypt_dm_on_advert is not None:
|
||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||
|
||||
if sidebar_sort_order is not None:
|
||||
updates.append("sidebar_sort_order = ?")
|
||||
params.append(sidebar_sort_order)
|
||||
|
||||
if last_message_times is not None:
|
||||
updates.append("last_message_times = ?")
|
||||
params.append(json.dumps(last_message_times))
|
||||
|
||||
if preferences_migrated is not None:
|
||||
updates.append("preferences_migrated = ?")
|
||||
params.append(1 if preferences_migrated else 0)
|
||||
|
||||
if advert_interval is not None:
|
||||
updates.append("advert_interval = ?")
|
||||
params.append(advert_interval)
|
||||
|
||||
if last_advert_time is not None:
|
||||
updates.append("last_advert_time = ?")
|
||||
params.append(last_advert_time)
|
||||
|
||||
if bots is not None:
|
||||
updates.append("bots = ?")
|
||||
bots_json = json.dumps([b.model_dump() for b in bots])
|
||||
params.append(bots_json)
|
||||
|
||||
if mqtt_broker_host is not None:
|
||||
updates.append("mqtt_broker_host = ?")
|
||||
params.append(mqtt_broker_host)
|
||||
|
||||
if mqtt_broker_port is not None:
|
||||
updates.append("mqtt_broker_port = ?")
|
||||
params.append(mqtt_broker_port)
|
||||
|
||||
if mqtt_username is not None:
|
||||
updates.append("mqtt_username = ?")
|
||||
params.append(mqtt_username)
|
||||
|
||||
if mqtt_password is not None:
|
||||
updates.append("mqtt_password = ?")
|
||||
params.append(mqtt_password)
|
||||
|
||||
if mqtt_use_tls is not None:
|
||||
updates.append("mqtt_use_tls = ?")
|
||||
params.append(1 if mqtt_use_tls else 0)
|
||||
|
||||
if mqtt_tls_insecure is not None:
|
||||
updates.append("mqtt_tls_insecure = ?")
|
||||
params.append(1 if mqtt_tls_insecure else 0)
|
||||
|
||||
if mqtt_topic_prefix is not None:
|
||||
updates.append("mqtt_topic_prefix = ?")
|
||||
params.append(mqtt_topic_prefix)
|
||||
|
||||
if mqtt_publish_messages is not None:
|
||||
updates.append("mqtt_publish_messages = ?")
|
||||
params.append(1 if mqtt_publish_messages else 0)
|
||||
|
||||
if mqtt_publish_raw_packets is not None:
|
||||
updates.append("mqtt_publish_raw_packets = ?")
|
||||
params.append(1 if mqtt_publish_raw_packets else 0)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
await db.conn.commit()
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
# Check if already favorited
|
||||
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
|
||||
return settings
|
||||
|
||||
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
new_favorites = [
|
||||
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
sort_order: str,
|
||||
last_message_times: dict[str, int],
|
||||
) -> tuple[AppSettings, bool]:
|
||||
"""Migrate all preferences from frontend localStorage.
|
||||
|
||||
This is a one-time migration. If already migrated, returns current settings
|
||||
without overwriting. Returns (settings, did_migrate) tuple.
|
||||
"""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
if settings.preferences_migrated:
|
||||
# Already migrated, don't overwrite
|
||||
return settings, False
|
||||
|
||||
# Convert frontend favorites format to Favorite objects
|
||||
new_favorites = []
|
||||
for f in favorites:
|
||||
if f.get("type") in ("channel", "contact") and f.get("id"):
|
||||
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
|
||||
|
||||
# Update with migrated preferences and mark as migrated
|
||||
settings = await AppSettingsRepository.update(
|
||||
favorites=new_favorites,
|
||||
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=True,
|
||||
)
|
||||
|
||||
return settings, True
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||
now = int(time.time())
|
||||
op = "!=" if exclude else "="
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||
SUM(CASE WHEN last_seen >= ? THEN 1 ELSE 0 END) AS last_week
|
||||
FROM contacts
|
||||
WHERE type {op} ? AND last_seen IS NOT NULL
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D, contact_type),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None # Aggregate query always returns a row
|
||||
return {
|
||||
"last_hour": row["last_hour"] or 0,
|
||||
"last_24_hours": row["last_24_hours"] or 0,
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Aggregate all statistics from existing tables."""
|
||||
now = int(time.time())
|
||||
|
||||
# Top 5 busiest channels in last 24h
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
|
||||
COUNT(*) AS message_count
|
||||
FROM messages m
|
||||
LEFT JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.received_at >= ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
busiest_channels_24h = [
|
||||
{
|
||||
"channel_key": row["conversation_key"],
|
||||
"channel_name": row["channel_name"],
|
||||
"message_count": row["message_count"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Entity counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type != 2")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
contact_count: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM contacts WHERE type = 2")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
repeater_count: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM channels")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
channel_count: int = row["cnt"]
|
||||
|
||||
# Packet split
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN message_id IS NOT NULL THEN 1 ELSE 0 END) AS decrypted
|
||||
FROM raw_packets
|
||||
"""
|
||||
)
|
||||
pkt_row = await cursor.fetchone()
|
||||
assert pkt_row is not None
|
||||
total_packets = pkt_row["total"] or 0
|
||||
decrypted_packets = pkt_row["decrypted"] or 0
|
||||
undecrypted_packets = total_packets - decrypted_packets
|
||||
|
||||
# Message type counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_dms: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_channel_messages: int = row["cnt"]
|
||||
|
||||
# Outgoing count
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_outgoing: int = row["cnt"]
|
||||
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
"contact_count": contact_count,
|
||||
"repeater_count": repeater_count,
|
||||
"channel_count": channel_count,
|
||||
"total_packets": total_packets,
|
||||
"decrypted_packets": decrypted_packets,
|
||||
"undecrypted_packets": undecrypted_packets,
|
||||
"total_dms": total_dms,
|
||||
"total_channel_messages": total_channel_messages,
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
}
|
||||
@@ -7,8 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.radio_sync import ensure_default_channels
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +25,8 @@ class CreateChannelRequest(BaseModel):
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
# Ensure Public channel always exists (self-healing)
|
||||
await ensure_default_channels()
|
||||
return await ChannelRepository.get_all()
|
||||
|
||||
|
||||
@@ -83,22 +84,35 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
@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."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
|
||||
count = 0
|
||||
|
||||
async with radio_manager.radio_operation("sync_channels_from_radio") as mc:
|
||||
for idx in range(max_channels):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
for idx in range(max_channels):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
|
||||
if key_hex is not None:
|
||||
count += 1
|
||||
logger.debug(
|
||||
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
|
||||
)
|
||||
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}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
|
||||
@@ -6,57 +7,62 @@ from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
CommandRequest,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
CreateContactRequest,
|
||||
NearestRepeater,
|
||||
NeighborInfo,
|
||||
TelemetryRequest,
|
||||
TelemetryResponse,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.radio_sync import pause_polling
|
||||
from app.repository import ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ACL permission level names
|
||||
ACL_PERMISSION_NAMES = {
|
||||
0: "Guest",
|
||||
1: "Read-only",
|
||||
2: "Read-write",
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||
return (
|
||||
f"Ambiguous contact key prefix '{err.prefix}'. "
|
||||
f"Use a full 64-character public key. Matching contacts: {sample}"
|
||||
)
|
||||
# Delay between repeater radio operations to allow key exchange and path establishment
|
||||
REPEATER_OP_DELAY_SECONDS = 2.0
|
||||
|
||||
|
||||
async def _resolve_contact_or_404(
|
||||
public_key: str, not_found_detail: str = "Contact not found"
|
||||
) -> Contact:
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
except AmbiguousPublicKeyPrefixError as err:
|
||||
raise HTTPException(status_code=409, detail=_ambiguous_contact_detail(err)) from err
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail=not_found_detail)
|
||||
return contact
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
|
||||
"""Prepare connection to a repeater by adding to radio and logging in.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
contact: The repeater contact
|
||||
password: Password for login (empty string for no password)
|
||||
|
||||
async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
"""Add a contact to the radio for routing, raising 500 on failure."""
|
||||
add_result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if add_result is not None and add_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||
)
|
||||
Raises:
|
||||
HTTPException: If login fails
|
||||
"""
|
||||
# Add contact to radio with path from DB
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
# Send login with password
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=401, detail=f"Login failed: {login_result.payload}")
|
||||
|
||||
# Wait for key exchange to complete before sending requests
|
||||
logger.debug("Waiting %.1fs for key exchange to complete", REPEATER_OP_DELAY_SECONDS)
|
||||
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
@@ -68,20 +74,6 @@ async def list_contacts(
|
||||
return await ContactRepository.get_all(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/repeaters/advert-paths", response_model=list[ContactAdvertPathSummary])
|
||||
async def list_repeater_advert_paths(
|
||||
limit_per_repeater: int = Query(default=10, ge=1, le=50),
|
||||
) -> list[ContactAdvertPathSummary]:
|
||||
"""List recent unique advert paths for all repeaters.
|
||||
|
||||
Note: This endpoint now returns paths for all contacts (table was renamed).
|
||||
The route is kept for backward compatibility.
|
||||
"""
|
||||
return await ContactAdvertPathRepository.get_recent_for_all_contacts(
|
||||
limit_per_contact=limit_per_repeater
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=Contact)
|
||||
async def create_contact(
|
||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
||||
@@ -98,7 +90,7 @@ async def create_contact(
|
||||
raise HTTPException(status_code=400, detail="Invalid public key: must be valid hex") from e
|
||||
|
||||
# Check if contact already exists
|
||||
existing = await ContactRepository.get_by_key(request.public_key)
|
||||
existing = await ContactRepository.get_by_key_or_prefix(request.public_key)
|
||||
if existing:
|
||||
# Update name if provided
|
||||
if request.name:
|
||||
@@ -118,9 +110,7 @@ async def create_contact(
|
||||
"last_contacted": existing.last_contacted,
|
||||
}
|
||||
)
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
existing.name = request.name
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
@@ -161,109 +151,23 @@ async def create_contact(
|
||||
return Contact(**contact_data)
|
||||
|
||||
|
||||
@router.get("/{public_key}/detail", response_model=ContactDetail)
|
||||
async def get_contact_detail(public_key: str) -> ContactDetail:
|
||||
"""Get comprehensive contact profile data.
|
||||
|
||||
Returns contact info, name history, message counts, most active rooms,
|
||||
advertisement paths, advert frequency, and nearest repeaters.
|
||||
"""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
|
||||
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
|
||||
for key, name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
# Compute advert observation rate (observations/hour) from path data.
|
||||
# Note: a single advertisement can arrive via multiple paths, so this counts
|
||||
# RF observations, not unique advertisement broadcasts.
|
||||
advert_frequency: float | None = None
|
||||
if advert_paths:
|
||||
total_observations = sum(p.heard_count for p in advert_paths)
|
||||
earliest = min(p.first_seen for p in advert_paths)
|
||||
latest = max(p.last_seen for p in advert_paths)
|
||||
span_hours = (latest - earliest) / 3600.0
|
||||
if span_hours > 0:
|
||||
advert_frequency = round(total_observations / span_hours, 2)
|
||||
|
||||
# Compute nearest repeaters from first-hop prefixes in advert paths
|
||||
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
|
||||
for p in advert_paths:
|
||||
if p.path and len(p.path) >= 2:
|
||||
prefix = p.path[:2].lower()
|
||||
if prefix not in first_hop_stats:
|
||||
first_hop_stats[prefix] = {
|
||||
"heard_count": 0,
|
||||
"path_len": p.path_len,
|
||||
"last_seen": p.last_seen,
|
||||
}
|
||||
first_hop_stats[prefix]["heard_count"] += p.heard_count
|
||||
first_hop_stats[prefix]["last_seen"] = max(
|
||||
first_hop_stats[prefix]["last_seen"], p.last_seen
|
||||
)
|
||||
|
||||
# Resolve all first-hop prefixes to contacts in a single query
|
||||
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
|
||||
|
||||
nearest_repeaters: list[NearestRepeater] = []
|
||||
for prefix, stats in first_hop_stats.items():
|
||||
resolved = resolved_contacts.get(prefix)
|
||||
nearest_repeaters.append(
|
||||
NearestRepeater(
|
||||
public_key=resolved.public_key if resolved else prefix,
|
||||
name=resolved.name if resolved else None,
|
||||
path_len=stats["path_len"],
|
||||
last_seen=stats["last_seen"],
|
||||
heard_count=stats["heard_count"],
|
||||
)
|
||||
)
|
||||
|
||||
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
|
||||
|
||||
return ContactDetail(
|
||||
contact=contact,
|
||||
name_history=name_history,
|
||||
dm_message_count=dm_count,
|
||||
channel_message_count=chan_count,
|
||||
most_active_rooms=most_active_rooms,
|
||||
advert_paths=advert_paths,
|
||||
advert_frequency=advert_frequency,
|
||||
nearest_repeaters=nearest_repeaters,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=Contact)
|
||||
async def get_contact(public_key: str) -> Contact:
|
||||
"""Get a specific contact by public key or prefix."""
|
||||
return await _resolve_contact_or_404(public_key)
|
||||
|
||||
|
||||
@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath])
|
||||
async def get_contact_advert_paths(
|
||||
public_key: str,
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
) -> list[ContactAdvertPath]:
|
||||
"""List recent unique advert paths for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit)
|
||||
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."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
logger.info("Syncing contacts from radio")
|
||||
|
||||
async with radio_manager.radio_operation("sync_contacts_from_radio") as mc:
|
||||
result = await mc.commands.get_contacts()
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get contacts: {result.payload}")
|
||||
@@ -272,13 +176,9 @@ async def sync_contacts_from_radio() -> dict:
|
||||
count = 0
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
lower_key = public_key.lower()
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=True)
|
||||
)
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
|
||||
count += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
@@ -288,26 +188,25 @@ async def sync_contacts_from_radio() -> dict:
|
||||
@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)."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
async with radio_manager.radio_operation("remove_contact_from_radio") as mc:
|
||||
# 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"}
|
||||
# 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])
|
||||
logger.info("Removing contact %s from radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.remove_contact(radio_contact)
|
||||
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}"
|
||||
)
|
||||
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"}
|
||||
@@ -316,22 +215,23 @@ async def remove_contact_from_radio(public_key: str) -> dict:
|
||||
@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."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found in database")
|
||||
|
||||
async with radio_manager.radio_operation("add_contact_to_radio") as mc:
|
||||
# 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"}
|
||||
# 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])
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
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}")
|
||||
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"}
|
||||
@@ -340,7 +240,9 @@ async def add_contact_to_radio(public_key: str) -> dict:
|
||||
@router.post("/{public_key}/mark-read")
|
||||
async def mark_contact_read(public_key: str) -> dict:
|
||||
"""Mark a contact conversation as read (update last_read_at timestamp)."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
updated = await ContactRepository.update_last_read_at(contact.public_key)
|
||||
if not updated:
|
||||
@@ -352,17 +254,17 @@ async def mark_contact_read(public_key: str) -> dict:
|
||||
@router.delete("/{public_key}")
|
||||
async def delete_contact(public_key: str) -> dict:
|
||||
"""Delete a contact from the database (and radio if present)."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
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:
|
||||
async with radio_manager.radio_operation("delete_contact_from_radio") as mc:
|
||||
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)
|
||||
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)
|
||||
@@ -371,6 +273,269 @@ async def delete_contact(public_key: str) -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{public_key}/telemetry", response_model=TelemetryResponse)
|
||||
async def request_telemetry(public_key: str, request: TelemetryRequest) -> TelemetryResponse:
|
||||
"""Request telemetry from a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). If not on the radio, it will be added.
|
||||
Uses login + status request with retry logic.
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
# Verify it's a repeater
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
|
||||
)
|
||||
|
||||
# Prepare connection (add/remove dance + login)
|
||||
await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
# Request status with retries
|
||||
logger.info("Requesting status from repeater %s", contact.public_key[:12])
|
||||
status = None
|
||||
for attempt in range(1, 4):
|
||||
logger.debug("Status request attempt %d/3", attempt)
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
if status:
|
||||
break
|
||||
logger.debug("Status request timeout, retrying...")
|
||||
|
||||
if not status:
|
||||
raise HTTPException(status_code=504, detail="No response from repeater after 3 attempts")
|
||||
|
||||
logger.info("Received telemetry from %s: %s", contact.public_key[:12], status)
|
||||
|
||||
# Fetch neighbors (fetch_all_neighbours handles pagination)
|
||||
logger.info("Fetching neighbors from repeater %s", contact.public_key[:12])
|
||||
neighbors_data = None
|
||||
for attempt in range(1, 4):
|
||||
logger.debug("Neighbors request attempt %d/3", attempt)
|
||||
neighbors_data = await mc.commands.fetch_all_neighbours(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
if neighbors_data:
|
||||
break
|
||||
logger.debug("Neighbors request timeout, retrying...")
|
||||
|
||||
# Process neighbors - resolve pubkey prefixes to contact names
|
||||
neighbors: list[NeighborInfo] = []
|
||||
if neighbors_data and "neighbours" in neighbors_data:
|
||||
logger.info("Received %d neighbors", len(neighbors_data["neighbours"]))
|
||||
for n in neighbors_data["neighbours"]:
|
||||
pubkey_prefix = n.get("pubkey", "")
|
||||
# Try to resolve to a contact name from our database
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
neighbors.append(
|
||||
NeighborInfo(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
snr=n.get("snr", 0.0),
|
||||
last_heard_seconds=n.get("secs_ago", 0),
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch ACL
|
||||
logger.info("Fetching ACL from repeater %s", contact.public_key[:12])
|
||||
acl_data = None
|
||||
for attempt in range(1, 4):
|
||||
logger.debug("ACL request attempt %d/3", attempt)
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
if acl_data:
|
||||
break
|
||||
logger.debug("ACL request timeout, retrying...")
|
||||
|
||||
# Process ACL - resolve pubkey prefixes to contact names
|
||||
acl_entries: list[AclEntry] = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
logger.info("Received %d ACL entries", len(acl_data))
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
# Try to resolve to a contact name from our database
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
acl_entries.append(
|
||||
AclEntry(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
permission=perm,
|
||||
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||
)
|
||||
)
|
||||
|
||||
# Fetch clock output (up to 2 attempts)
|
||||
# Must pause polling and stop auto-fetch to prevent race condition where
|
||||
# the CLI response is consumed before we can call get_msg()
|
||||
logger.info("Fetching clock from repeater %s", contact.public_key[:12])
|
||||
clock_output: str | None = None
|
||||
|
||||
async with pause_polling():
|
||||
await mc.stop_auto_message_fetching()
|
||||
try:
|
||||
for attempt in range(1, 3):
|
||||
logger.debug("Clock request attempt %d/2", attempt)
|
||||
try:
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, "clock")
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Clock command send error: %s", send_result.payload)
|
||||
continue
|
||||
|
||||
# Wait for response
|
||||
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=5.0)
|
||||
if wait_result is None:
|
||||
logger.debug("Clock request timeout, retrying...")
|
||||
continue
|
||||
|
||||
response_event = await mc.commands.get_msg()
|
||||
if response_event.type == EventType.ERROR:
|
||||
logger.debug("Clock get_msg error: %s", response_event.payload)
|
||||
continue
|
||||
|
||||
clock_output = response_event.payload.get("text", "")
|
||||
logger.info("Received clock output: %s", clock_output)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Clock request exception: %s", e)
|
||||
continue
|
||||
finally:
|
||||
await mc.start_auto_message_fetching()
|
||||
|
||||
if clock_output is None:
|
||||
clock_output = "Unable to fetch `clock` output (repeater did not respond)"
|
||||
|
||||
# Convert raw telemetry to response format
|
||||
# bat is in mV, convert to V (e.g., 3775 -> 3.775)
|
||||
|
||||
return TelemetryResponse(
|
||||
pubkey_prefix=status.get("pubkey_pre", contact.public_key[:12]),
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
last_rssi_dbm=status.get("last_rssi", 0),
|
||||
last_snr_db=status.get("last_snr", 0.0),
|
||||
packets_received=status.get("nb_recv", 0),
|
||||
packets_sent=status.get("nb_sent", 0),
|
||||
airtime_seconds=status.get("airtime", 0),
|
||||
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||
uptime_seconds=status.get("uptime", 0),
|
||||
sent_flood=status.get("sent_flood", 0),
|
||||
sent_direct=status.get("sent_direct", 0),
|
||||
recv_flood=status.get("recv_flood", 0),
|
||||
recv_direct=status.get("recv_direct", 0),
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
neighbors=neighbors,
|
||||
acl=acl_entries,
|
||||
clock_output=clock_output,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the telemetry endpoint. This endpoint ensures the contact is on the radio
|
||||
before sending commands (the repeater remembers ACL permissions after login).
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
# Verify it's a repeater
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
|
||||
)
|
||||
|
||||
# Pause message polling to prevent it from stealing our response
|
||||
async with pause_polling():
|
||||
# Stop auto-fetch to prevent race condition where it consumes our CLI response
|
||||
# before we can call get_msg(). This was causing every other command to fail
|
||||
# with {'messages_available': False}.
|
||||
await mc.stop_auto_message_fetching()
|
||||
|
||||
try:
|
||||
# Add contact to radio with path from DB
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
# Send the command
|
||||
logger.info(
|
||||
"Sending command to repeater %s: %s", contact.public_key[:12], request.command
|
||||
)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response (MESSAGES_WAITING event, then get_msg)
|
||||
try:
|
||||
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=10.0)
|
||||
|
||||
if wait_result is None:
|
||||
# Timeout - no response received
|
||||
logger.warning(
|
||||
"No response from repeater %s for command: %s",
|
||||
contact.public_key[:12],
|
||||
request.command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
response_event = await mc.commands.get_msg()
|
||||
|
||||
if response_event.type == EventType.ERROR:
|
||||
return CommandResponse(
|
||||
command=request.command, response=f"(error: {response_event.payload})"
|
||||
)
|
||||
|
||||
# Extract the response text and timestamp from the payload
|
||||
response_text = response_event.payload.get("text", str(response_event.payload))
|
||||
sender_timestamp = response_event.payload.get("timestamp")
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error waiting for response: %s", e)
|
||||
return CommandResponse(
|
||||
command=request.command, response=f"(error waiting for response: {e})"
|
||||
)
|
||||
finally:
|
||||
# Always restart auto-fetch, even if an error occurred
|
||||
await mc.start_auto_message_fetching()
|
||||
|
||||
|
||||
@router.post("/{public_key}/trace", response_model=TraceResponse)
|
||||
async def request_trace(public_key: str) -> TraceResponse:
|
||||
"""Send a single-hop trace to a contact and wait for the result.
|
||||
@@ -379,19 +544,22 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
(no intermediate repeaters). The radio firmware requires at least one
|
||||
node in the path.
|
||||
"""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
||||
contact_hash = contact.public_key[:2]
|
||||
|
||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||
# from the reader loop, not via get_msg().
|
||||
async with radio_manager.radio_operation("request_trace", pause_polling=True) as mc:
|
||||
# Note: unlike command/telemetry endpoints, trace does NOT need
|
||||
# stop/start_auto_message_fetching because the response arrives as a
|
||||
# TRACE_DATA event through the reader loop, not via get_msg().
|
||||
async with pause_polling():
|
||||
# Ensure contact is on radio so the trace can reach them
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
logger.info(
|
||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||
@@ -429,31 +597,3 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
)
|
||||
|
||||
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)
|
||||
|
||||
|
||||
@router.post("/{public_key}/reset-path")
|
||||
async def reset_contact_path(public_key: str) -> dict:
|
||||
"""Reset a contact's routing path to flood."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, "", -1)
|
||||
logger.info("Reset path to flood for %s", contact.public_key[:12])
|
||||
|
||||
# Push the updated path to radio if connected and contact is on radio
|
||||
if radio_manager.is_connected and contact.on_radio:
|
||||
try:
|
||||
updated = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated:
|
||||
async with radio_manager.radio_operation("reset_path_on_radio") as mc:
|
||||
await mc.commands.add_contact(updated.to_radio_dict())
|
||||
except Exception:
|
||||
logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12])
|
||||
|
||||
# Broadcast updated contact so frontend refreshes
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
updated_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated_contact:
|
||||
broadcast_event("contact", updated_contact.model_dump())
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
@@ -13,14 +13,12 @@ router = APIRouter(tags=["health"])
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
connection_info: str | None
|
||||
serial_port: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
mqtt_status: str | None = None
|
||||
loopback_eligible: bool = False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
async def build_health_data(radio_connected: bool, serial_port: str | None) -> dict:
|
||||
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
|
||||
db_size_mb = 0.0
|
||||
try:
|
||||
@@ -35,31 +33,17 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
# MQTT status
|
||||
mqtt_status: str | None = None
|
||||
try:
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
if mqtt_publisher._mqtt_configured():
|
||||
mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
|
||||
else:
|
||||
mqtt_status = "disabled"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"connection_info": connection_info,
|
||||
"serial_port": serial_port,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"mqtt_status": mqtt_status,
|
||||
"loopback_eligible": settings.loopback_eligible,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def healthcheck() -> HealthResponse:
|
||||
"""Check if the API is running and if the radio is connected."""
|
||||
data = await build_health_data(radio_manager.is_connected, radio_manager.connection_info)
|
||||
data = await build_health_data(radio_manager.is_connected, radio_manager.port)
|
||||
return HealthResponse(**data)
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""WebSocket endpoint for loopback transport (browser-bridged radio connection)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from app.config import settings
|
||||
from app.loopback import LoopbackTransport
|
||||
from app.radio import radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/transport")
|
||||
async def loopback_transport(websocket: WebSocket) -> None:
|
||||
"""Bridge a browser-side serial/BLE connection to the backend MeshCore stack.
|
||||
|
||||
Protocol:
|
||||
1. Client sends init JSON: {"type": "init", "mode": "serial"|"ble"}
|
||||
2. Binary frames flow bidirectionally (raw bytes for BLE, framed for serial)
|
||||
3. Either side can send {"type": "disconnect"} to tear down
|
||||
"""
|
||||
# Guard: reject if an explicit transport is configured via env vars
|
||||
if not settings.loopback_eligible:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4003, reason="Explicit transport configured")
|
||||
return
|
||||
|
||||
# Guard: reject if the radio is already connected (direct or another loopback)
|
||||
if radio_manager.is_connected:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4004, reason="Radio already connected")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
transport: LoopbackTransport | None = None
|
||||
setup_task: asyncio.Task | None = None
|
||||
|
||||
try:
|
||||
# Wait for init message
|
||||
init_raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
|
||||
init_msg = json.loads(init_raw)
|
||||
|
||||
if init_msg.get("type") != "init" or init_msg.get("mode") not in ("serial", "ble"):
|
||||
await websocket.close(code=4001, reason="Invalid init message")
|
||||
return
|
||||
|
||||
mode = init_msg["mode"]
|
||||
logger.info("Loopback init: mode=%s", mode)
|
||||
|
||||
# Create transport and MeshCore instance
|
||||
transport = LoopbackTransport(websocket, mode)
|
||||
|
||||
from meshcore import MeshCore
|
||||
|
||||
mc = MeshCore(transport, auto_reconnect=False, max_reconnect_attempts=0)
|
||||
await mc.connect()
|
||||
|
||||
if not mc.is_connected:
|
||||
logger.warning("Loopback MeshCore failed to connect")
|
||||
await websocket.close(code=4005, reason="MeshCore handshake failed")
|
||||
return
|
||||
|
||||
connection_info = f"Loopback ({mode})"
|
||||
radio_manager.connect_loopback(mc, connection_info)
|
||||
|
||||
# Run post-connect setup in background so the receive loop can run
|
||||
setup_task = asyncio.create_task(radio_manager.post_connect_setup())
|
||||
|
||||
# Main receive loop
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if "bytes" in message and message["bytes"]:
|
||||
transport.handle_rx(message["bytes"])
|
||||
elif "text" in message and message["text"]:
|
||||
try:
|
||||
text_msg = json.loads(message["text"])
|
||||
if text_msg.get("type") == "disconnect":
|
||||
logger.info("Loopback client requested disconnect")
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Loopback WebSocket disconnected")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Loopback init timeout")
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.close(code=4002, reason="Init timeout")
|
||||
except Exception as e:
|
||||
logger.exception("Loopback error: %s", e)
|
||||
finally:
|
||||
if setup_task is not None:
|
||||
setup_task.cancel()
|
||||
try:
|
||||
await setup_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
await radio_manager.disconnect_loopback()
|
||||
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -8,9 +8,7 @@ from meshcore import EventType
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.repository import MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
@@ -43,22 +41,12 @@ async def list_messages(
|
||||
@router.post("/direct", response_model=Message)
|
||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
"""Send a direct message to a contact."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
|
||||
try:
|
||||
db_contact = await ContactRepository.get_by_key_or_prefix(request.destination)
|
||||
except AmbiguousPublicKeyPrefixError as err:
|
||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Ambiguous destination key prefix '{err.prefix}'. "
|
||||
f"Use a full 64-character public key. Matching contacts: {sample}"
|
||||
),
|
||||
) from err
|
||||
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}"
|
||||
@@ -69,29 +57,28 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
# so we can't rely on it to know if the firmware has the contact.
|
||||
# add_contact is idempotent - updates if exists, adds if not.
|
||||
contact_data = db_contact.to_radio_dict()
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12])
|
||||
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 if contact exists
|
||||
logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12])
|
||||
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 if contact exists
|
||||
|
||||
# Get the contact from the library cache (may have updated info like path)
|
||||
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
|
||||
if not contact:
|
||||
contact = contact_data
|
||||
# Get the contact from the library cache (may have updated info like path)
|
||||
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
|
||||
if not contact:
|
||||
contact = contact_data
|
||||
|
||||
logger.info("Sending direct message to %s", db_contact.public_key[:12])
|
||||
logger.info("Sending direct message to %s", db_contact.public_key[:12])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures consistency for deduplication.
|
||||
now = int(time.time())
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures consistency for deduplication.
|
||||
now = int(time.time())
|
||||
|
||||
result = await mc.commands.send_msg(
|
||||
dst=contact,
|
||||
msg=request.text,
|
||||
timestamp=now,
|
||||
)
|
||||
result = await mc.commands.send_msg(
|
||||
dst=contact,
|
||||
msg=request.text,
|
||||
timestamp=now,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
@@ -133,9 +120,6 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
acked=0,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
@@ -163,7 +147,7 @@ TEMP_RADIO_SLOT = 0
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.decoder import calculate_channel_hash
|
||||
@@ -191,86 +175,56 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
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])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures the echo's timestamp matches our stored message
|
||||
# for proper deduplication.
|
||||
now = int(time.time())
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
timestamp=now.to_bytes(4, "little"), # Pass as bytes for compatibility
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing message with sender prefix (to match echo format)
|
||||
# The radio includes "SenderName: " prefix when broadcasting, so we store it the same way
|
||||
# to enable proper deduplication when the echo comes back
|
||||
channel_key_upper = request.channel_key.upper()
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
radio_name: str = ""
|
||||
text_with_sender: str = request.text
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
|
||||
# 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,
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text_with_sender,
|
||||
conversation_key=channel_key_upper,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
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,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to configure channel on radio before sending message",
|
||||
)
|
||||
|
||||
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures the echo's timestamp matches our stored message
|
||||
# for proper deduplication.
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing immediately after send to avoid a race where
|
||||
# our own echo lands before persistence.
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text_with_sender,
|
||||
conversation_key=channel_key_upper,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
# Broadcast immediately so all connected clients see the message promptly.
|
||||
# This ensures the message exists in frontend state when echo-driven
|
||||
# `message_acked` events arrive.
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=message_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
if message_id is None or now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
acked_count, paths = await MessageRepository.get_ack_and_paths(message_id)
|
||||
|
||||
message = Message(
|
||||
id=message_id,
|
||||
@@ -280,8 +234,7 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
paths=paths,
|
||||
acked=0,
|
||||
)
|
||||
|
||||
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
|
||||
@@ -302,134 +255,3 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
async def resend_channel_message(
|
||||
message_id: int,
|
||||
new_timestamp: bool = Query(default=False),
|
||||
) -> dict:
|
||||
"""Resend a channel message.
|
||||
|
||||
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
|
||||
Only allowed within 30 seconds of the original send.
|
||||
|
||||
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
||||
new packet. Creates a new message row in the database. No time window restriction.
|
||||
"""
|
||||
require_connected()
|
||||
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
msg = await MessageRepository.get_by_id(message_id)
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
if not msg.outgoing:
|
||||
raise HTTPException(status_code=400, detail="Can only resend outgoing messages")
|
||||
|
||||
if msg.type != "CHAN":
|
||||
raise HTTPException(status_code=400, detail="Can only resend channel messages")
|
||||
|
||||
if msg.sender_timestamp is None:
|
||||
raise HTTPException(status_code=400, detail="Message has no timestamp")
|
||||
|
||||
# Byte-perfect resend enforces the 30s window; new-timestamp resend does not
|
||||
if not new_timestamp:
|
||||
elapsed = int(time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)")
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
|
||||
|
||||
# Choose timestamp: original for byte-perfect, fresh for new-timestamp
|
||||
if new_timestamp:
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
|
||||
) from None
|
||||
|
||||
async with radio_manager.radio_operation("resend_channel_message") as mc:
|
||||
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to configure channel on radio before resending",
|
||||
)
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=text_to_send,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to resend message: {result.payload}"
|
||||
)
|
||||
|
||||
# For new-timestamp resend, create a new message row and broadcast it
|
||||
if new_timestamp:
|
||||
new_msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=msg.text,
|
||||
conversation_key=msg.conversation_key,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if new_msg_id is None:
|
||||
# Timestamp-second collision (same text+channel within the same second).
|
||||
# The radio already transmitted, so log and return the original ID rather
|
||||
# than surfacing a 500 for a message that was successfully sent over the air.
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message_id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=new_msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=msg.conversation_key,
|
||||
text=msg.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message_id,
|
||||
new_msg_id,
|
||||
db_channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_msg_id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
from sqlite3 import OperationalError
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -212,7 +210,7 @@ async def decrypt_historical_packets(
|
||||
# Try to find contact name for display
|
||||
from app.repository import ContactRepository
|
||||
|
||||
contact = await ContactRepository.get_by_key(contact_public_key_hex)
|
||||
contact = await ContactRepository.get_by_key_or_prefix(contact_public_key_hex)
|
||||
display_name = contact.name if contact else None
|
||||
|
||||
background_tasks.add_task(
|
||||
@@ -237,12 +235,8 @@ async def decrypt_historical_packets(
|
||||
|
||||
|
||||
class MaintenanceRequest(BaseModel):
|
||||
prune_undecrypted_days: int | None = Field(
|
||||
default=None, ge=1, description="Delete undecrypted packets older than this many days"
|
||||
)
|
||||
purge_linked_raw_packets: bool = Field(
|
||||
default=False,
|
||||
description="Delete raw packets already linked to a stored message",
|
||||
prune_undecrypted_days: int = Field(
|
||||
ge=1, description="Delete undecrypted packets older than this many days"
|
||||
)
|
||||
|
||||
|
||||
@@ -254,44 +248,21 @@ class MaintenanceResult(BaseModel):
|
||||
@router.post("/maintenance", response_model=MaintenanceResult)
|
||||
async def run_maintenance(request: MaintenanceRequest) -> MaintenanceResult:
|
||||
"""
|
||||
Run packet maintenance tasks and reclaim disk space.
|
||||
Clean up old undecrypted packets and reclaim disk space.
|
||||
|
||||
- Optionally deletes undecrypted packets older than the specified number of days
|
||||
- Optionally deletes raw packets already linked to stored messages
|
||||
- Deletes undecrypted packets older than the specified number of days
|
||||
- Runs VACUUM to reclaim disk space
|
||||
"""
|
||||
deleted = 0
|
||||
logger.info(
|
||||
"Running maintenance: pruning packets older than %d days", request.prune_undecrypted_days
|
||||
)
|
||||
|
||||
if request.prune_undecrypted_days is not None:
|
||||
logger.info(
|
||||
"Running maintenance: pruning undecrypted packets older than %d days",
|
||||
request.prune_undecrypted_days,
|
||||
)
|
||||
pruned_undecrypted = await RawPacketRepository.prune_old_undecrypted(
|
||||
request.prune_undecrypted_days
|
||||
)
|
||||
deleted += pruned_undecrypted
|
||||
logger.info("Deleted %d old undecrypted packets", pruned_undecrypted)
|
||||
# Prune old undecrypted packets
|
||||
deleted = await RawPacketRepository.prune_old_undecrypted(request.prune_undecrypted_days)
|
||||
logger.info("Deleted %d old undecrypted packets", deleted)
|
||||
|
||||
if request.purge_linked_raw_packets:
|
||||
logger.info("Running maintenance: purging raw packets linked to stored messages")
|
||||
purged_linked = await RawPacketRepository.purge_linked_to_messages()
|
||||
deleted += purged_linked
|
||||
logger.info("Deleted %d linked raw packets", purged_linked)
|
||||
# Run VACUUM to reclaim space (must be outside transaction, use executescript)
|
||||
await db.conn.executescript("VACUUM;")
|
||||
logger.info("Database vacuumed")
|
||||
|
||||
# Run VACUUM to reclaim space on a dedicated connection.
|
||||
# VACUUM requires exclusive access — if the main connection is actively
|
||||
# writing (background sync, message processing, etc.) it fails with
|
||||
# SQLITE_BUSY. This is expected; we just report vacuumed=False.
|
||||
vacuumed = False
|
||||
try:
|
||||
async with aiosqlite.connect(db.db_path) as vacuum_conn:
|
||||
await vacuum_conn.executescript("VACUUM;")
|
||||
vacuumed = True
|
||||
logger.info("Database vacuumed")
|
||||
except OperationalError as e:
|
||||
logger.warning("VACUUM skipped (database busy): %s", e)
|
||||
except Exception as e:
|
||||
logger.error("VACUUM failed unexpectedly: %s", e)
|
||||
|
||||
return MaintenanceResult(packets_deleted=deleted, vacuumed=vacuumed)
|
||||
return MaintenanceResult(packets_deleted=deleted, vacuumed=True)
|
||||
|
||||
@@ -5,7 +5,6 @@ from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
|
||||
@@ -70,46 +69,45 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""Update radio configuration. Only provided fields will be updated."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
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.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.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,
|
||||
)
|
||||
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
|
||||
await sync_radio_time(mc)
|
||||
# Sync time with system clock
|
||||
await sync_radio_time()
|
||||
|
||||
# Re-fetch self_info so the response reflects the changes we just made.
|
||||
# Commands like set_name() write to flash but don't update the cached
|
||||
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
|
||||
await mc.commands.send_appstart()
|
||||
# Re-fetch self_info so the response reflects the changes we just made.
|
||||
# Commands like set_name() write to flash but don't update the cached
|
||||
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
|
||||
await mc.commands.send_appstart()
|
||||
|
||||
return await get_radio_config()
|
||||
|
||||
@@ -117,7 +115,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
require_connected()
|
||||
mc = require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
@@ -125,30 +123,11 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for private key") from None
|
||||
|
||||
logger.info("Importing private key")
|
||||
async with radio_manager.radio_operation("import_private_key") as mc:
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
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}"
|
||||
)
|
||||
|
||||
# Re-export from radio so the server-side keystore uses the new key
|
||||
# for DM decryption immediately, rather than waiting for reconnect.
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
keystore_refreshed = await export_and_store_private_key(mc)
|
||||
if not keystore_refreshed:
|
||||
logger.warning("Keystore refresh failed after import, retrying once")
|
||||
keystore_refreshed = await export_and_store_private_key(mc)
|
||||
|
||||
if not keystore_refreshed:
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Private key imported on radio, but server-side keystore "
|
||||
"refresh failed. Reconnect to apply the new key for DM decryption."
|
||||
),
|
||||
status_code=500, detail=f"Failed to import private key: {result.payload}"
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -168,8 +147,7 @@ async def send_advertisement() -> dict:
|
||||
require_connected()
|
||||
|
||||
logger.info("Sending flood advertisement")
|
||||
async with radio_manager.radio_operation("manual_advertisement") as mc:
|
||||
success = await do_send_advertisement(mc, force=True)
|
||||
success = await do_send_advertisement(force=True)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send advertisement")
|
||||
@@ -177,33 +155,6 @@ async def send_advertisement() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Reconnection already in progress",
|
||||
"connected": False,
|
||||
}
|
||||
|
||||
success = await radio_manager.reconnect()
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed after reconnect")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
|
||||
|
||||
@router.post("/reboot")
|
||||
async def reboot_radio() -> dict:
|
||||
"""Reboot the radio, or reconnect if not currently connected.
|
||||
@@ -211,17 +162,36 @@ async def reboot_radio() -> dict:
|
||||
If connected: sends reboot command, connection will temporarily drop and auto-reconnect.
|
||||
If not connected: attempts to reconnect (same as /reconnect endpoint).
|
||||
"""
|
||||
if radio_manager.is_connected:
|
||||
from app.radio import radio_manager
|
||||
|
||||
# If connected, send reboot command
|
||||
if radio_manager.is_connected and radio_manager.meshcore:
|
||||
logger.info("Rebooting radio")
|
||||
async with radio_manager.radio_operation("reboot_radio") as mc:
|
||||
await mc.commands.reboot()
|
||||
await radio_manager.meshcore.commands.reboot()
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Reboot command sent. Radio will reconnect automatically.",
|
||||
}
|
||||
|
||||
# Not connected - attempt to reconnect
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Reconnection already in progress",
|
||||
"connected": False,
|
||||
}
|
||||
|
||||
logger.info("Radio not connected, attempting reconnect")
|
||||
return await _attempt_reconnect()
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
if success:
|
||||
await radio_manager.post_connect_setup()
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reconnect")
|
||||
@@ -232,20 +202,26 @@ async def reconnect_radio() -> dict:
|
||||
if no specific port is configured. Useful when the radio has been disconnected
|
||||
or power-cycled.
|
||||
"""
|
||||
if radio_manager.is_connected:
|
||||
if radio_manager.is_setup_complete:
|
||||
return {"status": "ok", "message": "Already connected", "connected": True}
|
||||
from app.radio import radio_manager
|
||||
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
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")
|
||||
return await _attempt_reconnect()
|
||||
success = await radio_manager.reconnect()
|
||||
|
||||
if success:
|
||||
await radio_manager.post_connect_setup()
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.database import db
|
||||
from app.models import UnreadCounts
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ChannelRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,18 +14,14 @@ router = APIRouter(prefix="/read-state", tags=["read-state"])
|
||||
|
||||
|
||||
@router.get("/unreads", response_model=UnreadCounts)
|
||||
async def get_unreads() -> UnreadCounts:
|
||||
async def get_unreads(
|
||||
name: str | None = Query(default=None, description="User's name for @mention detection"),
|
||||
) -> UnreadCounts:
|
||||
"""Get unread counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Computes unread counts server-side using last_read_at timestamps on
|
||||
channels and contacts, avoiding the need to fetch bulk messages.
|
||||
The radio's own name is sourced directly from the connected radio
|
||||
for @mention detection.
|
||||
"""
|
||||
name: str | None = None
|
||||
mc = radio_manager.meshcore
|
||||
if mc and mc.self_info:
|
||||
name = mc.self_info.get("name") or None
|
||||
data = await MessageRepository.get_unread_counts(name)
|
||||
return UnreadCounts(**data)
|
||||
|
||||
@@ -35,12 +31,13 @@ async def mark_all_read() -> dict:
|
||||
"""Mark all contacts and channels as read.
|
||||
|
||||
Updates last_read_at to current timestamp for all contacts and channels
|
||||
using two repository updates (same timestamp value across both tables).
|
||||
in a single database transaction.
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
await ContactRepository.mark_all_read(now)
|
||||
await ChannelRepository.mark_all_read(now)
|
||||
await db.conn.commit()
|
||||
|
||||
logger.info("Marked all contacts and channels as read at %d", now)
|
||||
return {"status": "ok", "timestamp": now}
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
CommandRequest,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
LppSensor,
|
||||
NeighborInfo,
|
||||
RepeaterAclResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterLoginRequest,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ACL permission level names
|
||||
ACL_PERMISSION_NAMES = {
|
||||
0: "Guest",
|
||||
1: "Read-only",
|
||||
2: "Read-write",
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
|
||||
# Delay between repeater radio operations to allow key exchange and path establishment
|
||||
REPEATER_OP_DELAY_SECONDS = 2.0
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability.
|
||||
|
||||
Patching time.monotonic directly breaks the asyncio event loop which also
|
||||
uses it. This indirection allows tests to control the clock safely.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
|
||||
|
||||
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
|
||||
target repeater arrives or the wall-clock deadline expires. Unrelated messages
|
||||
are safe to skip — meshcore's event dispatcher already delivers them to the
|
||||
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
|
||||
timeout: Wall-clock seconds before giving up
|
||||
|
||||
Returns:
|
||||
The matching Event, or None if no response arrived before the deadline.
|
||||
"""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("get_msg() exception: %s", e)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
# No messages queued yet — wait and retry
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
# Not our target — already dispatched to subscribers by meshcore,
|
||||
# so just continue draining the queue.
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
# Already dispatched to subscribers by meshcore; skip.
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
|
||||
"""Prepare connection to a repeater by adding to radio and logging in.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
contact: The repeater contact
|
||||
password: Password for login (empty string for no password)
|
||||
|
||||
Raises:
|
||||
HTTPException: If login fails
|
||||
"""
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
# Send login with password
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=401, detail=f"Login failed: {login_result.payload}")
|
||||
|
||||
# Wait for key exchange to complete before sending requests
|
||||
logger.debug("Waiting %.1fs for key exchange to complete", REPEATER_OP_DELAY_SECONDS)
|
||||
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
|
||||
|
||||
|
||||
def _require_repeater(contact: Contact) -> None:
|
||||
"""Raise 400 if contact is not a repeater."""
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Granular repeater endpoints — one attempt, no server-side retries.
|
||||
# Frontend manages retry logic for better UX control.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Log in to a repeater. Adds contact to radio, sends login, waits for key exchange."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_login",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await prepare_repeater_connection(mc, contact, request.password)
|
||||
|
||||
return RepeaterLoginResponse(status="ok")
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_status", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
# Ensure contact is on radio for routing
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
last_rssi_dbm=status.get("last_rssi", 0),
|
||||
last_snr_db=status.get("last_snr", 0.0),
|
||||
packets_received=status.get("nb_recv", 0),
|
||||
packets_sent=status.get("nb_sent", 0),
|
||||
airtime_seconds=status.get("airtime", 0),
|
||||
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||
uptime_seconds=status.get("uptime", 0),
|
||||
sent_flood=status.get("sent_flood", 0),
|
||||
sent_direct=status.get("sent_direct", 0),
|
||||
recv_flood=status.get("recv_flood", 0),
|
||||
recv_direct=status.get("recv_direct", 0),
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from repeater")
|
||||
|
||||
sensors: list[LppSensor] = []
|
||||
for entry in telemetry:
|
||||
channel = entry.get("channel", 0)
|
||||
type_name = str(entry.get("type", "unknown"))
|
||||
value = entry.get("value", 0)
|
||||
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
|
||||
|
||||
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_neighbors", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
# Ensure contact is on radio for routing
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
neighbors_data = await mc.commands.fetch_all_neighbours(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
neighbors: list[NeighborInfo] = []
|
||||
if neighbors_data and "neighbours" in neighbors_data:
|
||||
for n in neighbors_data["neighbours"]:
|
||||
pubkey_prefix = n.get("pubkey", "")
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
neighbors.append(
|
||||
NeighborInfo(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
snr=n.get("snr", 0.0),
|
||||
last_heard_seconds=n.get("secs_ago", 0),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterNeighborsResponse(neighbors=neighbors)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"repeater_acl", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
# Ensure contact is on radio for routing
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
acl_entries: list[AclEntry] = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
acl_entries.append(
|
||||
AclEntry(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
permission=perm,
|
||||
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterAclResponse(acl=acl_entries)
|
||||
|
||||
|
||||
async def _batch_cli_fetch(
|
||||
contact: Contact,
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a repeater and collect responses.
|
||||
|
||||
Opens a radio operation with polling paused and auto-fetch suspended (since
|
||||
we call get_msg() directly via _fetch_repeater_response), adds the contact
|
||||
to the radio for routing, then sends each command sequentially with a 1-second
|
||||
gap between them.
|
||||
|
||||
Returns a dict mapping field names to response strings (or None on timeout).
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for i, (cmd, field) in enumerate(commands):
|
||||
if i > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await _fetch_repeater_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = _extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""Fetch radio settings from a repeater via batch CLI commands."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_radio_settings",
|
||||
[
|
||||
("ver", "firmware_version"),
|
||||
("get radio", "radio"),
|
||||
("get tx", "tx_power"),
|
||||
("get af", "airtime_factor"),
|
||||
("get repeat", "repeat_enabled"),
|
||||
("get flood.max", "flood_max"),
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterRadioSettingsResponse(**results)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{public_key}/repeater/advert-intervals", response_model=RepeaterAdvertIntervalsResponse
|
||||
)
|
||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_advert_intervals",
|
||||
[
|
||||
("get advert.interval", "advert_interval"),
|
||||
("get flood.advert.interval", "flood_advert_interval"),
|
||||
],
|
||||
)
|
||||
return RepeaterAdvertIntervalsResponse(**results)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_owner_info",
|
||||
[
|
||||
("get owner.info", "owner_info"),
|
||||
("get guest.password", "guest_password"),
|
||||
],
|
||||
)
|
||||
return RepeaterOwnerInfoResponse(**results)
|
||||
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the repeater/login endpoint. This endpoint ensures the contact is on the
|
||||
radio before sending commands (the repeater remembers ACL permissions after login).
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"send_repeater_command",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Send the command
|
||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response using validated fetch loop
|
||||
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from repeater %s for command: %s",
|
||||
contact.public_key[:12],
|
||||
request.command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
|
||||
response_text = _extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
@@ -38,9 +37,7 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
),
|
||||
description="Maximum non-repeater contacts to keep on radio (1-1000)",
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
default=None,
|
||||
@@ -53,50 +50,12 @@ class AppSettingsUpdate(BaseModel):
|
||||
advert_interval: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
)
|
||||
bots: list[BotConfig] | None = Field(
|
||||
default=None,
|
||||
description="List of bot configurations",
|
||||
)
|
||||
mqtt_broker_host: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT broker hostname (empty = disabled)",
|
||||
)
|
||||
mqtt_broker_port: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="MQTT broker port",
|
||||
)
|
||||
mqtt_username: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT username (optional)",
|
||||
)
|
||||
mqtt_password: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT password (optional)",
|
||||
)
|
||||
mqtt_use_tls: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to use TLS for MQTT connection",
|
||||
)
|
||||
mqtt_tls_insecure: bool | None = Field(
|
||||
default=None,
|
||||
description="Skip TLS certificate verification (for self-signed certs)",
|
||||
)
|
||||
mqtt_topic_prefix: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT topic prefix",
|
||||
)
|
||||
mqtt_publish_messages: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish decrypted messages to MQTT",
|
||||
)
|
||||
mqtt_publish_raw_packets: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
|
||||
|
||||
class FavoriteRequest(BaseModel):
|
||||
@@ -150,47 +109,16 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
|
||||
|
||||
if update.advert_interval is not None:
|
||||
# Enforce minimum 1-hour interval; 0 means disabled
|
||||
interval = update.advert_interval
|
||||
if 0 < interval < 3600:
|
||||
interval = 3600
|
||||
logger.info("Updating advert_interval to %d", interval)
|
||||
kwargs["advert_interval"] = interval
|
||||
logger.info("Updating advert_interval to %d", update.advert_interval)
|
||||
kwargs["advert_interval"] = update.advert_interval
|
||||
|
||||
if update.bots is not None:
|
||||
validate_all_bots(update.bots)
|
||||
logger.info("Updating bots (count=%d)", len(update.bots))
|
||||
kwargs["bots"] = update.bots
|
||||
|
||||
# MQTT fields
|
||||
mqtt_fields = [
|
||||
"mqtt_broker_host",
|
||||
"mqtt_broker_port",
|
||||
"mqtt_username",
|
||||
"mqtt_password",
|
||||
"mqtt_use_tls",
|
||||
"mqtt_tls_insecure",
|
||||
"mqtt_topic_prefix",
|
||||
"mqtt_publish_messages",
|
||||
"mqtt_publish_raw_packets",
|
||||
]
|
||||
mqtt_changed = False
|
||||
for field in mqtt_fields:
|
||||
value = getattr(update, field)
|
||||
if value is not None:
|
||||
kwargs[field] = value
|
||||
mqtt_changed = True
|
||||
|
||||
if kwargs:
|
||||
result = await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
# Restart MQTT publisher if any MQTT settings changed
|
||||
if mqtt_changed:
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
await mqtt_publisher.restart(result)
|
||||
|
||||
return result
|
||||
return await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@@ -203,19 +131,10 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
|
||||
if is_favorited:
|
||||
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
return await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
else:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
|
||||
# When a contact favorite changes, sync the radio so the contact is
|
||||
# loaded/unloaded immediately rather than waiting for the next advert.
|
||||
if request.type == "contact":
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
|
||||
|
||||
return result
|
||||
return await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
return StatisticsResponse(**data)
|
||||
@@ -23,9 +23,7 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
|
||||
# Send initial health status
|
||||
try:
|
||||
health_data = await build_health_data(
|
||||
radio_manager.is_connected, radio_manager.connection_info
|
||||
)
|
||||
health_data = await build_health_data(radio_manager.is_connected, radio_manager.port)
|
||||
await ws_manager.send_personal(websocket, "health", health_data)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -96,14 +96,10 @@ 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 and forward to MQTT.
|
||||
an event to all connected WebSocket clients.
|
||||
"""
|
||||
asyncio.create_task(ws_manager.broadcast(event_type, data))
|
||||
|
||||
from app.mqtt import mqtt_broadcast
|
||||
|
||||
mqtt_broadcast(event_type, data)
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
"""Broadcast an error notification to all connected clients.
|
||||
@@ -127,13 +123,13 @@ def broadcast_success(message: str, details: str | None = None) -> None:
|
||||
asyncio.create_task(ws_manager.broadcast("success", data))
|
||||
|
||||
|
||||
def broadcast_health(radio_connected: bool, connection_info: str | None = None) -> None:
|
||||
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
|
||||
"""Broadcast health status change to all connected clients."""
|
||||
|
||||
async def _broadcast():
|
||||
from app.routers.health import build_health_data
|
||||
|
||||
data = await build_health_data(radio_connected, connection_info)
|
||||
data = await build_health_data(radio_connected, serial_port)
|
||||
await ws_manager.broadcast("health", data)
|
||||
|
||||
asyncio.create_task(_broadcast())
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
services:
|
||||
remoteterm:
|
||||
build: .
|
||||
# image: jkingsman/remoteterm-meshcore:latest
|
||||
|
||||
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||
# user: "${UID:-1000}:${GID:-1000}"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
################################################
|
||||
# Set your serial device for passthrough here! #
|
||||
################################################
|
||||
devices:
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0
|
||||
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
||||
|
||||
# Serial (USB)
|
||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
restart: unless-stopped
|
||||
@@ -1,311 +1,712 @@
|
||||
# Frontend AGENTS.md
|
||||
|
||||
This document is the frontend working guide for agents and developers.
|
||||
Keep it aligned with `frontend/src` source code.
|
||||
This document provides context for AI assistants and developers working on the React frontend.
|
||||
|
||||
## Stack
|
||||
## Technology Stack
|
||||
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- Vitest + Testing Library
|
||||
- shadcn/ui primitives
|
||||
- Tailwind utility classes + local CSS (`index.css`, `styles.css`)
|
||||
- Sonner (toasts)
|
||||
- Leaflet / react-leaflet (map)
|
||||
- `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker)
|
||||
- **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-hashtag-cracker** - WebGPU-accelerated channel key bruteforcing
|
||||
- **nosleep.js** - Prevents device sleep during cracking
|
||||
- **leaflet / react-leaflet** - Interactive map for node locations
|
||||
|
||||
## Frontend Map
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── main.tsx # React entry point (StrictMode, root render)
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
├── styles.css # Additional global app styles
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
|
||||
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
├── utils/
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
||||
│ ├── messageParser.ts # Message text → rendered segments
|
||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||
│ └── radioPresets.ts # LoRa radio preset configurations
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
│ ├── ChatHeader.tsx # Conversation header (trace, favorite, delete)
|
||||
│ ├── MessageList.tsx
|
||||
│ ├── MessageInput.tsx
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── MapView.tsx
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer3D.tsx
|
||||
│ ├── PathModal.tsx
|
||||
│ ├── CrackerPanel.tsx
|
||||
│ ├── BotCodeEditor.tsx
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Preset, freq/bw/sf/cr, txPower, lat/lon
|
||||
│ │ ├── SettingsIdentitySection.tsx # Name, keys, advert interval
|
||||
│ │ ├── SettingsConnectivitySection.tsx # Connection status, max contacts, reboot
|
||||
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ └── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ ├── repeater/
|
||||
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
|
||||
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
|
||||
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
|
||||
│ │ ├── RepeaterAclPane.tsx # Permission table
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
|
||||
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
|
||||
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
|
||||
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
|
||||
│ │ └── RepeaterConsolePane.tsx # CLI console with history
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
└── test/
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── integration.test.ts
|
||||
├── messageCache.test.ts
|
||||
├── messageParser.test.ts
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── radioPresets.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterDashboard.test.tsx
|
||||
├── repeaterFormatters.test.ts
|
||||
├── repeaterLogin.test.tsx
|
||||
├── repeaterMessageParsing.test.ts
|
||||
├── localLabel.test.ts
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
└── useWebSocket.lifecycle.test.ts
|
||||
```
|
||||
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 message times (sidebar sorting)
|
||||
│ │ ├── 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
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist)
|
||||
│ │ ├── NewMessageModal.tsx
|
||||
│ │ └── SettingsModal.tsx # Unified settings: radio config, identity, serial, database, advertise
|
||||
│ └── 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
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
## State Management
|
||||
|
||||
### State ownership
|
||||
All application state lives in `App.tsx` using React hooks. No external state library.
|
||||
|
||||
`App.tsx` orchestrates high-level state and delegates to hooks:
|
||||
- `useRadioControl`: radio health/config state, reconnect/reboot polling
|
||||
- `useAppSettings`: settings CRUD, favorites, preferences migration
|
||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||
- `useConversationRouter`: URL hash → active conversation routing
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||
### Core State
|
||||
|
||||
### Initial load + realtime
|
||||
```typescript
|
||||
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 [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
|
||||
- WebSocket: realtime deltas/events.
|
||||
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
|
||||
### App Settings
|
||||
|
||||
### New Message modal
|
||||
App settings are stored server-side and include:
|
||||
- `favorites` - List of favorited conversations (channels/contacts)
|
||||
- `sidebar_sort_order` - 'recent' or 'alpha'
|
||||
- `auto_decrypt_dm_on_advert` - Auto-decrypt historical DMs on new contact
|
||||
- `last_message_times` - Map of conversation keys to last message timestamps
|
||||
|
||||
`NewMessageModal` intentionally preserves form state (tab, inputs, checkboxes) when closed and reopened. The component instance persists across open/close cycles. This is by design so users don't lose in-progress input if they accidentally dismiss the dialog.
|
||||
**Migration**: On first load, localStorage preferences are migrated to the server.
|
||||
The `preferences_migrated` flag prevents duplicate migrations.
|
||||
|
||||
### Message behavior
|
||||
### State Flow
|
||||
|
||||
- Outgoing sends are added to UI after the send API returns (not pre-send optimistic insertion), then persisted server-side.
|
||||
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
|
||||
- ACK/repeat updates arrive as `message_acked` events.
|
||||
- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
|
||||
1. **REST API** fetches initial data on mount in parallel (config, settings, channels, contacts, unreads)
|
||||
2. **WebSocket** pushes real-time updates (health, messages, contact changes, raw packets)
|
||||
3. **Components** receive state as props, call handlers to trigger changes
|
||||
|
||||
### Visualizer behavior
|
||||
**Note:** Contacts and channels are loaded via REST on mount (not from WebSocket initial push).
|
||||
The WebSocket only sends health on initial connect, then broadcasts real-time updates.
|
||||
|
||||
- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs).
|
||||
- `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame.
|
||||
- Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets.
|
||||
- Raw packet events carry both:
|
||||
- `id`: backend storage row identity (payload-level dedup)
|
||||
- `observation_id`: realtime per-arrival identity (session fidelity)
|
||||
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
|
||||
### Conversation Header
|
||||
|
||||
For contacts, the header shows path information alongside "Last heard":
|
||||
- `(Last heard: 10:30 AM, direct)` - Direct neighbor (path_len=0)
|
||||
- `(Last heard: 10:30 AM, 2 hops)` - Routed through repeaters (path_len>0)
|
||||
- `(Last heard: 10:30 AM, flood)` - No known path (path_len=-1)
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
The `useWebSocket` hook manages real-time connection:
|
||||
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
```typescript
|
||||
const wsHandlers = useMemo(() => ({
|
||||
onHealth: (data: HealthStatus) => setHealth(data),
|
||||
onMessage: (msg: Message) => { /* add to list, track unread */ },
|
||||
onMessageAcked: (messageId: number, ackCount: number) => { /* update ack count */ },
|
||||
// ...
|
||||
}), []);
|
||||
|
||||
Supported routes:
|
||||
- `#raw`
|
||||
- `#map`
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
useWebSocket(wsHandlers);
|
||||
```
|
||||
|
||||
Legacy name-based hashes are still accepted for compatibility.
|
||||
### Features
|
||||
|
||||
## Conversation State Keys (`utils/conversationState.ts`)
|
||||
- **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
|
||||
|
||||
`getStateKey(type, id)` produces:
|
||||
- channels: `channel-{channelKey}`
|
||||
- contacts: `contact-{publicKey}`
|
||||
### URL Detection
|
||||
|
||||
Use full contact public key here (not 12-char prefix).
|
||||
```typescript
|
||||
const isDev = window.location.port === '5173';
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8000/api/ws'
|
||||
: `${protocol}//${window.location.host}/api/ws`;
|
||||
```
|
||||
|
||||
`conversationState.ts` keeps an in-memory cache and localStorage helpers used for migration/compatibility.
|
||||
Canonical persistence for unread and sort metadata is server-side (`app_settings` + read-state endpoints).
|
||||
## API Client (`api.ts`)
|
||||
|
||||
## Utilities
|
||||
Typed REST client with consistent error handling:
|
||||
|
||||
### `utils/pubkey.ts`
|
||||
```typescript
|
||||
import { api } from './api';
|
||||
|
||||
Current public export:
|
||||
- `getContactDisplayName(name, pubkey)`
|
||||
// Health
|
||||
await api.getHealth();
|
||||
|
||||
It falls back to a 12-char prefix when `name` is missing.
|
||||
// Radio
|
||||
await api.getRadioConfig();
|
||||
await api.updateRadioConfig({ name: 'MyRadio' });
|
||||
await api.sendAdvertisement();
|
||||
|
||||
### `utils/pathUtils.ts`
|
||||
// Contacts/Channels
|
||||
await api.getContacts();
|
||||
await api.createContact(publicKey, name, tryHistorical); // Create contact, optionally decrypt historical DMs
|
||||
await api.getChannels();
|
||||
await api.createChannel('#test');
|
||||
|
||||
Distance/validation helpers used by path + map UI.
|
||||
// Messages
|
||||
await api.getMessages({ type: 'CHAN', conversation_key: channelKey, limit: 200 });
|
||||
await api.sendChannelMessage(channelKey, 'Hello');
|
||||
await api.sendDirectMessage(publicKey, 'Hello');
|
||||
|
||||
### `utils/favorites.ts`
|
||||
// Historical decryption
|
||||
await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test' });
|
||||
|
||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
||||
// Radio reconnection
|
||||
await api.reconnectRadio(); // Returns { status, message, connected }
|
||||
|
||||
## Types and Contracts (`types.ts`)
|
||||
// Repeater telemetry
|
||||
await api.requestTelemetry(publicKey, password); // Returns TelemetryResponse
|
||||
|
||||
`AppSettings` currently includes:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
// Repeater CLI commands (after login)
|
||||
await api.sendRepeaterCommand(publicKey, 'ver'); // Returns CommandResponse
|
||||
```
|
||||
|
||||
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
|
||||
### API Proxy (Development)
|
||||
|
||||
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
||||
Vite proxies `/api/*` to backend (backend routes are already prefixed with `/api`):
|
||||
|
||||
## Contact Info Pane
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/{key}/detail`:
|
||||
## Type Definitions (`types.ts`)
|
||||
|
||||
- Header: avatar, name, public key, type badge, on-radio badge
|
||||
- Info grid: last seen, first heard, last contacted, distance, hops
|
||||
- GPS location (clickable → map)
|
||||
- Favorite toggle
|
||||
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
||||
- Message stats: DM count, channel message count
|
||||
- Most active rooms (clickable → navigate to channel)
|
||||
- Advert observation rate
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
### Key Interfaces
|
||||
|
||||
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
```typescript
|
||||
interface Contact {
|
||||
public_key: string; // 64-char hex public key
|
||||
name: string | null;
|
||||
type: number; // 0=unknown, 1=client, 2=repeater, 3=room
|
||||
on_radio: boolean;
|
||||
last_path_len: number; // -1=flood, 0=direct, >0=hops through repeaters
|
||||
last_path: string | null; // Hex routing path
|
||||
last_seen: number | null; // Unix timestamp
|
||||
// ...
|
||||
}
|
||||
|
||||
## Repeater Dashboard
|
||||
interface Channel {
|
||||
key: string; // 32-char hex channel key
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
}
|
||||
|
||||
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
interface Message {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
conversation_key: string; // public key for PRIV, channel key for CHAN
|
||||
text: string;
|
||||
outgoing: boolean;
|
||||
acked: number; // 0=not acked, 1+=ack count (flood echoes)
|
||||
// ...
|
||||
}
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
interface Conversation {
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
|
||||
id: string; // public key for contacts, channel key for channels, 'raw'/'map'/'visualizer' for special views
|
||||
name: string;
|
||||
}
|
||||
|
||||
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
interface Favorite {
|
||||
type: 'channel' | 'contact';
|
||||
id: string; // Channel key or contact public key
|
||||
}
|
||||
|
||||
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
|
||||
interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
sidebar_sort_order: 'recent' | 'alpha';
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
}
|
||||
|
||||
**Console pane**: Full CLI access via the same command endpoint. History is ephemeral (not persisted to DB).
|
||||
// Repeater telemetry types
|
||||
interface NeighborInfo {
|
||||
pubkey_prefix: string;
|
||||
name: string | null;
|
||||
snr: number;
|
||||
last_heard_seconds: number;
|
||||
}
|
||||
|
||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||
interface AclEntry {
|
||||
pubkey_prefix: string;
|
||||
name: string | null;
|
||||
permission: number;
|
||||
permission_name: string;
|
||||
}
|
||||
|
||||
## Styling
|
||||
interface TelemetryResponse {
|
||||
battery_volts: number;
|
||||
uptime_seconds: number;
|
||||
// ... status fields
|
||||
neighbors: NeighborInfo[];
|
||||
acl: AclEntry[];
|
||||
}
|
||||
|
||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||
Do not rely on old class-only layout assumptions.
|
||||
interface CommandResponse {
|
||||
command: string;
|
||||
response: string;
|
||||
sender_timestamp: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Security Posture (intentional)
|
||||
## Component Patterns
|
||||
|
||||
- No authentication UI.
|
||||
- Frontend assumes trusted network usage.
|
||||
- Bot editor intentionally allows arbitrary backend bot code configuration.
|
||||
### 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, isRepeaterMode }, 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}] `);
|
||||
```
|
||||
|
||||
### Repeater Mode
|
||||
|
||||
Repeater contacts (type=2) have a two-phase interaction:
|
||||
|
||||
**Phase 1: Login (password mode)**
|
||||
- Input type changes to `password`
|
||||
- Button shows "Fetch" instead of "Send"
|
||||
- Enter "." for empty password (converted to empty string)
|
||||
- Submitting requests telemetry + logs in
|
||||
|
||||
**Phase 2: CLI commands (after login)**
|
||||
- Input switches back to normal text
|
||||
- Placeholder shows "Enter CLI command..."
|
||||
- Commands sent via `/contacts/{key}/command` endpoint
|
||||
- Responses displayed as local messages (not persisted to database)
|
||||
|
||||
```typescript
|
||||
// State tracking
|
||||
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
|
||||
|
||||
// Reset on conversation change
|
||||
useEffect(() => {
|
||||
setRepeaterLoggedIn(false);
|
||||
}, [activeConversation?.id]);
|
||||
|
||||
// Mode switches after successful telemetry
|
||||
const isRepeaterMode = activeContactIsRepeater && !repeaterLoggedIn;
|
||||
|
||||
<MessageInput
|
||||
onSend={isRepeaterMode ? handleTelemetryRequest :
|
||||
(repeaterLoggedIn ? handleRepeaterCommand : handleSendMessage)}
|
||||
isRepeaterMode={isRepeaterMode}
|
||||
placeholder={repeaterLoggedIn ? 'Enter CLI command...' : undefined}
|
||||
/>
|
||||
```
|
||||
|
||||
Telemetry response is displayed as three local messages (not persisted):
|
||||
1. **Telemetry** - Battery voltage, uptime, signal quality, packet stats
|
||||
2. **Neighbors** - Sorted by SNR (highest first), with resolved names
|
||||
3. **ACL** - Access control list with permission levels
|
||||
|
||||
### Repeater Message Rendering
|
||||
|
||||
Repeater CLI responses often contain colons (e.g., `clock: 12:30:00`). To prevent
|
||||
incorrect sender parsing, MessageList skips `parseSenderFromText()` for repeater contacts:
|
||||
|
||||
```typescript
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
const { sender, content } = isRepeater
|
||||
? { sender: null, content: msg.text } // Preserve full text
|
||||
: parseSenderFromText(msg.text);
|
||||
```
|
||||
|
||||
### 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 message times used in sidebar sorting) 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 local state tracking, while `conversation_key` is the raw database field.
|
||||
|
||||
### Read State (Server-Side)
|
||||
|
||||
Unread tracking uses server-side `last_read_at` timestamps for cross-device consistency:
|
||||
|
||||
```typescript
|
||||
// Fetch aggregated unread counts from server (replaces bulk message fetch + client-side counting)
|
||||
await api.getUnreads(myName); // Returns { counts, mentions, last_message_times }
|
||||
|
||||
// Mark as read via API (called automatically when viewing conversation)
|
||||
await api.markContactRead(publicKey);
|
||||
await api.markChannelRead(channelKey);
|
||||
await api.markAllRead(); // Bulk mark all as read
|
||||
```
|
||||
|
||||
The `useUnreadCounts` hook fetches counts from `GET /api/read-state/unreads` on mount and
|
||||
when channels/contacts change. Real-time increments are still tracked client-side via WebSocket
|
||||
message events. The server computes unread counts using `last_read_at` vs `received_at`.
|
||||
|
||||
## 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, getLastMessageTimes } from './utils/conversationState';
|
||||
|
||||
// Generate state tracking key (NOT the same as Message.conversation_key)
|
||||
getStateKey('channel', channelKey)
|
||||
getStateKey('contact', publicKey)
|
||||
|
||||
// Track message times for sidebar sorting (stored in localStorage)
|
||||
setLastMessageTime(stateKey, timestamp)
|
||||
getLastMessageTimes() // Returns all tracked message times
|
||||
```
|
||||
|
||||
**Note:** Read state (`last_read_at`) is tracked server-side, not in localStorage.
|
||||
|
||||
### 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 all quality checks (backend + frontend, parallelized) from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
|
||||
Or run frontend checks individually:
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:run
|
||||
npm run build
|
||||
npm run test:run # Single run
|
||||
npm run test # Watch mode
|
||||
```
|
||||
|
||||
When touching cross-layer contracts, also run backend tests from repo root:
|
||||
### 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
|
||||
- `repeaterMode.test.ts` - Repeater CLI parsing, password "." conversion
|
||||
|
||||
### 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
|
||||
PYTHONPATH=. uv run pytest tests/ -v
|
||||
# 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
|
||||
```
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
The dev server runs on port 5173 and proxies API requests to `localhost:8000`.
|
||||
|
||||
### RawPacketList always scrolls to bottom
|
||||
### Production Build
|
||||
|
||||
`RawPacketList` unconditionally scrolls to the latest packet on every update. This is intentional — the packet feed is a live status display, not an interactive log meant for lingering or long-term analysis. Users watching it want to see the newest packet, not hold a scroll position.
|
||||
In production, the FastAPI backend serves the compiled frontend from `frontend/dist`:
|
||||
|
||||
## Editing Checklist
|
||||
```bash
|
||||
npm run build
|
||||
# Then run backend: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
1. If API/WS payloads change, update `types.ts`, handlers, and tests.
|
||||
2. If URL/hash behavior changes, update `utils/urlHash.ts` tests.
|
||||
3. If read/unread semantics change, update `useUnreadCounts` tests.
|
||||
4. Keep this file concise; prefer source links over speculative detail.
|
||||
## URL Hash Navigation
|
||||
|
||||
Deep linking to conversations via URL hash:
|
||||
|
||||
- `#channel/RoomName` - Opens a channel (leading `#` stripped from name for cleaner URLs)
|
||||
- `#contact/ContactName` - Opens a DM
|
||||
- `#raw` - Opens the raw packet feed
|
||||
- `#map` - Opens the node map
|
||||
|
||||
```typescript
|
||||
// Parse hash on initial load
|
||||
const hashConv = parseHashConversation();
|
||||
|
||||
// Update hash when conversation changes (uses replaceState to avoid history pollution)
|
||||
window.history.replaceState(null, '', newHash);
|
||||
```
|
||||
|
||||
## CrackerPanel
|
||||
|
||||
The `CrackerPanel` component provides WebGPU-accelerated brute-forcing of channel keys for undecrypted GROUP_TEXT packets.
|
||||
|
||||
### Features
|
||||
|
||||
- **Dictionary attack first**: Uses `words.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
|
||||
- **NoSleep integration**: Prevents device sleep during cracking via `nosleep.js`
|
||||
- **Global collapsible panel**: Toggle from sidebar, runs in background when hidden
|
||||
|
||||
### 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.
|
||||
|
||||
## MapView
|
||||
|
||||
The `MapView` component displays contacts with GPS coordinates on an interactive Leaflet map.
|
||||
|
||||
### Features
|
||||
|
||||
- **Location filtering**: Only shows contacts with lat/lon that were heard within the last 7 days
|
||||
- **Freshness coloring**: Markers colored by how recently the contact was heard:
|
||||
- Bright green (`#22c55e`) - less than 1 hour ago
|
||||
- Light green (`#4ade80`) - less than 1 day ago
|
||||
- Yellow-green (`#a3e635`) - less than 3 days ago
|
||||
- Gray (`#9ca3af`) - older (up to 7 days)
|
||||
- **Node/repeater distinction**: Regular nodes have black outlines, repeaters are larger with no outline
|
||||
- **Geolocation**: Tries browser geolocation first, falls back to fitting all markers in view
|
||||
- **Popups**: Click a marker to see contact name, last heard time, and coordinates
|
||||
|
||||
### Data Source
|
||||
|
||||
Contact location data (`lat`, `lon`) is extracted from advertisement packets in the backend (`decoder.py`).
|
||||
The `last_seen` timestamp determines marker freshness.
|
||||
|
||||
## Sidebar Features
|
||||
|
||||
- **Sort toggle**: Default is 'recent' (most recent message first), can toggle to alphabetical
|
||||
- **Mark all as read**: Button appears when there are unread messages, clears all unread counts
|
||||
- **Cracker toggle**: Shows/hides the global cracker panel with running status indicator
|
||||
|
||||
## 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 (use sparingly - only for significant/destructive actions)
|
||||
toast.success('Channel deleted');
|
||||
|
||||
// Error toast with details
|
||||
toast.error('Failed to send message', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
All async operations that can fail should show error toasts. Keep console.error for debugging:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await api.someOperation();
|
||||
} catch (err) {
|
||||
console.error('Failed to do X:', err);
|
||||
toast.error('Failed to do X', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Where Toasts Are Used
|
||||
|
||||
**Error toasts** (shown when operations fail):
|
||||
- `App.tsx`: Advertisement, channel delete, contact delete
|
||||
- `useConversationMessages.ts`: Message loading (initial and pagination)
|
||||
- `MessageInput.tsx`: Message send, telemetry request
|
||||
- `CrackerPanel.tsx`: Channel save after cracking, WebGPU unavailable
|
||||
- `StatusBar.tsx`: Manual reconnection failure
|
||||
- `useWebSocket.ts`: Backend errors via WebSocket `error` events
|
||||
|
||||
**Success toasts** (used sparingly for significant actions):
|
||||
- Radio connection/disconnection status changes
|
||||
- Manual reconnection success
|
||||
- Advertisement sent, channel/contact deleted (confirmation of intentional actions)
|
||||
|
||||
**Avoid success toasts** for routine operations like sending messages - only show errors.
|
||||
|
||||
The `<Toaster />` component is rendered in `App.tsx` with `position="top-right"`.
|
||||
|
||||
@@ -6,50 +6,13 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MCTerm" />
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
// fetches before it discovers and starts downloading the JS bundle.
|
||||
// React hooks consume the promises via window.__prefetch.
|
||||
var fetchJsonOrThrow = function(url) {
|
||||
return fetch(url).then(function(response) {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text().then(function(rawError) {
|
||||
var message = rawError || response.statusText || ('HTTP ' + response.status);
|
||||
try {
|
||||
var parsed = JSON.parse(rawError);
|
||||
if (parsed && typeof parsed.detail === 'string' && parsed.detail.length > 0) {
|
||||
message = parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
// Keep raw text fallback when body is not JSON.
|
||||
}
|
||||
throw new Error('Prefetch failed for ' + url + ': ' + message);
|
||||
});
|
||||
}).catch(function(err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
window.__prefetch = {
|
||||
config: fetchJsonOrThrow('/api/radio/config'),
|
||||
settings: fetchJsonOrThrow('/api/settings'),
|
||||
channels: fetchJsonOrThrow('/api/channels'),
|
||||
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,8 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "eslint src/ --cache",
|
||||
"lint:fix": "eslint src/ --fix --cache",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/"
|
||||
},
|
||||
@@ -28,18 +28,16 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"meshcore-hashtag-cracker": "^1.7.0",
|
||||
"meshcore-hashtag-cracker": "^1.6.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0"
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
@@ -47,7 +45,6 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/three": "^0.182.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 96 KiB |
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512pt" height="512pt" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m455.68 85.902c-31.289 0-56.32 25.031-56.32 56.32 0 11.379 3.4141 21.617 8.5352 30.152l-106.38 135.39c12.516 6.2578 23.895 15.359 32.996 25.602l107.52-136.54c4.5508 1.1367 9.1016 1.707 13.652 1.707 31.289 0 56.32-25.031 56.32-56.32 0-30.719-25.031-56.32-56.32-56.32z"/>
|
||||
<path d="m256 343.04c-5.6875 0-10.809 0.57031-15.93 2.2773l-106.38-135.96c-9.1016 10.809-20.48 19.344-32.996 25.602l106.38 135.96c-5.1211 8.5352-7.3945 18.203-7.3945 28.445 0 31.289 25.031 56.32 56.32 56.32s56.32-25.031 56.32-56.32c0-31.293-25.031-56.324-56.32-56.324z"/>
|
||||
<path d="m356.69 114.91c3.9805-13.652 10.238-26.738 19.344-37.547-38.113-13.652-78.508-21.047-120.04-21.047-59.164 0-115.48 14.789-166.12 42.668-9.1016-6.8281-21.051-10.809-33.562-10.809-31.289-0.57031-56.32 25.027-56.32 55.75 0 31.289 25.031 56.32 56.32 56.32 31.289 0 56.32-25.031 56.32-56.32 0-3.4141-0.57031-6.8281-1.1367-9.6719 44.371-23.895 93.297-36.41 144.5-36.41 34.703 0 68.836 5.6914 100.69 17.066z"/>
|
||||
<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m405.89 352.77c0 30.797-25.02 55.762-55.887 55.762s-55.887-24.965-55.887-55.762c0-30.797 25.02-55.762 55.887-55.762s55.887 24.965 55.887 55.762z"/>
|
||||
<path d="m333.07 352.77h33.871v297.37h-33.871z"/>
|
||||
<path d="m412.71 495.07-13.648-30.926c44.27-19.438 72.879-63.152 72.879-111.37 0-67.082-54.699-121.65-121.94-121.65-67.242 0-121.94 54.57-121.94 121.65 0 48.215 28.609 91.93 72.879 111.37l-13.648 30.926c-56.547-24.844-93.094-80.695-93.094-142.3 0-85.715 69.887-155.44 155.8-155.44 85.918 0 155.8 69.727 155.8 155.44-0.003906 61.594-36.551 117.46-93.094 142.3z"/>
|
||||
<path d="m410.17 581.6-8.5742-32.691c89.277-23.309 151.63-103.96 151.63-196.15 0-111.8-91.168-202.75-203.22-202.75-112.06 0.003907-203.23 90.961-203.23 202.77 0 92.184 62.348 172.83 151.63 196.15l-8.5742 32.691c-104.18-27.195-176.93-121.3-176.93-228.83 0-130.43 106.36-236.54 237.1-236.54 130.73 0 237.1 106.12 237.1 236.54-0.003906 107.52-72.754 201.62-176.93 228.82z"/>
|
||||
<path d="m409.05 661.5-6.3125-33.199c132.34-25.047 228.39-140.93 228.39-275.53 0-154.66-126.12-280.48-281.13-280.48-155 0-281.13 125.82-281.13 280.48 0 134.6 96.055 250.48 228.39 275.53l-6.3164 33.199c-148.3-28.07-255.95-157.91-255.95-308.73 0-173.29 141.32-314.27 315-314.27s315 140.98 315 314.27c0 150.81-107.65 280.66-255.95 308.73z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/public/glyph.xcf
Normal file
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
21
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 46 KiB |
1317
frontend/src/App.tsx
@@ -4,9 +4,6 @@ import type {
|
||||
Channel,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
MaintenanceResult,
|
||||
@@ -15,15 +12,7 @@ import type {
|
||||
MigratePreferencesResponse,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RepeaterAclResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
StatisticsResponse,
|
||||
TelemetryResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
} from './types';
|
||||
@@ -31,11 +20,10 @@ import type {
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const hasBody = options?.body !== undefined;
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(hasBody && { 'Content-Type': 'application/json' }),
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
@@ -104,14 +92,6 @@ export const api = {
|
||||
// Contacts
|
||||
getContacts: (limit = 100, offset = 0) =>
|
||||
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
|
||||
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAdvertPaths: (publicKey: string, limit = 10) =>
|
||||
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
getContactDetail: (publicKey: string) =>
|
||||
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
@@ -125,6 +105,11 @@ export const api = {
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
requestTelemetry: (publicKey: string, password: string) =>
|
||||
fetchJson<TelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
sendRepeaterCommand: (publicKey: string, command: string) =>
|
||||
fetchJson<CommandResponse>(`/contacts/${publicKey}/command`, {
|
||||
method: 'POST',
|
||||
@@ -134,10 +119,6 @@ export const api = {
|
||||
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
resetContactPath: (publicKey: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/reset-path`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Channels
|
||||
getChannels: () => fetchJson<Channel[]>('/channels'),
|
||||
@@ -166,8 +147,8 @@ export const api = {
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.limit !== undefined) searchParams.set('limit', params.limit.toString());
|
||||
if (params?.offset !== undefined) searchParams.set('offset', params.offset.toString());
|
||||
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);
|
||||
if (params?.before !== undefined) searchParams.set('before', params.before.toString());
|
||||
@@ -185,11 +166,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
resendChannelMessage: (messageId: number, newTimestamp?: boolean) =>
|
||||
fetchJson<{ status: string; message_id: number }>(
|
||||
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Packets
|
||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
@@ -202,21 +178,17 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
runMaintenance: (options: { pruneUndecryptedDays?: number; purgeLinkedRawPackets?: boolean }) =>
|
||||
runMaintenance: (pruneUndecryptedDays: number) =>
|
||||
fetchJson<MaintenanceResult>('/packets/maintenance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...(options.pruneUndecryptedDays !== undefined && {
|
||||
prune_undecrypted_days: options.pruneUndecryptedDays,
|
||||
}),
|
||||
...(options.purgeLinkedRawPackets !== undefined && {
|
||||
purge_linked_raw_packets: options.purgeLinkedRawPackets,
|
||||
}),
|
||||
}),
|
||||
body: JSON.stringify({ prune_undecrypted_days: pruneUndecryptedDays }),
|
||||
}),
|
||||
|
||||
// Read State
|
||||
getUnreads: () => fetchJson<UnreadCounts>('/read-state/unreads'),
|
||||
getUnreads: (name?: string) => {
|
||||
const params = name ? `?name=${encodeURIComponent(name)}` : '';
|
||||
return fetchJson<UnreadCounts>(`/read-state/unreads${params}`);
|
||||
},
|
||||
markAllRead: () =>
|
||||
fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', {
|
||||
method: 'POST',
|
||||
@@ -243,42 +215,4 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
|
||||
// Statistics
|
||||
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
||||
|
||||
// Granular repeater endpoints
|
||||
repeaterLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/repeater/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
repeaterStatus: (publicKey: string) =>
|
||||
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterNeighbors: (publicKey: string) =>
|
||||
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterAcl: (publicKey: string) =>
|
||||
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterRadioSettings: (publicKey: string) =>
|
||||
fetchJson<RepeaterRadioSettingsResponse>(`/contacts/${publicKey}/repeater/radio-settings`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterAdvertIntervals: (publicKey: string) =>
|
||||
fetchJson<RepeaterAdvertIntervalsResponse>(`/contacts/${publicKey}/repeater/advert-intervals`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterOwnerInfo: (publicKey: string) =>
|
||||
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterLppTelemetry: (publicKey: string) =>
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# PacketVisualizer3D Architecture
|
||||
# PacketVisualizer Architecture
|
||||
|
||||
This document explains the architecture and design of the `PacketVisualizer3D` component, which renders a real-time 3D force-directed graph visualization of mesh network packet traffic using Three.js and `d3-force-3d`.
|
||||
This document explains the architecture and design of the PacketVisualizer component, which renders a real-time force-directed graph visualization of mesh network packet traffic.
|
||||
|
||||
## Overview
|
||||
|
||||
The visualizer displays:
|
||||
The PacketVisualizer displays:
|
||||
|
||||
- **Nodes**: Network participants (self, repeaters, clients) as colored spheres
|
||||
- **Nodes**: Network participants (self, repeaters, clients)
|
||||
- **Links**: Connections between nodes based on observed packet paths
|
||||
- **Particles**: Animated colored dots traveling along links representing packets in transit
|
||||
- **Particles**: Animated dots traveling along links representing packets in transit
|
||||
|
||||
## Architecture
|
||||
## Architecture: Data Layer vs Rendering Layer
|
||||
|
||||
### Data Layer (`useVisualizerData3D` hook)
|
||||
The component is split into two distinct layers to enable future rendering engine swaps (e.g., WebGL, Three.js):
|
||||
|
||||
### Data Layer (`useVisualizerData` hook)
|
||||
|
||||
The custom hook manages all graph state and simulation logic:
|
||||
|
||||
@@ -23,7 +25,7 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An
|
||||
**Key responsibilities:**
|
||||
|
||||
- Maintains node and link maps (`nodesRef`, `linksRef`)
|
||||
- Runs `d3-force-3d` simulation for 3D layout (`.numDimensions(3)`)
|
||||
- Runs D3 force simulation for layout
|
||||
- Processes incoming packets with deduplication
|
||||
- Aggregates packet repeats across multiple paths
|
||||
- Manages particle queue and animation timing
|
||||
@@ -33,32 +35,19 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An
|
||||
- `nodesRef`: Map of node ID → GraphNode
|
||||
- `linksRef`: Map of link key → GraphLink
|
||||
- `particlesRef`: Array of active Particle objects
|
||||
- `simulationRef`: d3-force-3d simulation instance
|
||||
- `simulationRef`: D3 force simulation instance
|
||||
- `pendingRef`: Packets in observation window awaiting animation
|
||||
- `timersRef`: Per-packet publish timers
|
||||
|
||||
### Rendering Layer (Three.js)
|
||||
### Rendering Layer (canvas drawing functions)
|
||||
|
||||
- `THREE.WebGLRenderer` + `CSS2DRenderer` (text labels overlaid on 3D scene)
|
||||
- `OrbitControls` for camera interaction (orbit, pan, zoom)
|
||||
- `THREE.Mesh` with `SphereGeometry` per node + `CSS2DObject` labels
|
||||
- `THREE.LineSegments` for links (persistent geometries; position buffers updated in-place each frame)
|
||||
- `THREE.Points` with vertex colors for particles (persistent geometry + circular sprite texture)
|
||||
- `THREE.Raycaster` for hover/click detection on node spheres
|
||||
Separate pure functions handle all canvas rendering:
|
||||
|
||||
### Shared Utilities (`utils/visualizerUtils.ts`)
|
||||
- `renderLinks()`: Draws connections between nodes
|
||||
- `renderParticles()`: Draws animated packets with labels
|
||||
- `renderNodes()`: Draws node circles with emojis/text
|
||||
|
||||
Types, constants, and pure functions shared across the codebase:
|
||||
|
||||
- Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis`
|
||||
- Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS`
|
||||
- Functions: `hashString` (from `utils/contactAvatar.ts`), `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation`
|
||||
|
||||
`GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`.
|
||||
|
||||
### Type Declarations (`types/d3-force-3d.d.ts`)
|
||||
|
||||
Minimal `.d.ts` file for `d3-force-3d` which has no bundled TypeScript types. Declares `SimulationNodeDatum3D` extending `SimulationNodeDatum` with `z`/`vz`/`fz` properties.
|
||||
The main component orchestrates rendering via `requestAnimationFrame`.
|
||||
|
||||
## Packet Processing Pipeline
|
||||
|
||||
@@ -81,14 +70,12 @@ packets.forEach((packet) => {
|
||||
|
||||
Packets are grouped by a unique key to aggregate repeats:
|
||||
|
||||
| Packet Type | Key Format |
|
||||
| -------------- | ------------------------------------------------------ |
|
||||
| Advertisement | `ad:{pubkey_prefix_12}` |
|
||||
| Group Text | `gt:{channel}:{sender}:{message_hash_or_data_hash}` |
|
||||
| Direct Message | `dm:{src_hash}:{dst_hash}:{message_hash_or_data_hash}` |
|
||||
| Other | `other:{message_hash_or_data_hash}` |
|
||||
|
||||
`parsePacket()` exposes decoder `messageHash` (path-insensitive). `generatePacketKey()` prefers that hash, falling back to a local data hash for malformed/unsupported packets.
|
||||
| Packet Type | Key Format |
|
||||
| -------------- | ----------------------------------------- |
|
||||
| Advertisement | `ad:{pubkey_prefix_12}` |
|
||||
| Group Text | `gt:{channel}:{sender}:{content_hash}` |
|
||||
| Direct Message | `dm:{src_hash}:{dst_hash}:{content_hash}` |
|
||||
| Other | `other:{data_hash}` |
|
||||
|
||||
### 3. Observation Window
|
||||
|
||||
@@ -99,7 +86,7 @@ if (existing && now < existing.expiresAt) {
|
||||
// Append path to existing entry
|
||||
existing.paths.push({ nodes: path, snr: packet.snr, timestamp: now });
|
||||
} else {
|
||||
// Create new pending entry with observation window
|
||||
// Create new pending entry with 2-second observation window
|
||||
pendingPacketsRef.current.set(key, {
|
||||
key,
|
||||
label,
|
||||
@@ -133,28 +120,25 @@ function publishPacket(pending: PendingPacket) {
|
||||
|
||||
**Key insight:** Particles start with negative progress. This creates smooth flow through multi-hop paths without pausing at intermediate nodes.
|
||||
|
||||
## D3 Force Simulation (3D)
|
||||
## D3 Force Simulation
|
||||
|
||||
The layout uses `d3-force-3d` with `.numDimensions(3)`:
|
||||
The layout uses D3's force simulation with these forces:
|
||||
|
||||
| Force | Purpose |
|
||||
| ------------------- | ---------------------------------------------- |
|
||||
| `link` | Pulls connected nodes together (distance: 120) |
|
||||
| `charge` | Repels nodes (self node 6x stronger, max: 800) |
|
||||
| `center` | Gently pulls graph toward origin (0, 0, 0) |
|
||||
| `selfX/selfY/selfZ` | Anchors self node near origin |
|
||||
| Force | Purpose |
|
||||
| ------------- | ---------------------------------------------------- |
|
||||
| `link` | Pulls connected nodes together |
|
||||
| `charge` | Repels nodes from each other (self node 6x stronger) |
|
||||
| `center` | Gently pulls graph toward center |
|
||||
| `collide` | Prevents node overlap |
|
||||
| `selfX/selfY` | Anchors self node near center |
|
||||
|
||||
### Shuffle Layout
|
||||
|
||||
The "Shuffle layout" button randomizes all node positions in a 3D sphere (radius 200, except self stays at origin) and reheats the simulation to alpha=1.
|
||||
The "Shuffle layout" button randomizes all node positions (except self, which stays centered) and reheats the simulation to alpha=1. This lets users try different random starting configurations to find a cleaner layout.
|
||||
|
||||
### Continuous Drift
|
||||
|
||||
When "Let 'em drift" is enabled, `alphaTarget(0.05)` keeps the simulation running indefinitely, allowing the graph to continuously reorganize.
|
||||
|
||||
### Expand/Contract ("Oooh Big Stretch!")
|
||||
|
||||
Temporarily increases repulsion to push nodes apart, then relaxes back. Useful for untangling dense graphs.
|
||||
When "Let 'em drift" is enabled, `alphaTarget(0.05)` keeps the simulation running indefinitely, allowing the graph to continuously reorganize into better layouts.
|
||||
|
||||
## Node Resolution
|
||||
|
||||
@@ -163,7 +147,7 @@ Nodes are resolved from various sources:
|
||||
```typescript
|
||||
function resolveNode(source, isRepeater, showAmbiguous): string | null {
|
||||
// source.type can be: 'pubkey', 'prefix', or 'name'
|
||||
// Use precomputed contact indexes (by 12-char prefix, by name, by shorter prefixes)
|
||||
// Try to find matching contact
|
||||
// If found: use full 12-char prefix as node ID
|
||||
// If not found and showAmbiguous: create "?prefix" node
|
||||
// Otherwise: return null (path terminates)
|
||||
@@ -172,24 +156,7 @@ function resolveNode(source, isRepeater, showAmbiguous): string | null {
|
||||
|
||||
### Ambiguous Nodes
|
||||
|
||||
When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity.
|
||||
|
||||
### Advert-Path Identity Hints
|
||||
|
||||
**Problem:** When multiple repeaters share a 1-byte prefix, the visualizer can't tell which physical repeater a path hop refers to.
|
||||
|
||||
**Solution:** The backend tracks recent unique advertisement paths per contact in `contact_advert_paths` (see root `AGENTS.md` § "Contact Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix.
|
||||
|
||||
**Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop prefix. It requires:
|
||||
|
||||
- At least 2 matching observations (stronger-than-trivial evidence)
|
||||
- The top candidate's match score must be at least 2x the runner-up's
|
||||
|
||||
When a winner is found, the ambiguous node gets a `probableIdentity` label (the likely repeater's name) and the display name updates accordingly. The remaining candidates are listed as "Other possible" in the tooltip.
|
||||
|
||||
**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name.
|
||||
|
||||
**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden).
|
||||
When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling.
|
||||
|
||||
### Traffic Pattern Splitting (Experimental)
|
||||
|
||||
@@ -231,6 +198,7 @@ Here source `ae` routes through `32` to BOTH `ba` and `60`. This proves `32` is
|
||||
- Source sets are completely disjoint (no source appears in multiple groups)
|
||||
- Each group has at least 20 unique sources (conservative threshold)
|
||||
4. **Final repeaters** (no next hop, connects directly to self): Never split
|
||||
- Rationale: The last repeater before you is clearly a single physical node
|
||||
|
||||
**Node ID format:**
|
||||
|
||||
@@ -238,6 +206,12 @@ Here source `ae` routes through `32` to BOTH `ba` and `60`. This proves `32` is
|
||||
- With splitting (after evidence threshold met): `?XX:>YY` (e.g., `?32:>ba`)
|
||||
- Final repeater: `?XX` (unchanged, no suffix)
|
||||
|
||||
**Implementation Notes:**
|
||||
|
||||
- Observations are stored with timestamps and pruned after 30 minutes
|
||||
- Maximum 200 observations per prefix to limit memory
|
||||
- Once split, nodes cannot be un-split (be conservative before splitting)
|
||||
|
||||
## Path Building
|
||||
|
||||
Paths are constructed from packet data:
|
||||
@@ -269,6 +243,8 @@ function buildPath(parsed, packet, myPrefix): string[] {
|
||||
|
||||
### Sender Extraction by Packet Type
|
||||
|
||||
Different packet types provide different levels of sender identification:
|
||||
|
||||
| Packet Type | Sender Info Available | Resolution |
|
||||
| -------------- | ------------------------------ | ------------------------------ |
|
||||
| Advertisement | Full 32-byte public key | Exact contact match |
|
||||
@@ -278,86 +254,106 @@ function buildPath(parsed, packet, myPrefix): string[] {
|
||||
| Request | 1-byte source hash | Ambiguous |
|
||||
| Other | None | Path bytes only |
|
||||
|
||||
## Node Colors
|
||||
**AnonRequest packets** are particularly useful because they include the sender's full public key (unlike regular Request packets which only have a 1-byte hash). This allows exact identification of who is making the request.
|
||||
|
||||
| Color | Hex | Meaning |
|
||||
| ------- | --------- | ---------------------------------------------- |
|
||||
| Green | `#22c55e` | Self node (larger sphere) |
|
||||
| Blue | `#3b82f6` | Repeater |
|
||||
| White | `#ffffff` | Client |
|
||||
| Gray | `#9ca3af` | Unknown/ambiguous (not identified as repeater) |
|
||||
| Gold | `#ffd700` | Active (hovered or pinned) node |
|
||||
| Lt Gold | `#fff0b3` | Neighbors of active node |
|
||||
## Canvas Rendering
|
||||
|
||||
### Coordinate Transformation
|
||||
|
||||
Pan and zoom are applied via transform matrix:
|
||||
|
||||
```typescript
|
||||
ctx.setTransform(dpr * scale, 0, 0, dpr * scale, dpr * (x + panX), dpr * (y + panY));
|
||||
```
|
||||
|
||||
### Render Order
|
||||
|
||||
1. Clear canvas with background
|
||||
2. Draw links (gray lines)
|
||||
3. Draw particles (colored dots with labels)
|
||||
4. Draw nodes (circles with emojis)
|
||||
5. Draw hover tooltip if applicable
|
||||
|
||||
## Mouse Interactions
|
||||
|
||||
| Action | Behavior |
|
||||
| -------------------------- | ------------------------------------------- |
|
||||
| Left-click on node | Pin: highlight node + links + neighbors |
|
||||
| Left-click pinned node | Unpin: remove highlights |
|
||||
| Left-click empty space | Unpin any pinned node |
|
||||
| Hover over node | Shows tooltip with node details + neighbors |
|
||||
| Orbit (left-drag on space) | Rotate camera around scene |
|
||||
| Pan (right-drag) | Pan the camera |
|
||||
| Scroll wheel | Zoom in/out |
|
||||
| Action | Behavior |
|
||||
| -------------------------- | ------------------------------------------------ |
|
||||
| Click + drag on node | Move node to new position (temporarily fixes it) |
|
||||
| Release dragged node | Node returns to force-directed layout |
|
||||
| Click + drag on empty area | Pan the canvas |
|
||||
| Scroll wheel | Zoom in/out |
|
||||
| Hover over node | Shows node details, cursor changes to pointer |
|
||||
|
||||
**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" listing all connected neighbors with their possible names.
|
||||
**Node Dragging Implementation:**
|
||||
|
||||
- On mouse down over a node, sets `fx`/`fy` (D3 fixed position) to lock it
|
||||
- On mouse move, updates the fixed position to follow cursor
|
||||
- On mouse up, clears `fx`/`fy` so node rejoins the simulation
|
||||
- Simulation is slightly reheated during drag for responsive feedback
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | ------- | --------------------------------------------------------- |
|
||||
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
||||
| Ambiguous path repeaters | On | Show nodes when only partial prefix known |
|
||||
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
||||
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
|
||||
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
||||
| Hide repeaters >48hrs | Off | Filter out old repeaters |
|
||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||
| Let 'em drift | On | Continuous layout optimization |
|
||||
| Repulsion | 200 | Force strength (50-2500) |
|
||||
| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
|
||||
| Shuffle layout | - | Button to randomize node positions and reheat sim |
|
||||
| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
|
||||
| Clear & Reset | - | Button to clear all nodes, links, and packets |
|
||||
| Hide UI | Off | Hide legends and most controls for cleaner view |
|
||||
| Full screen | Off | Hide the packet feed panel (desktop only) |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
PacketVisualizer3D.tsx
|
||||
├── TYPES (GraphNode extends SimulationNodeDatum3D, GraphLink)
|
||||
├── CONSTANTS (NODE_COLORS, NODE_LEGEND_ITEMS)
|
||||
├── DATA LAYER HOOK (useVisualizerData3D)
|
||||
│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns, stretchRaf)
|
||||
│ ├── d3-force-3d simulation initialization (.numDimensions(3))
|
||||
│ ├── Contact indexing (byPrefix12 / byName / byPrefix)
|
||||
PacketVisualizer.tsx
|
||||
├── TYPES (GraphNode, GraphLink, Particle, etc.)
|
||||
├── CONSTANTS (colors, timing, legend items)
|
||||
├── UTILITY FUNCTIONS
|
||||
│ ├── simpleHash()
|
||||
│ ├── parsePacket()
|
||||
│ ├── getPacketLabel()
|
||||
│ ├── generatePacketKey()
|
||||
│ ├── findContactBy*()
|
||||
│ ├── dedupeConsecutive()
|
||||
│ ├── analyzeRepeaterTraffic()
|
||||
│ └── recordTrafficObservation()
|
||||
├── DATA LAYER HOOK (useVisualizerData)
|
||||
│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns)
|
||||
│ ├── Simulation initialization
|
||||
│ ├── Node/link management (addNode, addLink, syncSimulation)
|
||||
│ ├── Path building (resolveNode, buildPath)
|
||||
│ ├── Traffic pattern analysis (for repeater disambiguation)
|
||||
│ └── Packet processing & publishing
|
||||
└── MAIN COMPONENT (PacketVisualizer3D)
|
||||
├── Three.js scene setup (WebGLRenderer, CSS2DRenderer, OrbitControls)
|
||||
├── Node mesh management (SphereGeometry + CSS2DObject labels)
|
||||
├── Link rendering (persistent LineSegments + dynamic position buffer updates)
|
||||
├── Particle rendering (persistent Points + dynamic position/color buffer updates)
|
||||
├── Raycasting (hover detection, click-to-pin)
|
||||
├── State (options, pinned/hovered node, neighbors; change-detected UI updates)
|
||||
└── JSX (container, legend overlay, options panel, tooltip)
|
||||
|
||||
utils/visualizerUtils.ts
|
||||
├── Types (NodeType, PacketLabel, Particle, PendingPacket, ParsedPacket, etc.)
|
||||
├── Constants (COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, PACKET_LEGEND_ITEMS)
|
||||
└── Functions (parsePacket, generatePacketKey, analyzeRepeaterTraffic, etc.)
|
||||
|
||||
types/d3-force-3d.d.ts
|
||||
└── Type declarations for d3-force-3d (SimulationNodeDatum3D, Simulation3D, forces)
|
||||
├── RENDERING FUNCTIONS
|
||||
│ ├── renderLinks()
|
||||
│ ├── renderParticles()
|
||||
│ └── renderNodes()
|
||||
└── MAIN COMPONENT (PacketVisualizer)
|
||||
├── State (dimensions, options, transform, hover)
|
||||
├── Event handlers (mouse, wheel)
|
||||
├── Animation loop
|
||||
└── JSX (canvas, legend, settings panel)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Observation window**: Configurable (default 15s) to balance latency vs. path aggregation
|
||||
- **Persistent geometries**: Links/highlights/particles are created once; buffers are updated per frame to reduce GC/GPU churn
|
||||
- **Observation window**: 2 seconds balances latency vs. path aggregation
|
||||
- **Max links**: Capped at 100 to prevent graph explosion
|
||||
- **Particle culling**: Particles removed when progress > 1
|
||||
- **Change-detected React updates**: Hover + neighbor UI state updates only when values change
|
||||
- **Node filtering**: Old repeaters can be hidden to reduce clutter
|
||||
- **requestAnimationFrame**: Render loop tied to display refresh rate
|
||||
- **CSS2DRenderer z-index**: Set to 1 so UI overlays (z-10) render above node labels
|
||||
|
||||
## Future Improvements
|
||||
|
||||
The data/rendering split enables:
|
||||
|
||||
- WebGL rendering for larger graphs
|
||||
- 3D visualization
|
||||
- Different layout algorithms
|
||||
- Export to other formats
|
||||
|
||||
@@ -6,10 +6,9 @@ interface BotCodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
id?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function BotCodeEditor({ value, onChange, id, height = '256px' }: BotCodeEditorProps) {
|
||||
export function BotCodeEditor({ value, onChange, id }: BotCodeEditorProps) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-input">
|
||||
<CodeMirror
|
||||
@@ -17,7 +16,7 @@ export function BotCodeEditor({ value, onChange, id, height = '256px' }: BotCode
|
||||
onChange={onChange}
|
||||
extensions={[python()]}
|
||||
theme={oneDark}
|
||||
height={height}
|
||||
height="256px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import type React from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
onTrace: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
conversation,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onTrace,
|
||||
onToggleFavorite,
|
||||
onDeleteChannel,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
title="View contact info"
|
||||
>
|
||||
<ContactAvatar
|
||||
name={conversation.name}
|
||||
publicKey={conversation.id}
|
||||
size={28}
|
||||
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
|
||||
clickable
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
onClick={
|
||||
conversation.type === 'contact' && onOpenContactInfo
|
||||
? () => onOpenContactInfo(conversation.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
conversation.name !== 'Public'
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
||||
</span>
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (contact.last_seen) {
|
||||
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
|
||||
}
|
||||
if (contact.last_path_len === -1) {
|
||||
parts.push('flood');
|
||||
} else if (contact.last_path_len === 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
direct
|
||||
</span>
|
||||
);
|
||||
} else if (contact.last_path_len > 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isValidLocation(contact.lat, contact.lon)) {
|
||||
const distFromUs =
|
||||
config && isValidLocation(config.lat, config.lon)
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
parts.push(
|
||||
<span key="coords">
|
||||
<span
|
||||
className="font-mono cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const url =
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
getMapFocusHash(contact.public_key);
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
title="View on map"
|
||||
>
|
||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||
</span>
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return parts.length > 0 ? (
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{/* Direct trace button (contacts only) */}
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
>
|
||||
🛎
|
||||
</button>
|
||||
)}
|
||||
{/* Favorite button */}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
onClick={() =>
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
title={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
<span className="text-amber-400">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Delete button */}
|
||||
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
|
||||
onClick={() => {
|
||||
if (conversation.type === 'channel') {
|
||||
onDeleteChannel(conversation.id);
|
||||
} else {
|
||||
onDeleteContact(conversation.id);
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,14 @@ interface ContactAvatarProps {
|
||||
publicKey: string;
|
||||
size?: number;
|
||||
contactType?: number;
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
export function ContactAvatar({
|
||||
name,
|
||||
publicKey,
|
||||
size = 28,
|
||||
contactType,
|
||||
clickable,
|
||||
}: ContactAvatarProps) {
|
||||
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${clickable ? ' cursor-pointer' : ''}`}
|
||||
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
|
||||
style={{
|
||||
backgroundColor: avatar.background,
|
||||
color: avatar.textColor,
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
0: 'Unknown',
|
||||
1: 'Client',
|
||||
2: 'Repeater',
|
||||
3: 'Room',
|
||||
4: 'Sensor',
|
||||
};
|
||||
|
||||
interface ContactInfoPaneProps {
|
||||
contactKey: string | null;
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
}
|
||||
|
||||
export function ContactInfoPane({
|
||||
contactKey,
|
||||
onClose,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
}: ContactInfoPaneProps) {
|
||||
const [detail, setDetail] = useState<ContactDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get live contact data from contacts array (real-time via WS)
|
||||
const liveContact = contactKey
|
||||
? (contacts.find((c) => c.public_key === contactKey) ?? null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactKey) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api
|
||||
.getContactDetail(contactKey)
|
||||
.then((data) => {
|
||||
if (!cancelled) setDetail(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to fetch contact detail:', err);
|
||||
toast.error('Failed to load contact info');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contactKey]);
|
||||
|
||||
// Use live contact data where available, fall back to detail snapshot
|
||||
const contact = liveContact ?? detail?.contact ?? null;
|
||||
|
||||
const distFromUs =
|
||||
contact &&
|
||||
config &&
|
||||
isValidLocation(config.lat, config.lon) &&
|
||||
isValidLocation(contact.lat, contact.lon)
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Contact Info</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !detail ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : contact ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<div className="flex items-start gap-4">
|
||||
<ContactAvatar
|
||||
name={contact.name}
|
||||
publicKey={contact.public_key}
|
||||
size={56}
|
||||
contactType={contact.type}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(contact.public_key);
|
||||
toast.success('Public key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{contact.public_key}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||
</span>
|
||||
{contact.on_radio && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
{contact.last_seen && (
|
||||
<InfoItem label="Last Seen" value={formatTime(contact.last_seen)} />
|
||||
)}
|
||||
{contact.first_seen && (
|
||||
<InfoItem label="First Heard" value={formatTime(contact.first_seen)} />
|
||||
)}
|
||||
{contact.last_contacted && (
|
||||
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
||||
)}
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
)}
|
||||
{contact.last_path_len >= 0 && (
|
||||
<InfoItem
|
||||
label="Hops"
|
||||
value={
|
||||
contact.last_path_len === 0
|
||||
? 'Direct'
|
||||
: `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{contact.last_path_len === -1 && <InfoItem label="Routing" value="Flood" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPS */}
|
||||
{isValidLocation(contact.lat, contact.lon) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Location</SectionLabel>
|
||||
<span
|
||||
className="text-sm font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
|
||||
onClick={() => {
|
||||
const url =
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
getMapFocusHash(contact.public_key);
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
title="View on map"
|
||||
>
|
||||
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorite toggle */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
<>
|
||||
<span className="text-amber-400 text-lg">★</span>
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Stats */}
|
||||
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{detail.dm_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Direct Messages"
|
||||
value={detail.dm_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
{detail.channel_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Channel Messages"
|
||||
value={detail.channel_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most Active Rooms */}
|
||||
{detail && detail.most_active_rooms.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.most_active_rooms.map((room) => (
|
||||
<div
|
||||
key={room.channel_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{detail && detail.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.nearest_repeaters.map((r) => (
|
||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{r.path_len === 0
|
||||
? 'direct'
|
||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||
· {r.heard_count}x
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advert Paths */}
|
||||
{detail && detail.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.advert_paths.map((p) => (
|
||||
<div
|
||||
key={p.path + p.first_seen}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">
|
||||
{p.path ? p.path.match(/.{2}/g)!.join(' → ') : '(direct)'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Contact not found
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<p className="font-medium text-sm leading-tight">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,6 @@ export function CrackerPanel({
|
||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
||||
const [turboMode, setTurboMode] = useState(false);
|
||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
||||
@@ -95,7 +94,6 @@ export function CrackerPanel({
|
||||
const maxLengthRef = useRef(6);
|
||||
const decryptHistoricalRef = useRef(true);
|
||||
const turboModeRef = useRef(false);
|
||||
const twoWordModeRef = useRef(false);
|
||||
const undecryptedIdsRef = useRef<Set<number>>(new Set());
|
||||
const seenPayloadsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -228,10 +226,6 @@ export function CrackerPanel({
|
||||
turboModeRef.current = turboMode;
|
||||
}, [turboMode]);
|
||||
|
||||
useEffect(() => {
|
||||
twoWordModeRef.current = twoWordMode;
|
||||
}, [twoWordMode]);
|
||||
|
||||
// Keep undecrypted IDs ref in sync - used to skip packets already decrypted by other means
|
||||
useEffect(() => {
|
||||
undecryptedIdsRef.current = new Set(undecryptedGroupText.map((p) => p.id));
|
||||
@@ -317,14 +311,9 @@ export function CrackerPanel({
|
||||
useSenderFilter: true,
|
||||
useTimestampFilter: true,
|
||||
useUtf8Filter: true,
|
||||
useTwoWordCombinations: twoWordModeRef.current,
|
||||
...(turboModeRef.current && { gpuDispatchMs: 10000 }),
|
||||
// For retries, skip dictionary and shorter lengths - we already checked those
|
||||
...(isRetry && {
|
||||
useDictionary: false,
|
||||
useTwoWordCombinations: false,
|
||||
startingLength: targetLength,
|
||||
}),
|
||||
...(isRetry && { useDictionary: false, startingLength: targetLength }),
|
||||
},
|
||||
(prog) => {
|
||||
setProgress(prog);
|
||||
@@ -455,11 +444,8 @@ export function CrackerPanel({
|
||||
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border overflow-auto">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="cracker-max-length" className="text-sm text-muted-foreground">
|
||||
Max Length:
|
||||
</label>
|
||||
<label className="text-sm text-muted-foreground">Max Length:</label>
|
||||
<input
|
||||
id="cracker-max-length"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
@@ -496,16 +482,6 @@ export function CrackerPanel({
|
||||
</span>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twoWordMode}
|
||||
onChange={(e) => setTwoWordMode(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Try word pairs
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -563,11 +539,9 @@ export function CrackerPanel({
|
||||
<span>
|
||||
{progress.phase === 'wordlist'
|
||||
? 'Dictionary'
|
||||
: progress.phase === 'wordlist-pairs'
|
||||
? 'Word Pairs'
|
||||
: progress.phase === 'bruteforce'
|
||||
? 'Bruteforce'
|
||||
: 'Public Key'}
|
||||
: progress.phase === 'bruteforce'
|
||||
? 'Bruteforce'
|
||||
: 'Public Key'}
|
||||
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}:{' '}
|
||||
{progress.currentPosition}
|
||||
</span>
|
||||
@@ -602,7 +576,7 @@ export function CrackerPanel({
|
||||
|
||||
{/* Cracked rooms list */}
|
||||
{crackedRooms.length > 0 && (
|
||||
<div>
|
||||
<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) => (
|
||||
@@ -629,9 +603,6 @@ export function CrackerPanel({
|
||||
way of knowing but try as if they are).
|
||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time.
|
||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
||||
if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
interface MapViewProps {
|
||||
@@ -98,7 +97,7 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
const mappableContacts = useMemo(() => {
|
||||
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
|
||||
return contacts.filter(
|
||||
(c) => isValidLocation(c.lat, c.lon) && c.last_seen != null && c.last_seen > sevenDaysAgo
|
||||
(c) => c.lat != null && c.lon != null && c.last_seen != null && c.last_seen > sevenDaysAgo
|
||||
);
|
||||
}, [contacts]);
|
||||
|
||||
|
||||
@@ -16,23 +16,18 @@ import { cn } from '@/lib/utils';
|
||||
// MeshCore message size limits (empirically determined from LoRa packet constraints)
|
||||
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
|
||||
// Channels include "sender: " prefix in the encrypted payload.
|
||||
// All limits are in bytes (UTF-8), not characters, since LoRa packets are byte-constrained.
|
||||
const DM_HARD_LIMIT = 156; // Max bytes for direct delivery
|
||||
const DM_HARD_LIMIT = 156; // Max for direct delivery
|
||||
const DM_WARNING_THRESHOLD = 140; // Conservative for multi-hop
|
||||
const CHANNEL_HARD_LIMIT = 156; // Base byte limit before sender overhead
|
||||
const CHANNEL_HARD_LIMIT = 156; // Base limit before sender overhead
|
||||
const CHANNEL_WARNING_THRESHOLD = 120; // Conservative for multi-hop
|
||||
const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many bytes before hard limit
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
/** Get UTF-8 byte length of a string (LoRa packets are byte-constrained, not character-constrained). */
|
||||
function byteLen(s: string): number {
|
||||
return textEncoder.encode(s).length;
|
||||
}
|
||||
const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many chars before hard limit
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (text: string) => Promise<void>;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
/** When true, input becomes password field for repeater telemetry */
|
||||
isRepeaterMode?: boolean;
|
||||
/** Conversation type for character limit calculation */
|
||||
conversationType?: 'contact' | 'channel' | 'raw';
|
||||
/** Sender name (radio name) for channel message limit calculation */
|
||||
@@ -46,7 +41,7 @@ export interface MessageInputHandle {
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
{ onSend, disabled, placeholder, conversationType, senderName },
|
||||
{ onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName },
|
||||
ref
|
||||
) {
|
||||
const [text, setText] = useState('');
|
||||
@@ -70,9 +65,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
hardLimit: DM_HARD_LIMIT,
|
||||
};
|
||||
} else if (conversationType === 'channel') {
|
||||
// Channel hard limit = 156 bytes - senderName bytes - 2 (for ": " separator)
|
||||
const nameByteLen = senderName ? byteLen(senderName) : 10;
|
||||
const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameByteLen - 2);
|
||||
// Channel hard limit = 156 - senderName.length - 2 (for ": " separator)
|
||||
const nameLen = senderName?.length ?? 10;
|
||||
const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameLen - 2);
|
||||
return {
|
||||
warningAt: CHANNEL_WARNING_THRESHOLD,
|
||||
dangerAt: Math.max(1, hardLimit - CHANNEL_DANGER_BUFFER),
|
||||
@@ -82,9 +77,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
return null; // Raw/other - no limits
|
||||
}, [conversationType, senderName]);
|
||||
|
||||
// UTF-8 byte length of the current text (LoRa packets are byte-constrained)
|
||||
const textByteLen = useMemo(() => byteLen(text), [text]);
|
||||
|
||||
// Determine current limit state
|
||||
const { limitState, warningMessage } = useMemo((): {
|
||||
limitState: LimitState;
|
||||
@@ -92,43 +84,64 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
} => {
|
||||
if (!limits) return { limitState: 'normal', warningMessage: null };
|
||||
|
||||
if (textByteLen >= limits.hardLimit) {
|
||||
const len = text.length;
|
||||
if (len >= limits.hardLimit) {
|
||||
return { limitState: 'error', warningMessage: 'likely truncated by radio' };
|
||||
}
|
||||
if (textByteLen >= limits.dangerAt) {
|
||||
if (len >= limits.dangerAt) {
|
||||
return { limitState: 'danger', warningMessage: 'may impact multi-repeater hop delivery' };
|
||||
}
|
||||
if (textByteLen >= limits.warningAt) {
|
||||
if (len >= limits.warningAt) {
|
||||
return { limitState: 'warning', warningMessage: 'may impact multi-repeater hop delivery' };
|
||||
}
|
||||
return { limitState: 'normal', warningMessage: null };
|
||||
}, [textByteLen, limits]);
|
||||
}, [text.length, limits]);
|
||||
|
||||
const remaining = limits ? limits.hardLimit - textByteLen : 0;
|
||||
const remaining = limits ? limits.hardLimit - text.length : 0;
|
||||
|
||||
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);
|
||||
toast.error('Failed to send message', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
setSending(false);
|
||||
// For repeater mode, empty password means guest login
|
||||
if (isRepeaterMode) {
|
||||
if (sending || disabled) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(trimmed);
|
||||
setText('');
|
||||
} catch (err) {
|
||||
console.error('Failed to request telemetry:', err);
|
||||
toast.error('Failed to request telemetry', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
// Refocus after React re-enables the input (now in CLI command mode)
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
} else {
|
||||
if (!trimmed || sending || disabled) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(trimmed);
|
||||
setText('');
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
toast.error('Failed to send message', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
// Refocus after React re-enables the input
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
// Refocus after React re-enables the input
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
},
|
||||
[text, sending, disabled, onSend]
|
||||
[text, sending, disabled, onSend, isRepeaterMode]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -141,32 +154,26 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const canSubmit = text.trim().length > 0;
|
||||
// For repeater mode, always allow submit (empty = guest login)
|
||||
const canSubmit = isRepeaterMode ? true : text.trim().length > 0;
|
||||
|
||||
// Show counter for messages (not raw).
|
||||
// Desktop: always visible. Mobile: only show count after 100 characters.
|
||||
const showCharCounter = limits !== null;
|
||||
const showMobileCounterValue = text.length > 100;
|
||||
// Show character counter for messages (not repeater mode or raw)
|
||||
const showCharCounter = !isRepeaterMode && limits !== null;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="px-4 py-2.5 border-t border-border flex flex-col gap-1"
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<form className="px-4 py-3 border-t border-border flex flex-col gap-1" onSubmit={handleSubmit}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
name="chat-message-input"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
type={isRepeaterMode ? 'password' : 'text'}
|
||||
autoComplete={isRepeaterMode ? 'off' : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || 'Type a message...'}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
(isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...')
|
||||
}
|
||||
disabled={disabled || sending}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
@@ -175,57 +182,38 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
disabled={disabled || sending || !canSubmit}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
{sending
|
||||
? isRepeaterMode
|
||||
? 'Logging in...'
|
||||
: 'Sending...'
|
||||
: isRepeaterMode
|
||||
? text.trim()
|
||||
? 'Log in with password'
|
||||
: 'Log in as guest/use repeater ACLs'
|
||||
: 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
{showCharCounter && (
|
||||
<>
|
||||
<div className="hidden sm:flex items-center justify-end gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
limitState === 'error' || limitState === 'danger'
|
||||
? 'text-red-500 font-medium'
|
||||
: limitState === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{textByteLen}/{limits!.hardLimit}
|
||||
{remaining < 0 && ` (${remaining})`}
|
||||
</span>
|
||||
{warningMessage && (
|
||||
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
|
||||
— {warningMessage}
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
limitState === 'error' || limitState === 'danger'
|
||||
? 'text-red-500 font-medium'
|
||||
: limitState === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(showMobileCounterValue || warningMessage) && (
|
||||
<div className="flex sm:hidden items-center justify-end gap-2 text-xs">
|
||||
{showMobileCounterValue && (
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
limitState === 'error' || limitState === 'danger'
|
||||
? 'text-red-500 font-medium'
|
||||
: limitState === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{textByteLen}/{limits!.hardLimit}
|
||||
{remaining < 0 && ` (${remaining})`}
|
||||
</span>
|
||||
)}
|
||||
{warningMessage && (
|
||||
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
|
||||
— {warningMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{text.length}/{limits!.hardLimit}
|
||||
{remaining < 0 && ` (${remaining})`}
|
||||
</span>
|
||||
{warningMessage && (
|
||||
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
|
||||
— {warningMessage}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -23,10 +23,8 @@ interface MessageListProps {
|
||||
hasOlderMessages?: boolean;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
}
|
||||
|
||||
// URL regex for linkifying plain text
|
||||
@@ -119,8 +117,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const className =
|
||||
variant === 'header'
|
||||
? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
? 'font-normal text-muted-foreground/70 ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[10px] text-muted-foreground/50 ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -136,8 +134,6 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
@@ -146,10 +142,8 @@ export function MessageList({
|
||||
hasOlderMessages = false,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
radioName,
|
||||
config,
|
||||
onOpenContactInfo,
|
||||
}: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
@@ -158,11 +152,7 @@ export function MessageList({
|
||||
const [selectedPath, setSelectedPath] = useState<{
|
||||
paths: MessagePath[];
|
||||
senderInfo: SenderInfo;
|
||||
messageId?: number;
|
||||
isOutgoingChan?: boolean;
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
@@ -173,9 +163,6 @@ export function MessageList({
|
||||
wasNearBottom: true, // Default to true so initial messages scroll to bottom
|
||||
});
|
||||
|
||||
// Track conversation key to detect when entire message set changes
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Handle scroll position AFTER render
|
||||
useLayoutEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
@@ -183,15 +170,8 @@ export function MessageList({
|
||||
const list = listRef.current;
|
||||
const messagesAdded = messages.length - prevMessagesLengthRef.current;
|
||||
|
||||
// Detect if messages are from a different conversation (handles the case where
|
||||
// the key prop remount consumes isInitialLoadRef on stale data from the previous
|
||||
// conversation before the cache restore effect sets the correct messages)
|
||||
const convKey = messages.length > 0 ? messages[0].conversation_key : null;
|
||||
const conversationChanged = convKey !== null && convKey !== prevConvKeyRef.current;
|
||||
if (convKey !== null) prevConvKeyRef.current = convKey;
|
||||
|
||||
if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) {
|
||||
// Initial load or conversation switch - scroll to bottom
|
||||
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) {
|
||||
@@ -215,7 +195,6 @@ export function MessageList({
|
||||
if (messages.length === 0) {
|
||||
isInitialLoadRef.current = true;
|
||||
prevMessagesLengthRef.current = 0;
|
||||
prevConvKeyRef.current = null;
|
||||
scrollStateRef.current = {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
@@ -226,53 +205,7 @@ export function MessageList({
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Track resendable outgoing CHAN messages (within 30s window)
|
||||
useEffect(() => {
|
||||
if (!onResendChannelMessage) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const newResendable = new Set<number>();
|
||||
const timers = resendTimersRef.current;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.outgoing || msg.type !== 'CHAN' || msg.sender_timestamp === null) continue;
|
||||
const remaining = RESEND_WINDOW_SECONDS - (now - msg.sender_timestamp);
|
||||
if (remaining <= 0) continue;
|
||||
|
||||
newResendable.add(msg.id);
|
||||
|
||||
// Schedule removal if not already tracked
|
||||
if (!timers.has(msg.id)) {
|
||||
const timer = setTimeout(() => {
|
||||
setResendableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.id);
|
||||
return next;
|
||||
});
|
||||
timers.delete(msg.id);
|
||||
}, remaining * 1000);
|
||||
timers.set(msg.id, timer);
|
||||
}
|
||||
}
|
||||
|
||||
setResendableIds(newResendable);
|
||||
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
}, [messages, onResendChannelMessage]);
|
||||
|
||||
// Refs for scroll handler to read without causing callback recreation
|
||||
const onLoadOlderRef = useRef(onLoadOlder);
|
||||
const loadingOlderRef = useRef(loadingOlder);
|
||||
const hasOlderMessagesRef = useRef(hasOlderMessages);
|
||||
onLoadOlderRef.current = onLoadOlder;
|
||||
loadingOlderRef.current = loadingOlder;
|
||||
hasOlderMessagesRef.current = hasOlderMessages;
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top/bottom
|
||||
// Stable callback: reads changing values from refs, never recreated.
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
@@ -291,13 +224,13 @@ export function MessageList({
|
||||
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
|
||||
setShowScrollToBottom(distanceFromBottom > 100);
|
||||
|
||||
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) return;
|
||||
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
|
||||
|
||||
// Trigger load when within 100px of top
|
||||
if (scrollTop < 100) {
|
||||
onLoadOlderRef.current();
|
||||
onLoadOlder();
|
||||
}
|
||||
}, []);
|
||||
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -314,21 +247,6 @@ export function MessageList({
|
||||
[messages]
|
||||
);
|
||||
|
||||
// Sender info for outgoing messages (used by path modal on own messages)
|
||||
const selfSenderInfo = useMemo<SenderInfo>(
|
||||
() => ({
|
||||
name: config?.name || 'Unknown',
|
||||
publicKeyOrPrefix: config?.public_key || '',
|
||||
lat: config?.lat ?? null,
|
||||
lon: config?.lon ?? null,
|
||||
}),
|
||||
[config?.name, config?.public_key, config?.lat, config?.lon]
|
||||
);
|
||||
|
||||
// Derive live so the byte-perfect button disables if the 30s window expires while modal is open
|
||||
const isSelectedMessageResendable =
|
||||
selectedPath?.messageId !== undefined && resendableIds.has(selectedPath.messageId);
|
||||
|
||||
// Look up contact by public key
|
||||
const getContact = (conversationKey: string | null): Contact | null => {
|
||||
if (!conversationKey) return null;
|
||||
@@ -436,8 +354,7 @@ export function MessageList({
|
||||
const prevSenderKey = prevMsg
|
||||
? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender)
|
||||
: null;
|
||||
const isFirstInGroup = currentSenderKey !== prevSenderKey;
|
||||
const showAvatar = !msg.outgoing && isFirstInGroup;
|
||||
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
|
||||
const isFirstMessage = index === 0;
|
||||
|
||||
// Get avatar info for incoming messages
|
||||
@@ -462,40 +379,27 @@ export function MessageList({
|
||||
className={cn(
|
||||
'flex items-start max-w-[85%]',
|
||||
msg.outgoing && 'flex-row-reverse self-end',
|
||||
isFirstInGroup && !isFirstMessage && 'mt-3'
|
||||
showAvatar && !isFirstMessage && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<span
|
||||
onClick={
|
||||
onOpenContactInfo && !avatarKey.startsWith('name:')
|
||||
? () => onOpenContactInfo(avatarKey)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable={!!onOpenContactInfo && !avatarKey.startsWith('name:')}
|
||||
/>
|
||||
</span>
|
||||
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'py-1.5 px-3 rounded-lg min-w-0',
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming'
|
||||
msg.outgoing ? 'bg-[#1e3a29]' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
@@ -504,7 +408,7 @@ export function MessageList({
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
@@ -530,7 +434,7 @@ export function MessageList({
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
<span className="text-[10px] text-muted-foreground/50 ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
@@ -547,48 +451,7 @@ export function MessageList({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
title="View echo paths"
|
||||
>{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
title="Message status"
|
||||
>
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
))}
|
||||
{msg.outgoing && (msg.acked > 0 ? ` ✓${msg.acked > 1 ? msg.acked : ''}` : ' ?')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -600,13 +463,13 @@ export function MessageList({
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105"
|
||||
className="absolute bottom-4 right-4 w-10 h-10 rounded-full bg-muted hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-opacity"
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -629,10 +492,6 @@ export function MessageList({
|
||||
senderInfo={selectedPath.senderInfo}
|
||||
contacts={contacts}
|
||||
config={config ?? null}
|
||||
messageId={selectedPath.messageId}
|
||||
isOutgoingChan={selectedPath.isOutgoingChan}
|
||||
isResendable={isSelectedMessageResendable}
|
||||
onResend={onResendChannelMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, Polyline } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface Neighbor {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
name: string | null;
|
||||
pubkey_prefix: string;
|
||||
snr: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
neighbors: Neighbor[];
|
||||
radioLat?: number | null;
|
||||
radioLon?: number | null;
|
||||
radioName?: string | null;
|
||||
}
|
||||
|
||||
export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: Props) {
|
||||
const valid = neighbors.filter(
|
||||
(n): n is Neighbor & { lat: number; lon: number } => n.lat != null && n.lon != null
|
||||
);
|
||||
|
||||
const hasRadio = radioLat != null && radioLon != null && !(radioLat === 0 && radioLon === 0);
|
||||
|
||||
if (valid.length === 0 && !hasRadio) return null;
|
||||
|
||||
// Center on radio if available, otherwise first neighbor
|
||||
const center: [number, number] = hasRadio ? [radioLat!, radioLon!] : [valid[0].lat, valid[0].lon];
|
||||
|
||||
return (
|
||||
<div className="min-h-48 flex-1 rounded border border-border overflow-hidden">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={10}
|
||||
className="h-full w-full"
|
||||
style={{ background: '#1a1a2e' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{/* Dotted lines from radio to each neighbor */}
|
||||
{hasRadio &&
|
||||
valid.map((n, i) => (
|
||||
<Polyline
|
||||
key={`line-${i}`}
|
||||
positions={[
|
||||
[radioLat!, radioLon!],
|
||||
[n.lat, n.lon],
|
||||
]}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
weight: 1.5,
|
||||
opacity: 0.5,
|
||||
dashArray: '6 4',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Radio node (bright blue) */}
|
||||
{hasRadio && (
|
||||
<CircleMarker
|
||||
center={[radioLat!, radioLon!]}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
color: '#1d4ed8',
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<span className="text-sm font-medium">{radioName || 'Our Radio'}</span>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)}
|
||||
{/* Neighbor nodes (SNR-colored) */}
|
||||
{valid.map((n, i) => (
|
||||
<CircleMarker
|
||||
key={i}
|
||||
center={[n.lat, n.lon]}
|
||||
radius={6}
|
||||
pathOptions={{
|
||||
color: '#000',
|
||||
fillColor: n.snr >= 6 ? '#22c55e' : n.snr >= 0 ? '#eab308' : '#ef4444',
|
||||
fillOpacity: 0.8,
|
||||
weight: 1,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<span className="text-sm">{n.name || n.pubkey_prefix}</span>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,15 +133,7 @@ export function NewMessageModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as Tab);
|
||||
setName('');
|
||||
setError('');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<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>
|
||||
|
||||
1710
frontend/src/components/PacketVisualizer.tsx
Normal file
@@ -1,9 +1,15 @@
|
||||
import type { Contact, RadioConfig, MessagePath } from '../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
resolvePath,
|
||||
parsePathHops,
|
||||
calculateDistance,
|
||||
isValidLocation,
|
||||
formatDistance,
|
||||
@@ -21,34 +27,14 @@ interface PathModalProps {
|
||||
senderInfo: SenderInfo;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
messageId?: number;
|
||||
isOutgoingChan?: boolean;
|
||||
isResendable?: boolean;
|
||||
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
}
|
||||
|
||||
export function PathModal({
|
||||
open,
|
||||
onClose,
|
||||
paths,
|
||||
senderInfo,
|
||||
contacts,
|
||||
config,
|
||||
messageId,
|
||||
isOutgoingChan,
|
||||
isResendable,
|
||||
onResend,
|
||||
}: PathModalProps) {
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
|
||||
export function PathModal({ open, onClose, paths, senderInfo, contacts, config }: PathModalProps) {
|
||||
// Resolve all paths
|
||||
const resolvedPaths = hasPaths
|
||||
? paths.map((p) => ({
|
||||
...p,
|
||||
resolved: resolvePath(p.path, senderInfo, contacts, config),
|
||||
}))
|
||||
: [];
|
||||
const resolvedPaths = paths.map((p) => ({
|
||||
...p,
|
||||
resolved: resolvePath(p.path, senderInfo, contacts, config),
|
||||
}));
|
||||
|
||||
const hasSinglePath = paths.length === 1;
|
||||
|
||||
@@ -56,15 +42,9 @@ export function PathModal({
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasPaths
|
||||
? `Message Path${!hasSinglePath ? `s (${paths.length})` : ''}`
|
||||
: 'Message Status'}
|
||||
</DialogTitle>
|
||||
<DialogTitle>Message Path{!hasSinglePath && `s (${paths.length})`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{!hasPaths ? (
|
||||
<>No echoes heard yet. Echoes appear when repeaters re-broadcast your message.</>
|
||||
) : hasSinglePath ? (
|
||||
{hasSinglePath ? (
|
||||
<>
|
||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||
Routers may be incorrectly identified due to prefix collisions between heard and
|
||||
@@ -79,104 +59,54 @@ export function PathModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasPaths && (
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Straight-line distance (sender to receiver, same for all routes) */}
|
||||
{resolvedPaths.length > 0 &&
|
||||
isValidLocation(
|
||||
resolvedPaths[0].resolved.sender.lat,
|
||||
resolvedPaths[0].resolved.sender.lon
|
||||
) &&
|
||||
isValidLocation(
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
) && (
|
||||
<div className="text-sm pb-2 border-b border-border">
|
||||
<span className="text-muted-foreground">Straight-line distance: </span>
|
||||
<span className="font-medium">
|
||||
{formatDistance(
|
||||
calculateDistance(
|
||||
resolvedPaths[0].resolved.sender.lat,
|
||||
resolvedPaths[0].resolved.sender.lon,
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
)!
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{resolvedPaths.map((pathData, index) => (
|
||||
<div key={index}>
|
||||
{!hasSinglePath && (
|
||||
<div className="text-xs text-muted-foreground font-medium mb-2 pb-1 border-b border-border">
|
||||
Path {index + 1} — received {formatTime(pathData.received_at)}
|
||||
</div>
|
||||
)}
|
||||
<PathVisualization
|
||||
resolved={pathData.resolved}
|
||||
senderInfo={senderInfo}
|
||||
hideStraightLine={!hasSinglePath}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{resolvedPaths.map((pathData, index) => (
|
||||
<div key={index}>
|
||||
{!hasSinglePath && (
|
||||
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
{/* Straight-line distance shown once for multi-path (same for all routes) */}
|
||||
{!hasSinglePath &&
|
||||
resolvedPaths.length > 0 &&
|
||||
(() => {
|
||||
const first = resolvedPaths[0].resolved;
|
||||
if (
|
||||
isValidLocation(first.sender.lat, first.sender.lon) &&
|
||||
isValidLocation(first.receiver.lat, first.receiver.lon)
|
||||
) {
|
||||
return (
|
||||
<div className="pt-3 mt-1 border-t border-border">
|
||||
<span className="text-sm text-muted-foreground">Straight-line distance: </span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatDistance(
|
||||
calculateDistance(
|
||||
first.sender.lat,
|
||||
first.sender.lon,
|
||||
first.receiver.lat,
|
||||
first.receiver.lon
|
||||
)!
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{hasResendActions && (
|
||||
<div className="flex gap-2">
|
||||
{isResendable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 min-w-0 h-auto py-2"
|
||||
onClick={() => {
|
||||
onResend(messageId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
Only repeated by new repeaters
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 min-w-0 h-auto py-2"
|
||||
onClick={() => {
|
||||
onResend(messageId, true);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend as new</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
Will appear as duplicate to receivers
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="secondary" className="h-auto py-2" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -185,9 +115,11 @@ export function PathModal({
|
||||
interface PathVisualizationProps {
|
||||
resolved: ResolvedPath;
|
||||
senderInfo: SenderInfo;
|
||||
/** If true, hide the straight-line distance (shown once at container level for multi-path) */
|
||||
hideStraightLine?: boolean;
|
||||
}
|
||||
|
||||
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
function PathVisualization({ resolved, senderInfo, hideStraightLine }: PathVisualizationProps) {
|
||||
// Track previous location for each hop to calculate distances
|
||||
// Returns null if previous hop was ambiguous or has invalid location
|
||||
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
|
||||
@@ -262,6 +194,31 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Straight-line distance (when both sender and receiver have coordinates) */}
|
||||
{!hideStraightLine &&
|
||||
isValidLocation(resolved.sender.lat, resolved.sender.lon) &&
|
||||
isValidLocation(resolved.receiver.lat, resolved.receiver.lon) && (
|
||||
<div
|
||||
className={
|
||||
resolved.totalDistances && resolved.totalDistances.length > 0
|
||||
? 'pt-1'
|
||||
: 'pt-3 mt-3 border-t border-border'
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">Straight-line distance: </span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatDistance(
|
||||
calculateDistance(
|
||||
resolved.sender.lat,
|
||||
resolved.sender.lon,
|
||||
resolved.receiver.lat,
|
||||
resolved.receiver.lon
|
||||
)!
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,12 +261,9 @@ function PathNode({
|
||||
|
||||
{/* Content */}
|
||||
<div className="pb-3 flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold">
|
||||
<span className="text-primary">{label}:</span>{' '}
|
||||
<span className="text-primary font-mono">{prefix}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-medium">{label}</div>
|
||||
<div className="font-medium truncate">
|
||||
{name}
|
||||
{name} <span className="text-muted-foreground font-mono text-sm">({prefix})</span>
|
||||
{distance !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
|
||||
)}
|
||||
@@ -351,20 +305,21 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{/* Vertical line and dot column */}
|
||||
<div className="flex flex-col items-center w-4 flex-shrink-0">
|
||||
<div className="w-0.5 h-3 bg-border" />
|
||||
<div className="w-3 h-3 rounded-full bg-primary/50 flex-shrink-0" />
|
||||
<div className="w-3 h-3 rounded-full bg-muted-foreground flex-shrink-0" />
|
||||
<div className="w-0.5 flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="pb-3 flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold">
|
||||
<span className="text-foreground/80">Hop {hopNumber}:</span>{' '}
|
||||
<span className="text-primary font-mono">{hop.prefix}</span>
|
||||
{isAmbiguous && <span className="text-yellow-500 ml-1 font-normal">(ambiguous)</span>}
|
||||
<div className="text-xs text-muted-foreground font-medium">
|
||||
Hop {hopNumber}
|
||||
{isAmbiguous && <span className="text-yellow-500 ml-1">(ambiguous)</span>}
|
||||
</div>
|
||||
|
||||
{isUnknown ? (
|
||||
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||
<div className="font-medium text-muted-foreground/70">
|
||||
<UNKNOWN <span className="font-mono text-sm">{hop.prefix}</span>>
|
||||
</div>
|
||||
) : isAmbiguous ? (
|
||||
<div>
|
||||
{hop.matches.map((contact) => {
|
||||
@@ -372,7 +327,10 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||
return (
|
||||
<div key={contact.public_key} className="font-medium truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{contact.name || contact.public_key.slice(0, 12)}{' '}
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
({contact.public_key.slice(0, 2).toUpperCase()})
|
||||
</span>
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist)}
|
||||
@@ -391,7 +349,8 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium truncate">
|
||||
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
|
||||
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}{' '}
|
||||
<span className="text-muted-foreground font-mono text-sm">({hop.prefix})</span>
|
||||
{hop.distanceFromPrev !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(hop.distanceFromPrev)}
|
||||
@@ -425,7 +384,7 @@ function CoordinateLink({ lat, lon, publicKey }: { lat: number; lon: number; pub
|
||||
|
||||
return (
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-mono cursor-pointer hover:text-primary hover:underline ml-1"
|
||||
className="text-xs text-muted-foreground/70 font-mono cursor-pointer hover:text-primary hover:underline ml-1"
|
||||
onClick={handleClick}
|
||||
title="View on map"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
|
||||
import type { RawPacket } from '../types';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RawPacketListProps {
|
||||
packets: RawPacket[];
|
||||
@@ -157,7 +155,7 @@ function getRouteTypeColor(routeType: string): string {
|
||||
case 'Transport Direct':
|
||||
return 'bg-orange-500/20 text-orange-400';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground';
|
||||
return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +186,6 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
}));
|
||||
}, [packets]);
|
||||
|
||||
// Sort packets by timestamp ascending (oldest first)
|
||||
const sortedPackets = useMemo(
|
||||
() => [...decodedPackets].sort((a, b) => a.packet.timestamp - b.packet.timestamp),
|
||||
[decodedPackets]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
@@ -208,13 +200,13 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort packets by timestamp ascending (oldest first)
|
||||
const sortedPackets = [...decodedPackets].sort((a, b) => a.packet.timestamp - b.packet.timestamp);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 flex flex-col gap-2" ref={listRef}>
|
||||
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
|
||||
{sortedPackets.map(({ packet, decoded }) => (
|
||||
<div
|
||||
key={getRawPacketObservationKey(packet)}
|
||||
className="py-2 px-3 bg-card rounded-md border border-border/50"
|
||||
>
|
||||
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
@@ -228,27 +220,25 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
{!packet.decrypted && <span title="Encrypted">🔒</span>}
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
>
|
||||
<span className={packet.decrypted ? 'text-primary' : 'text-foreground'}>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
<span className="text-muted-foreground ml-auto text-sm">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground/70 mt-1 p-1 bg-background/50 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
|
||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||
export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared';
|
||||
|
||||
// --- Main Dashboard ---
|
||||
|
||||
interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
onTrace: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
onTrace,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
}: RepeaterDashboardProps) {
|
||||
const {
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
loginError,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
consoleLoading,
|
||||
login,
|
||||
loginAsGuest,
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation);
|
||||
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
|
||||
// Loading all panes indicator
|
||||
const anyLoading = Object.values(paneStates).some((s) => s.loading);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success('Contact key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.id}
|
||||
</span>
|
||||
{contact &&
|
||||
(() => {
|
||||
const parts: ReactNode[] = [];
|
||||
if (contact.last_seen) {
|
||||
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
|
||||
}
|
||||
if (contact.last_path_len === -1) {
|
||||
parts.push('flood');
|
||||
} else if (contact.last_path_len === 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
direct
|
||||
</span>
|
||||
);
|
||||
} else if (contact.last_path_len > 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isValidLocation(contact.lat, contact.lon)) {
|
||||
const distFromUs =
|
||||
radioLat != null && radioLon != null && isValidLocation(radioLat, radioLon)
|
||||
? calculateDistance(radioLat, radioLon, contact.lat, contact.lon)
|
||||
: null;
|
||||
parts.push(
|
||||
<span key="coords">
|
||||
<span
|
||||
className="font-mono cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const url =
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
getMapFocusHash(contact.public_key);
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
title="View on map"
|
||||
>
|
||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||
</span>
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return parts.length > 0 ? (
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{loggedIn && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAll}
|
||||
disabled={anyLoading}
|
||||
className="text-xs border-green-600 text-green-600 hover:bg-green-600/10 hover:text-green-600"
|
||||
>
|
||||
{anyLoading ? 'Loading...' : 'Load All'}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
>
|
||||
🛎
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||
onClick={() => onToggleFavorite('contact', conversation.id)}
|
||||
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFav ? (
|
||||
<span className="text-amber-400">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
|
||||
onClick={() => onDeleteContact(conversation.id)}
|
||||
title="Delete"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{!loggedIn ? (
|
||||
<RepeaterLogin
|
||||
repeaterName={conversation.name}
|
||||
loading={loginLoading}
|
||||
error={loginError}
|
||||
onLogin={login}
|
||||
onLoginAsGuest={loginAsGuest}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<RadioSettingsPane
|
||||
data={paneData.radioSettings}
|
||||
state={paneStates.radioSettings}
|
||||
onRefresh={() => refreshPane('radioSettings')}
|
||||
disabled={anyLoading}
|
||||
advertData={paneData.advertIntervals}
|
||||
advertState={paneStates.advertIntervals}
|
||||
onRefreshAdvert={() => refreshPane('advertIntervals')}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() => refreshPane('lppTelemetry')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
</div>
|
||||
<NeighborsPane
|
||||
data={paneData.neighbors}
|
||||
state={paneStates.neighbors}
|
||||
onRefresh={() => refreshPane('neighbors')}
|
||||
disabled={anyLoading}
|
||||
contacts={contacts}
|
||||
radioLat={radioLat}
|
||||
radioLon={radioLon}
|
||||
radioName={radioName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remaining panes: ACL | Owner Info + Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<OwnerInfoPane
|
||||
data={paneData.ownerInfo}
|
||||
state={paneStates.ownerInfo}
|
||||
onRefresh={() => refreshPane('ownerInfo')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<ActionsPane
|
||||
onSendAdvert={sendAdvert}
|
||||
onSyncClock={syncClock}
|
||||
onReboot={rebootRepeater}
|
||||
consoleLoading={consoleLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Console — full width */}
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={sendConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useState, useCallback, type FormEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface RepeaterLoginProps {
|
||||
repeaterName: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onLogin: (password: string) => Promise<void>;
|
||||
onLoginAsGuest: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function RepeaterLogin({
|
||||
repeaterName,
|
||||
loading,
|
||||
error,
|
||||
onLogin,
|
||||
onLoginAsGuest,
|
||||
}: RepeaterLoginProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
await onLogin(password.trim());
|
||||
},
|
||||
[password, loading, onLogin]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h2 className="text-lg font-semibold">{repeaterName}</h2>
|
||||
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
name="repeater-password"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Repeater password..."
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-destructive text-center">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Logging in...' : 'Login with Password'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
onClick={onLoginAsGuest}
|
||||
>
|
||||
Login as Guest / ACLs
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type Conversation,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import { getStateKey, type ConversationTimes, type SortOrder } from '../utils/conversationState';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -14,49 +14,7 @@ import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
|
||||
|
||||
type ConversationRow = {
|
||||
key: string;
|
||||
type: 'channel' | 'contact';
|
||||
id: string;
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
isMention: boolean;
|
||||
contact?: Contact;
|
||||
};
|
||||
|
||||
type CollapseState = {
|
||||
favorites: boolean;
|
||||
channels: boolean;
|
||||
contacts: boolean;
|
||||
repeaters: boolean;
|
||||
};
|
||||
|
||||
const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state';
|
||||
|
||||
const DEFAULT_COLLAPSE_STATE: CollapseState = {
|
||||
favorites: false,
|
||||
channels: false,
|
||||
contacts: false,
|
||||
repeaters: false,
|
||||
};
|
||||
|
||||
function loadCollapsedState(): CollapseState {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIDEBAR_COLLAPSE_STATE_KEY);
|
||||
if (!raw) return DEFAULT_COLLAPSE_STATE;
|
||||
const parsed = JSON.parse(raw) as Partial<CollapseState>;
|
||||
return {
|
||||
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
|
||||
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
|
||||
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
|
||||
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_COLLAPSE_STATE;
|
||||
}
|
||||
}
|
||||
type SortOrder = 'alpha' | 'recent';
|
||||
|
||||
interface SidebarProps {
|
||||
contacts: Contact[];
|
||||
@@ -98,12 +56,6 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const sortOrder = sortOrderProp;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
|
||||
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
|
||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||
|
||||
const handleSortToggle = () => {
|
||||
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
|
||||
@@ -130,383 +82,161 @@ export function Sidebar({
|
||||
return mentions[key] || false;
|
||||
};
|
||||
|
||||
const getLastMessageTime = useCallback(
|
||||
(type: 'channel' | 'contact', id: string) => {
|
||||
const key = getStateKey(type, id);
|
||||
return lastMessageTimes[key] || 0;
|
||||
},
|
||||
[lastMessageTimes]
|
||||
);
|
||||
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
|
||||
const key = getStateKey(type, id);
|
||||
return lastMessageTimes[key] || 0;
|
||||
};
|
||||
|
||||
// Deduplicate channels by key only.
|
||||
// Channel names are not unique; distinct keys must remain visible.
|
||||
const uniqueChannels = useMemo(
|
||||
() =>
|
||||
channels.reduce<Channel[]>((acc, channel) => {
|
||||
if (!acc.some((c) => c.key === channel.key)) {
|
||||
acc.push(channel);
|
||||
}
|
||||
return acc;
|
||||
}, []),
|
||||
[channels]
|
||||
);
|
||||
// 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 public key, preferring ones with names
|
||||
// Also filter out any contacts with empty public keys
|
||||
const uniqueContacts = useMemo(
|
||||
() =>
|
||||
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) => {
|
||||
if (!acc.some((c) => c.public_key === contact.public_key)) {
|
||||
acc.push(contact);
|
||||
}
|
||||
return acc;
|
||||
}, []),
|
||||
[contacts]
|
||||
);
|
||||
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) => {
|
||||
if (!acc.some((c) => c.public_key === contact.public_key)) {
|
||||
acc.push(contact);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Sort channels based on sort order, with Public always first
|
||||
const sortedChannels = useMemo(
|
||||
() =>
|
||||
[...uniqueChannels].sort((a, b) => {
|
||||
// Public channel always sorts to the top
|
||||
if (a.name === 'Public') return -1;
|
||||
if (b.name === 'Public') return 1;
|
||||
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 (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
[uniqueChannels, sortOrder, getLastMessageTime]
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
const sortContactsByOrder = useCallback(
|
||||
(items: Contact[]) =>
|
||||
[...items].sort((a, b) => {
|
||||
if (sortOrder === 'recent') {
|
||||
const timeA = getLastMessageTime('contact', a.public_key);
|
||||
const timeB = getLastMessageTime('contact', b.public_key);
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
}
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
}),
|
||||
[sortOrder, getLastMessageTime]
|
||||
);
|
||||
// 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;
|
||||
|
||||
// Split non-repeater contacts and repeater contacts into separate sorted lists
|
||||
const sortedNonRepeaterContacts = useMemo(
|
||||
() => sortContactsByOrder(uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER)),
|
||||
[uniqueContacts, sortContactsByOrder]
|
||||
);
|
||||
// Repeaters always go to the bottom
|
||||
if (aIsRepeater && !bIsRepeater) return 1;
|
||||
if (!aIsRepeater && bIsRepeater) return -1;
|
||||
|
||||
const sortedRepeaters = useMemo(
|
||||
() => sortContactsByOrder(uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER)),
|
||||
[uniqueContacts, sortContactsByOrder]
|
||||
);
|
||||
// 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 isSearching = query.length > 0;
|
||||
const filteredChannels = query
|
||||
? sortedChannels.filter(
|
||||
(c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedChannels;
|
||||
const filteredContacts = query
|
||||
? sortedContacts.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedContacts;
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedChannels.filter(
|
||||
(c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedChannels,
|
||||
[sortedChannels, query]
|
||||
// Separate favorites from regular items
|
||||
const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favoriteContacts = filteredContacts.filter((c) =>
|
||||
isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavoriteChannels = filteredChannels.filter(
|
||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
||||
);
|
||||
const nonFavoriteContacts = filteredContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
|
||||
const filteredNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedNonRepeaterContacts.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedNonRepeaterContacts,
|
||||
[sortedNonRepeaterContacts, query]
|
||||
);
|
||||
// Combine and sort favorites by most recent message (always recent order)
|
||||
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
|
||||
|
||||
const filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRepeaters.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRepeaters,
|
||||
[sortedRepeaters, query]
|
||||
);
|
||||
|
||||
// Expand sections while searching; restore prior collapse state when search ends.
|
||||
useEffect(() => {
|
||||
if (isSearching) {
|
||||
if (!collapseSnapshotRef.current) {
|
||||
collapseSnapshotRef.current = {
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
}
|
||||
|
||||
if (favoritesCollapsed || channelsCollapsed || contactsCollapsed || repeatersCollapsed) {
|
||||
setFavoritesCollapsed(false);
|
||||
setChannelsCollapsed(false);
|
||||
setContactsCollapsed(false);
|
||||
setRepeatersCollapsed(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (collapseSnapshotRef.current) {
|
||||
const prev = collapseSnapshotRef.current;
|
||||
collapseSnapshotRef.current = null;
|
||||
setFavoritesCollapsed(prev.favorites);
|
||||
setChannelsCollapsed(prev.channels);
|
||||
setContactsCollapsed(prev.contacts);
|
||||
setRepeatersCollapsed(prev.repeaters);
|
||||
}
|
||||
}, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) return;
|
||||
|
||||
const state: CollapseState = {
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSE_STATE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Ignore localStorage write failures (e.g., disabled storage)
|
||||
}
|
||||
}, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]);
|
||||
|
||||
// Separate favorites from regular items, and build combined favorites list
|
||||
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
|
||||
useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) =>
|
||||
isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavChannels = filteredChannels.filter(
|
||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
||||
);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
].sort((a, b) => {
|
||||
const timeA =
|
||||
a.type === 'channel'
|
||||
? getLastMessageTime('channel', a.channel.key)
|
||||
: getLastMessageTime('contact', a.contact.public_key);
|
||||
const timeB =
|
||||
b.type === 'channel'
|
||||
? getLastMessageTime('channel', b.channel.key)
|
||||
: getLastMessageTime('contact', b.contact.public_key);
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
const nameA =
|
||||
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
||||
const nameB =
|
||||
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return {
|
||||
favoriteItems: items,
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
getLastMessageTime,
|
||||
]);
|
||||
|
||||
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
|
||||
key: `${keyPrefix}-${channel.key}`,
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
unreadCount: getUnreadCount('channel', channel.key),
|
||||
isMention: hasMention('channel', channel.key),
|
||||
const favoriteItems: FavoriteItem[] = [
|
||||
...favoriteChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favoriteContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
].sort((a, b) => {
|
||||
const timeA =
|
||||
a.type === 'channel'
|
||||
? getLastMessageTime('channel', a.channel.key)
|
||||
: getLastMessageTime('contact', a.contact.public_key);
|
||||
const timeB =
|
||||
b.type === 'channel'
|
||||
? getLastMessageTime('channel', b.channel.key)
|
||||
: getLastMessageTime('contact', b.contact.public_key);
|
||||
// Sort by most recent first
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
// Fall back to name comparison
|
||||
const nameA = a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
||||
const nameB = b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
||||
key: `${keyPrefix}-${contact.public_key}`,
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
unreadCount: getUnreadCount('contact', contact.public_key),
|
||||
isMention: hasMention('contact', contact.public_key),
|
||||
contact,
|
||||
});
|
||||
|
||||
const renderConversationRow = (row: ConversationRow) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors',
|
||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{row.type === 'contact' && row.contact && (
|
||||
<ContactAvatar
|
||||
name={row.contact.name}
|
||||
publicKey={row.contact.public_key}
|
||||
size={24}
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: 'bg-primary/90 text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const getSectionUnreadCount = (rows: ConversationRow[]): number =>
|
||||
rows.reduce((total, row) => total + row.unreadCount, 0);
|
||||
|
||||
const favoriteRows = favoriteItems.map((item) =>
|
||||
item.type === 'channel'
|
||||
? buildChannelRow(item.channel, 'fav-chan')
|
||||
: buildContactRow(item.contact, 'fav-contact')
|
||||
);
|
||||
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
|
||||
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
|
||||
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
|
||||
|
||||
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
|
||||
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
||||
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
||||
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
||||
|
||||
const renderSectionHeader = (
|
||||
title: string,
|
||||
collapsed: boolean,
|
||||
onToggle: () => void,
|
||||
showSortToggle = false,
|
||||
unreadCount = 0
|
||||
) => {
|
||||
const effectiveCollapsed = isSearching ? false : collapsed;
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors',
|
||||
isSearching && 'cursor-default'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSearching) onToggle();
|
||||
}}
|
||||
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
|
||||
>
|
||||
<span className="text-[9px]">{effectiveCollapsed ? '▸' : '▾'}</span>
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
{(showSortToggle || unreadCount > 0) && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{showSortToggle && (
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors"
|
||||
onClick={handleSortToggle}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Conversations
|
||||
</h2>
|
||||
<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 transition-colors"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative px-3 py-2">
|
||||
<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-7 text-[13px] pr-8 bg-background/50"
|
||||
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 transition-colors"
|
||||
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"
|
||||
>
|
||||
@@ -521,7 +251,7 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'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={() =>
|
||||
@@ -533,7 +263,7 @@ export function Sidebar({
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">📡</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Packet Feed</span>
|
||||
<span className="flex-1 truncate">Packet Feed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -541,7 +271,7 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
||||
isActive('map', 'map') && 'bg-accent border-l-primary'
|
||||
)}
|
||||
onClick={() =>
|
||||
@@ -553,7 +283,7 @@ export function Sidebar({
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">🗺️</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Node Map</span>
|
||||
<span className="flex-1 truncate">Node Map</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -561,7 +291,7 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
||||
isActive('visualizer', 'visualizer') && 'bg-accent border-l-primary'
|
||||
)}
|
||||
onClick={() =>
|
||||
@@ -573,7 +303,7 @@ export function Sidebar({
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">✨</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">Mesh Visualizer</span>
|
||||
<span className="flex-1 truncate">Mesh Visualizer</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -581,21 +311,21 @@ export function Sidebar({
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
|
||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
||||
showCracker && 'bg-accent border-l-primary'
|
||||
)}
|
||||
onClick={onToggleCracker}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">🔓</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">
|
||||
<span className="flex-1 truncate">
|
||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
||||
'ml-1 text-xs',
|
||||
crackerRunning ? 'text-green-500' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
({crackerRunning ? 'running' : 'idle'})
|
||||
({crackerRunning ? 'running' : 'stopped'})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -604,7 +334,7 @@ export function Sidebar({
|
||||
{/* Mark All Read */}
|
||||
{!query && Object.keys(unreadCounts).length > 0 && (
|
||||
<div
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]"
|
||||
className="px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent"
|
||||
onClick={onMarkAllRead}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">✓</span>
|
||||
@@ -615,67 +345,208 @@ export function Sidebar({
|
||||
{/* Favorites */}
|
||||
{favoriteItems.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Favorites',
|
||||
favoritesCollapsed,
|
||||
() => setFavoritesCollapsed((prev) => !prev),
|
||||
false,
|
||||
favoritesUnreadCount
|
||||
)}
|
||||
{(isSearching || !favoritesCollapsed) &&
|
||||
favoriteRows.map((row) => renderConversationRow(row))}
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Favorites</span>
|
||||
</div>
|
||||
{favoriteItems.map((item) => {
|
||||
if (item.type === 'channel') {
|
||||
const channel = item.channel;
|
||||
const unreadCount = getUnreadCount('channel', channel.key);
|
||||
const isMention = hasMention('channel', channel.key);
|
||||
return (
|
||||
<div
|
||||
key={`fav-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="name flex-1 truncate">{channel.name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
isMention
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const contact = item.contact;
|
||||
const unreadCount = getUnreadCount('contact', contact.public_key);
|
||||
const isMention = hasMention('contact', contact.public_key);
|
||||
return (
|
||||
<div
|
||||
key={`fav-contact-${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={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
isMention
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Channels */}
|
||||
{nonFavoriteChannels.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Channels',
|
||||
channelsCollapsed,
|
||||
() => setChannelsCollapsed((prev) => !prev),
|
||||
true,
|
||||
channelsUnreadCount
|
||||
)}
|
||||
{(isSearching || !channelsCollapsed) &&
|
||||
channelRows.map((row) => renderConversationRow(row))}
|
||||
<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>
|
||||
{nonFavoriteChannels.map((channel) => {
|
||||
const unreadCount = getUnreadCount('channel', channel.key);
|
||||
const isMention = hasMention('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="name flex-1 truncate">{channel.name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
isMention
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contacts */}
|
||||
{nonFavoriteContacts.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Contacts',
|
||||
contactsCollapsed,
|
||||
() => setContactsCollapsed((prev) => !prev),
|
||||
true,
|
||||
contactsUnreadCount
|
||||
)}
|
||||
{(isSearching || !contactsCollapsed) &&
|
||||
contactRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Repeaters */}
|
||||
{nonFavoriteRepeaters.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Repeaters',
|
||||
repeatersCollapsed,
|
||||
() => setRepeatersCollapsed((prev) => !prev),
|
||||
true,
|
||||
repeatersUnreadCount
|
||||
)}
|
||||
{(isSearching || !repeatersCollapsed) &&
|
||||
repeaterRows.map((row) => renderConversationRow(row))}
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
|
||||
{nonFavoriteChannels.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>
|
||||
{nonFavoriteContacts.map((contact) => {
|
||||
const unreadCount = getUnreadCount('contact', contact.public_key);
|
||||
const isMention = hasMention('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={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
isMention
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{nonFavoriteContacts.length === 0 &&
|
||||
nonFavoriteChannels.length === 0 &&
|
||||
nonFavoriteRepeaters.length === 0 &&
|
||||
favoriteItems.length === 0 && (
|
||||
<div className="p-5 text-center text-muted-foreground">
|
||||
{query ? 'No matches found' : 'No conversations yet'}
|
||||
|
||||
@@ -3,23 +3,15 @@ import { Menu } from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatusBarProps {
|
||||
health: HealthStatus | null;
|
||||
config: RadioConfig | null;
|
||||
settingsMode?: boolean;
|
||||
onSettingsClick: () => void;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
health,
|
||||
config,
|
||||
settingsMode = false,
|
||||
onSettingsClick,
|
||||
onMenuClick,
|
||||
}: StatusBarProps) {
|
||||
export function StatusBar({ health, config, onSettingsClick, onMenuClick }: StatusBarProps) {
|
||||
const connected = health?.radio_connected ?? false;
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
|
||||
@@ -40,51 +32,32 @@ export function StatusBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
|
||||
<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-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
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="text-base font-semibold tracking-tight mr-auto text-foreground flex items-center gap-1.5">
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-white"
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m455.68 85.902c-31.289 0-56.32 25.031-56.32 56.32 0 11.379 3.4141 21.617 8.5352 30.152l-106.38 135.39c12.516 6.2578 23.895 15.359 32.996 25.602l107.52-136.54c4.5508 1.1367 9.1016 1.707 13.652 1.707 31.289 0 56.32-25.031 56.32-56.32 0-30.719-25.031-56.32-56.32-56.32z" />
|
||||
<path d="m256 343.04c-5.6875 0-10.809 0.57031-15.93 2.2773l-106.38-135.96c-9.1016 10.809-20.48 19.344-32.996 25.602l106.38 135.96c-5.1211 8.5352-7.3945 18.203-7.3945 28.445 0 31.289 25.031 56.32 56.32 56.32s56.32-25.031 56.32-56.32c0-31.293-25.031-56.324-56.32-56.324z" />
|
||||
<path d="m356.69 114.91c3.9805-13.652 10.238-26.738 19.344-37.547-38.113-13.652-78.508-21.047-120.04-21.047-59.164 0-115.48 14.789-166.12 42.668-9.1016-6.8281-21.051-10.809-33.562-10.809-31.289-0.57031-56.32 25.027-56.32 55.75 0 31.289 25.031 56.32 56.32 56.32 31.289 0 56.32-25.031 56.32-56.32 0-3.4141-0.57031-6.8281-1.1367-9.6719 44.371-23.895 93.297-36.41 144.5-36.41 34.703 0 68.836 5.6914 100.69 17.066z" />
|
||||
</svg>
|
||||
RemoteTerm
|
||||
</h1>
|
||||
<h1 className="text-base font-semibold mr-auto">RemoteTerm</h1>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors',
|
||||
connected
|
||||
? 'bg-primary shadow-[0_0_6px_hsl(var(--primary)/0.5)]'
|
||||
: 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="hidden lg:inline text-muted-foreground">
|
||||
<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>
|
||||
|
||||
{config && (
|
||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||
<div className="hidden lg:flex items-center gap-2 text-[#888]">
|
||||
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
className="font-mono text-[#888] cursor-pointer hover:text-[#4a9eff]"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(config.public_key);
|
||||
toast.success('Public key copied!');
|
||||
@@ -100,16 +73,19 @@ export function StatusBar({
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
className="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-md text-xs cursor-pointer hover:bg-amber-500/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
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={onSettingsClick}
|
||||
className="px-3 py-1.5 bg-secondary border border-border text-muted-foreground rounded-md text-xs cursor-pointer hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
|
||||
>
|
||||
{settingsMode ? 'Back to Chat' : 'Settings'}
|
||||
<span role="img" aria-label="Settings">
|
||||
🔧
|
||||
</span>{' '}
|
||||
Radio & Config
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { Contact, RawPacket, RadioConfig } from '../types';
|
||||
import { PacketVisualizer3D } from './PacketVisualizer3D';
|
||||
import { PacketVisualizer } from './PacketVisualizer';
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -10,44 +9,17 @@ interface VisualizerViewProps {
|
||||
packets: RawPacket[];
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
onClearPackets?: () => void;
|
||||
}
|
||||
|
||||
export function VisualizerView({ packets, contacts, config }: VisualizerViewProps) {
|
||||
export function VisualizerView({ packets, contacts, config, onClearPackets }: VisualizerViewProps) {
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
const [paneFullScreen, setPaneFullScreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync state when browser exits fullscreen (Escape, F11, etc.)
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!document.fullscreenElement) setPaneFullScreen(false);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
return () => document.removeEventListener('fullscreenchange', handler);
|
||||
}, []);
|
||||
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current?.requestFullscreen();
|
||||
setPaneFullScreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
// State synced via fullscreenchange handler
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
|
||||
<span>Mesh Visualizer</span>
|
||||
<button
|
||||
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={toggleFullScreen}
|
||||
title={paneFullScreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{paneFullScreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Tabbed interface */}
|
||||
@@ -58,7 +30,12 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
|
||||
<TabsTrigger value="packets">Packet Feed</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="visualizer" className="flex-1 m-0 overflow-hidden">
|
||||
<PacketVisualizer3D packets={packets} contacts={contacts} config={config} />
|
||||
<PacketVisualizer
|
||||
packets={packets}
|
||||
contacts={contacts}
|
||||
config={config}
|
||||
onClearPackets={onClearPackets}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="packets" className="flex-1 m-0 overflow-hidden">
|
||||
<RawPacketList packets={packets} />
|
||||
@@ -75,12 +52,13 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
|
||||
fullScreen ? 'flex-1' : 'flex-1 border-r border-border'
|
||||
)}
|
||||
>
|
||||
<PacketVisualizer3D
|
||||
<PacketVisualizer
|
||||
packets={packets}
|
||||
contacts={contacts}
|
||||
config={config}
|
||||
fullScreen={fullScreen}
|
||||
onFullScreenChange={setFullScreen}
|
||||
onClearPackets={onClearPackets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched } from './repeaterPaneShared';
|
||||
import type { RepeaterAclResponse, PaneState } from '../../types';
|
||||
|
||||
export function AclPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: {
|
||||
data: RepeaterAclResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const permColor: Record<number, string> = {
|
||||
0: 'bg-muted text-muted-foreground',
|
||||
1: 'bg-blue-500/10 text-blue-500',
|
||||
2: 'bg-green-500/10 text-green-500',
|
||||
3: 'bg-amber-500/10 text-amber-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<RepeaterPane title="ACL" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : data.acl.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No ACL entries</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground text-xs">
|
||||
<th className="pb-1 font-medium">Name</th>
|
||||
<th className="pb-1 font-medium text-right">Permission</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.acl.map((entry, i) => (
|
||||
<tr key={i} className="border-t border-border/50">
|
||||
<td className="py-1">{entry.name || entry.pubkey_prefix}</td>
|
||||
<td className="py-1 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded',
|
||||
permColor[entry.permission] ?? 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{entry.permission_name}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function ActionsPane({
|
||||
onSendAdvert,
|
||||
onSyncClock,
|
||||
onReboot,
|
||||
consoleLoading,
|
||||
}: {
|
||||
onSendAdvert: () => void;
|
||||
onSyncClock: () => void;
|
||||
onReboot: () => void;
|
||||
consoleLoading: boolean;
|
||||
}) {
|
||||
const [confirmReboot, setConfirmReboot] = useState(false);
|
||||
|
||||
const handleReboot = useCallback(() => {
|
||||
if (!confirmReboot) {
|
||||
setConfirmReboot(true);
|
||||
return;
|
||||
}
|
||||
setConfirmReboot(false);
|
||||
onReboot();
|
||||
}, [confirmReboot, onReboot]);
|
||||
|
||||
// Reset confirmation after 3 seconds
|
||||
useEffect(() => {
|
||||
if (!confirmReboot) return;
|
||||
const timer = setTimeout(() => setConfirmReboot(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [confirmReboot]);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Actions</h3>
|
||||
</div>
|
||||
<div className="p-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
|
||||
Send Advert
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
|
||||
Sync Clock
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmReboot ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleReboot}
|
||||
disabled={consoleLoading}
|
||||
>
|
||||
{confirmReboot ? 'Confirm Reboot' : 'Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect, type FormEvent } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
export function ConsolePane({
|
||||
history,
|
||||
loading,
|
||||
onSend,
|
||||
}: {
|
||||
history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>;
|
||||
loading: boolean;
|
||||
onSend: (command: string) => Promise<void>;
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom on new entries
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || loading) return;
|
||||
setInput('');
|
||||
await onSend(trimmed);
|
||||
},
|
||||
[input, loading, onSend]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden col-span-full">
|
||||
<div className="px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Console</h3>
|
||||
</div>
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
|
||||
>
|
||||
{history.length === 0 && (
|
||||
<p className="text-muted-foreground italic">Type a CLI command below...</p>
|
||||
)}
|
||||
{history.map((entry, i) =>
|
||||
entry.outgoing ? (
|
||||
<div key={i} className="text-green-300">
|
||||
> {entry.command}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="text-green-400/80 whitespace-pre-wrap">
|
||||
{entry.response}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{loading && <div className="text-muted-foreground animate-pulse">...</div>}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
|
||||
<Input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
name="console-input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="CLI command..."
|
||||
disabled={loading}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={loading || !input.trim()}>
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
|
||||
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||
|
||||
export function LppTelemetryPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: {
|
||||
data: RepeaterLppTelemetryResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : data.sensors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No sensor data available</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{data.sensors.map((sensor, i) => (
|
||||
<LppSensorRow key={i} sensor={sensor} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useMemo, lazy, Suspense } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||
import type { Contact, RepeaterNeighborsResponse, PaneState, NeighborInfo } from '../../types';
|
||||
|
||||
const NeighborsMiniMap = lazy(() =>
|
||||
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
|
||||
);
|
||||
|
||||
export function NeighborsPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
contacts,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
}: {
|
||||
data: RepeaterNeighborsResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
contacts: Contact[];
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
}) {
|
||||
// Resolve contact data for each neighbor in a single pass — used for
|
||||
// coords (mini-map), distances (table column), and sorted display order.
|
||||
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
|
||||
if (!data) {
|
||||
return {
|
||||
neighborsWithCoords: [] as Array<NeighborInfo & { lat: number | null; lon: number | null }>,
|
||||
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
|
||||
hasDistances: false,
|
||||
};
|
||||
}
|
||||
|
||||
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
|
||||
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
|
||||
let anyDist = false;
|
||||
|
||||
for (const n of data.neighbors) {
|
||||
const contact = contacts.find((c) => c.public_key.startsWith(n.pubkey_prefix));
|
||||
const nLat = contact?.lat ?? null;
|
||||
const nLon = contact?.lon ?? null;
|
||||
|
||||
let dist: string | null = null;
|
||||
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
anyDist = true;
|
||||
}
|
||||
}
|
||||
enriched.push({ ...n, distance: dist });
|
||||
|
||||
if (isValidLocation(nLat, nLon)) {
|
||||
withCoords.push({ ...n, lat: nLat, lon: nLon });
|
||||
}
|
||||
}
|
||||
|
||||
enriched.sort((a, b) => b.snr - a.snr);
|
||||
|
||||
return {
|
||||
neighborsWithCoords: withCoords,
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [data, contacts, radioLat, radioLon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
title="Neighbors"
|
||||
state={state}
|
||||
onRefresh={onRefresh}
|
||||
disabled={disabled}
|
||||
className="flex flex-col"
|
||||
contentClassName="flex-1 flex flex-col"
|
||||
>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No neighbors reported</p>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground text-xs">
|
||||
<th className="pb-1 font-medium">Name</th>
|
||||
<th className="pb-1 font-medium text-right">SNR</th>
|
||||
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
|
||||
<th className="pb-1 font-medium text-right">Last Heard</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((n, i) => {
|
||||
const dist = n.distance;
|
||||
const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
|
||||
const snrColor =
|
||||
n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
|
||||
return (
|
||||
<tr key={i} className="border-t border-border/50">
|
||||
<td className="py-1">{n.name || n.pubkey_prefix}</td>
|
||||
<td className={cn('py-1 text-right font-mono', snrColor)}>{snrStr} dB</td>
|
||||
{hasDistances && (
|
||||
<td className="py-1 text-right text-muted-foreground font-mono">
|
||||
{dist ?? '—'}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-1 text-right text-muted-foreground">
|
||||
{formatDuration(n.last_heard_seconds)} ago
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Loading map...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NeighborsMiniMap
|
||||
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
|
||||
neighbors={neighborsWithCoords}
|
||||
radioLat={radioLat}
|
||||
radioLon={radioLon}
|
||||
radioName={radioName}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { RepeaterPane, NotFetched, KvRow } from './repeaterPaneShared';
|
||||
import type { RepeaterOwnerInfoResponse, PaneState } from '../../types';
|
||||
|
||||
export function OwnerInfoPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: {
|
||||
data: RepeaterOwnerInfoResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<RepeaterPane title="Owner Info" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div className="break-all">
|
||||
<KvRow label="Owner Info" value={data.owner_info ?? '—'} />
|
||||
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '../ui/separator';
|
||||
import {
|
||||
RepeaterPane,
|
||||
RefreshIcon,
|
||||
NotFetched,
|
||||
KvRow,
|
||||
formatClockDrift,
|
||||
formatAdvertInterval,
|
||||
} from './repeaterPaneShared';
|
||||
import type {
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
PaneState,
|
||||
} from '../../types';
|
||||
|
||||
export function RadioSettingsPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
advertData,
|
||||
advertState,
|
||||
onRefreshAdvert,
|
||||
}: {
|
||||
data: RepeaterRadioSettingsResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
advertData: RepeaterAdvertIntervalsResponse | null;
|
||||
advertState: PaneState;
|
||||
onRefreshAdvert: () => void;
|
||||
}) {
|
||||
const clockDrift = useMemo(() => {
|
||||
if (!data?.clock_utc) return null;
|
||||
return formatClockDrift(data.clock_utc);
|
||||
}, [data?.clock_utc]);
|
||||
|
||||
return (
|
||||
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
|
||||
<KvRow label="Radio" value={data.radio ?? '—'} />
|
||||
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
|
||||
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
|
||||
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
|
||||
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="Name" value={data.name ?? '—'} />
|
||||
<KvRow
|
||||
label="Lat / Lon"
|
||||
value={
|
||||
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
|
||||
}
|
||||
/>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between text-sm py-0.5">
|
||||
<span className="text-muted-foreground">Clock (UTC)</span>
|
||||
<span>
|
||||
{data.clock_utc ?? '—'}
|
||||
{clockDrift && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs',
|
||||
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
(drift: {clockDrift.text})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Advert Intervals sub-section */}
|
||||
<Separator className="my-2" />
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Advert Intervals</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshAdvert}
|
||||
disabled={disabled || advertState.loading}
|
||||
className={cn(
|
||||
'p-1 rounded transition-colors disabled:opacity-50',
|
||||
disabled || advertState.loading
|
||||
? 'text-muted-foreground'
|
||||
: 'text-green-500 hover:bg-accent hover:text-green-400'
|
||||
)}
|
||||
title="Refresh Advert Intervals"
|
||||
>
|
||||
<RefreshIcon
|
||||
className={cn(
|
||||
'w-3 h-3',
|
||||
advertState.loading && 'animate-spin [animation-direction:reverse]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{advertState.error && <p className="text-xs text-destructive mb-1">{advertState.error}</p>}
|
||||
{advertState.loading ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
|
||||
</p>
|
||||
) : !advertData ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
|
||||
<KvRow
|
||||
label="Flood Advert"
|
||||
value={formatAdvertInterval(advertData.flood_advert_interval)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Separator } from '../ui/separator';
|
||||
import { RepeaterPane, NotFetched, KvRow, formatDuration } from './repeaterPaneShared';
|
||||
import type { RepeaterStatusResponse, PaneState } from '../../types';
|
||||
|
||||
export function TelemetryPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: {
|
||||
data: RepeaterStatusResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<RepeaterPane title="Telemetry" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<KvRow label="Battery" value={`${data.battery_volts.toFixed(3)}V`} />
|
||||
<KvRow label="Uptime" value={formatDuration(data.uptime_seconds)} />
|
||||
<KvRow label="TX Airtime" value={formatDuration(data.airtime_seconds)} />
|
||||
<KvRow label="RX Airtime" value={formatDuration(data.rx_airtime_seconds)} />
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="Noise Floor" value={`${data.noise_floor_dbm} dBm`} />
|
||||
<KvRow label="Last RSSI" value={`${data.last_rssi_dbm} dBm`} />
|
||||
<KvRow label="Last SNR" value={`${data.last_snr_db.toFixed(1)} dB`} />
|
||||
<Separator className="my-1" />
|
||||
<KvRow
|
||||
label="Packets"
|
||||
value={`${data.packets_received.toLocaleString()} rx / ${data.packets_sent.toLocaleString()} tx`}
|
||||
/>
|
||||
<KvRow
|
||||
label="Flood"
|
||||
value={`${data.recv_flood.toLocaleString()} rx / ${data.sent_flood.toLocaleString()} tx`}
|
||||
/>
|
||||
<KvRow
|
||||
label="Direct"
|
||||
value={`${data.recv_direct.toLocaleString()} rx / ${data.sent_direct.toLocaleString()} tx`}
|
||||
/>
|
||||
<KvRow
|
||||
label="Duplicates"
|
||||
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
||||
/>
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
||||
<KvRow label="Debug Flags" value={data.full_events} />
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LppSensor, PaneState } from '../../types';
|
||||
|
||||
// --- Shared Icons ---
|
||||
|
||||
export function RefreshIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) {
|
||||
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
|
||||
if (hours > 0) return `${days}d${hours}h`;
|
||||
if (mins > 0) return `${days}d${mins}m`;
|
||||
return `${days}d`;
|
||||
}
|
||||
if (hours > 0) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } {
|
||||
// Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC"
|
||||
// Also handle ISO-like: "YYYY-MM-DD HH:MM:SS"
|
||||
let parsed: Date;
|
||||
const fwMatch = clockUtc.match(
|
||||
/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/
|
||||
);
|
||||
if (fwMatch) {
|
||||
const [, hh, mm, ss, dd, mo, yyyy] = fwMatch;
|
||||
parsed = new Date(Date.UTC(+yyyy, +mo - 1, +dd, +hh, +mm, +(ss ?? 0)));
|
||||
} else {
|
||||
parsed = new Date(
|
||||
clockUtc.replace(' ', 'T') + (clockUtc.includes('Z') || clockUtc.includes('UTC') ? '' : 'Z')
|
||||
);
|
||||
}
|
||||
if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false };
|
||||
|
||||
const driftMs = Math.abs(Date.now() - parsed.getTime());
|
||||
const driftSec = Math.floor(driftMs / 1000);
|
||||
|
||||
if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true };
|
||||
|
||||
const h = Math.floor(driftSec / 3600);
|
||||
const m = Math.floor((driftSec % 3600) / 60);
|
||||
const s = driftSec % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
|
||||
return { text: parts.join(''), isLarge: false };
|
||||
}
|
||||
|
||||
export function formatAdvertInterval(val: string | null): string {
|
||||
if (val == null) return '—';
|
||||
const trimmed = val.trim();
|
||||
if (trimmed === '0') return '<disabled>';
|
||||
return `${trimmed}h`;
|
||||
}
|
||||
|
||||
// --- Generic Pane Wrapper ---
|
||||
|
||||
export function RepeaterPane({
|
||||
title,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: {
|
||||
title: string;
|
||||
state: PaneState;
|
||||
onRefresh?: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={disabled || state.loading}
|
||||
className={cn(
|
||||
'p-1 rounded transition-colors disabled:opacity-50',
|
||||
disabled || state.loading
|
||||
? 'text-muted-foreground'
|
||||
: 'text-green-500 hover:bg-accent hover:text-green-400'
|
||||
)}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshIcon
|
||||
className={cn(
|
||||
'w-3.5 h-3.5',
|
||||
state.loading && 'animate-spin [animation-direction:reverse]'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{state.error && (
|
||||
<div className="px-3 py-1.5 text-xs text-destructive bg-destructive/5 border-b border-border">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('p-3', contentClassName)}>
|
||||
{state.loading ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
|
||||
</p>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFetched() {
|
||||
return <p className="text-sm text-muted-foreground italic"><not fetched></p>;
|
||||
}
|
||||
|
||||
export function KvRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center text-sm py-0.5">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- LPP Utilities ---
|
||||
|
||||
export const LPP_UNIT_MAP: Record<string, string> = {
|
||||
temperature: '°C',
|
||||
humidity: '%',
|
||||
barometer: 'hPa',
|
||||
voltage: 'V',
|
||||
current: 'mA',
|
||||
luminosity: 'lux',
|
||||
altitude: 'm',
|
||||
power: 'W',
|
||||
distance: 'mm',
|
||||
energy: 'kWh',
|
||||
direction: '°',
|
||||
concentration: 'ppm',
|
||||
colour: '',
|
||||
};
|
||||
|
||||
export function formatLppLabel(typeName: string): string {
|
||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function LppSensorRow({ sensor }: { sensor: LppSensor }) {
|
||||
const label = formatLppLabel(sensor.type_name);
|
||||
|
||||
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
||||
// Multi-value sensor (GPS, accelerometer, etc.)
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div className="pl-3">
|
||||
{Object.entries(sensor.value).map(([k, v]) => (
|
||||
<KvRow
|
||||
key={k}
|
||||
label={k.charAt(0).toUpperCase() + k.slice(1)}
|
||||
value={typeof v === 'number' ? v.toFixed(2) : String(v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unit = LPP_UNIT_MAP[sensor.type_name] ?? '';
|
||||
const formatted =
|
||||
typeof sensor.value === 'number'
|
||||
? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}`
|
||||
: String(sensor.value);
|
||||
|
||||
return <KvRow label={label} value={formatted} />;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Separator } from '../ui/separator';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore';
|
||||
|
||||
export function SettingsAboutSection({ className }: { className?: string }) {
|
||||
const version = __APP_VERSION__;
|
||||
const commit = __COMMIT_HASH__;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-6">
|
||||
{/* Version */}
|
||||
<div className="text-center space-y-1">
|
||||
<h3 className="text-lg font-semibold">RemoteTerm for MeshCore</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
v{version}
|
||||
<span className="mx-1.5">·</span>
|
||||
<span className="font-mono text-xs">{commit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Author & License */}
|
||||
<div className="text-sm text-center space-y-2">
|
||||
<p>
|
||||
Made with love and open source by{' '}
|
||||
<a
|
||||
href="https://jacksbrain.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Jack Kingsman
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Licensed under the{' '}
|
||||
<a
|
||||
href={`${GITHUB_URL}/blob/main/LICENSE.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
MIT License
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex justify-center gap-4 text-sm">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={`${GITHUB_URL}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Report a Bug
|
||||
</a>
|
||||
<a
|
||||
href={`${GITHUB_URL}/blob/main/CHANGELOG.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Acknowledgements */}
|
||||
<div className="text-sm text-center text-muted-foreground space-y-2">
|
||||
<p>With great appreciation to those who have made the tools upon which this is built:</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://github.com/meshcore-dev/MeshCore"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
MeshCore
|
||||
</a>
|
||||
<span className="mx-1.5">·</span>
|
||||
<a
|
||||
href="https://github.com/meshcore-dev/meshcore_py"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
meshcore_py
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { AppSettings, AppSettingsUpdate, BotConfig } from '../../types';
|
||||
|
||||
const BotCodeEditor = lazy(() =>
|
||||
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
|
||||
);
|
||||
|
||||
const DEFAULT_BOT_CODE = `def bot(
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None,
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Process messages and optionally return a reply.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None for channel msgs)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel
|
||||
channel_key: 32-char hex key for channels, None for DMs
|
||||
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
||||
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
|
||||
Returns:
|
||||
None for no reply, a string for a single reply,
|
||||
or a list of strings to send multiple messages in order
|
||||
"""
|
||||
# Don't reply to our own outgoing messages
|
||||
if is_outgoing:
|
||||
return None
|
||||
|
||||
# Example: Only respond in #bot channel to "!pling" command
|
||||
if channel_name == "#bot" and "!pling" in message_text.lower():
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
export function SettingsBotSection({
|
||||
appSettings,
|
||||
isMobileLayout,
|
||||
onSaveAppSettings,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
isMobileLayout: boolean;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [bots, setBots] = useState<BotConfig[]>([]);
|
||||
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
|
||||
const [editingNameId, setEditingNameId] = useState<string | null>(null);
|
||||
const [editingNameValue, setEditingNameValue] = useState('');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBots(appSettings.bots || []);
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ bots });
|
||||
toast.success('Bot settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save bot settings:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to save';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBot = () => {
|
||||
const newBot: BotConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Bot ${bots.length + 1}`,
|
||||
enabled: false,
|
||||
code: DEFAULT_BOT_CODE,
|
||||
};
|
||||
setBots([...bots, newBot]);
|
||||
setExpandedBotId(newBot.id);
|
||||
};
|
||||
|
||||
const handleDeleteBot = (botId: string) => {
|
||||
const bot = bots.find((b) => b.id === botId);
|
||||
if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) {
|
||||
if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBots(bots.filter((b) => b.id !== botId));
|
||||
if (expandedBotId === botId) {
|
||||
setExpandedBotId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBotEnabled = (botId: string) => {
|
||||
setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b)));
|
||||
};
|
||||
|
||||
const handleBotCodeChange = (botId: string, code: string) => {
|
||||
setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b)));
|
||||
};
|
||||
|
||||
const handleStartEditingName = (bot: BotConfig) => {
|
||||
setEditingNameId(bot.id);
|
||||
setEditingNameValue(bot.name);
|
||||
};
|
||||
|
||||
const handleFinishEditingName = () => {
|
||||
if (editingNameId && editingNameValue.trim()) {
|
||||
setBots(
|
||||
bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b))
|
||||
);
|
||||
}
|
||||
setEditingNameId(null);
|
||||
setEditingNameValue('');
|
||||
};
|
||||
|
||||
const handleResetBotCode = (botId: string) => {
|
||||
setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
|
||||
<p className="text-sm text-red-500">
|
||||
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
|
||||
sending to your radio; unexpected behavior may occur. Use with caution, and please report
|
||||
any bugs!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
|
||||
<p className="text-sm text-yellow-500">
|
||||
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
|
||||
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
|
||||
<p className="text-sm text-yellow-500">
|
||||
<strong>Don't wreck the mesh!</strong> Bots process ALL messages, including their
|
||||
own. Be careful of creating infinite loops!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Bots</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
|
||||
+ New Bot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bots.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-input rounded-md">
|
||||
<p className="text-muted-foreground mb-4">No bots configured</p>
|
||||
<Button type="button" variant="outline" onClick={handleAddBot}>
|
||||
Create your first bot
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{bots.map((bot) => (
|
||||
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('input, button')) return;
|
||||
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{expandedBotId === bot.id ? '▼' : '▶'}
|
||||
</span>
|
||||
|
||||
{editingNameId === bot.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingNameValue}
|
||||
onChange={(e) => setEditingNameValue(e.target.value)}
|
||||
onBlur={handleFinishEditingName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleFinishEditingName();
|
||||
if (e.key === 'Escape') {
|
||||
setEditingNameId(null);
|
||||
setEditingNameValue('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEditingName(bot);
|
||||
}}
|
||||
title="Click to rename"
|
||||
>
|
||||
{bot.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<label
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bot.enabled}
|
||||
onChange={() => handleToggleBotEnabled(bot.id)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Enabled</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteBot(bot.id);
|
||||
}}
|
||||
title="Delete bot"
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expandedBotId === bot.id && (
|
||||
<div className="p-3 space-y-3 border-t border-input">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define a <code className="bg-muted px-1 rounded">bot()</code> function that
|
||||
receives message data and optionally returns a reply.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResetBotCode(bot.id)}
|
||||
>
|
||||
Reset to Example
|
||||
</Button>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 md:h-96 rounded-md border border-input bg-[#282c34] flex items-center justify-center text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BotCodeEditor
|
||||
value={bot.code}
|
||||
onChange={(code) => handleBotCodeChange(bot.id, code)}
|
||||
id={`bot-code-${bot.id}`}
|
||||
height={isMobileLayout ? '256px' : '384px'}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<strong>Available:</strong> Standard Python libraries and any modules installed in the
|
||||
server environment.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Limits:</strong> 10 second timeout per bot.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
|
||||
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
|
||||
serially, with a two-second delay between messages to prevent repeater collision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Bot Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsConnectivitySection({
|
||||
appSettings,
|
||||
health,
|
||||
pageMode,
|
||||
onSaveAppSettings,
|
||||
onReboot,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
health: HealthStatus | null;
|
||||
pageMode: boolean;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [maxRadioContacts, setMaxRadioContacts] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const update: AppSettingsUpdate = {};
|
||||
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
|
||||
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
|
||||
update.max_radio_contacts = newMaxRadioContacts;
|
||||
}
|
||||
if (Object.keys(update).length > 0) {
|
||||
await onSaveAppSettings(update);
|
||||
}
|
||||
toast.success('Connectivity settings saved');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReboot = async () => {
|
||||
if (
|
||||
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
setRebooting(true);
|
||||
|
||||
try {
|
||||
await onReboot();
|
||||
if (!pageMode) {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
|
||||
} finally {
|
||||
setRebooting(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<Label>Connection</Label>
|
||||
{health?.connection_info ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
|
||||
{health.connection_info}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span>Not connected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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">
|
||||
Favorite contacts load first, then recent non-repeater contacts until this limit is
|
||||
reached (1-1000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReboot}
|
||||
disabled={rebooting || busy}
|
||||
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
getReopenLastConversationEnabled,
|
||||
setReopenLastConversationEnabled,
|
||||
} from '../../utils/lastViewedConversation';
|
||||
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
health,
|
||||
onSaveAppSettings,
|
||||
onHealthRefresh,
|
||||
onLocalLabelChange,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
health: HealthStatus | null;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
}, [appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
setReopenLastConversation(getReopenLastConversationEnabled());
|
||||
}, []);
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const days = parseInt(retentionDays, 10);
|
||||
if (isNaN(days) || days < 1) {
|
||||
toast.error('Invalid retention days', {
|
||||
description: 'Retention days must be at least 1',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCleaning(true);
|
||||
|
||||
try {
|
||||
const result = await api.runMaintenance({ pruneUndecryptedDays: days });
|
||||
toast.success('Database cleanup complete', {
|
||||
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
|
||||
});
|
||||
await onHealthRefresh();
|
||||
} catch (err) {
|
||||
console.error('Failed to run maintenance:', err);
|
||||
toast.error('Database cleanup failed', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurgeDecryptedRawPackets = async () => {
|
||||
setPurgingDecryptedRaw(true);
|
||||
|
||||
try {
|
||||
const result = await api.runMaintenance({ purgeLinkedRawPackets: true });
|
||||
toast.success('Decrypted raw packets purged', {
|
||||
description: `Deleted ${result.packets_deleted} raw packet${result.packets_deleted === 1 ? '' : 's'}`,
|
||||
});
|
||||
await onHealthRefresh();
|
||||
} catch (err) {
|
||||
console.error('Failed to purge decrypted raw packets:', err);
|
||||
toast.error('Failed to purge decrypted raw packets', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setPurgingDecryptedRaw(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
|
||||
toast.success('Database settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save database settings:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||
setReopenLastConversation(enabled);
|
||||
setReopenLastConversationEnabled(enabled);
|
||||
if (enabled) {
|
||||
captureLastViewedConversationFromHash();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Database size</span>
|
||||
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
|
||||
</div>
|
||||
|
||||
{health?.oldest_undecrypted_timestamp ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
||||
<span className="font-medium">
|
||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||
days old)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
||||
<span className="text-muted-foreground">None</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Delete Undecrypted Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently deletes stored raw packets containing DMs and channel messages that have not
|
||||
yet been decrypted. These packets are retained in case you later obtain the correct key —
|
||||
once deleted, these messages can never be recovered or decrypted.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="retention-days" className="text-xs">
|
||||
Older than (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Purge Archival Raw Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||
visible in your chat history.{' '}
|
||||
<em className="text-muted-foreground/80">
|
||||
This will not affect any displayed messages or app functionality.
|
||||
</em>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePurgeDecryptedRawPackets}
|
||||
disabled={purgingDecryptedRaw}
|
||||
className="w-full border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10"
|
||||
>
|
||||
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>DM Decryption</Label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoDecryptOnAdvert}
|
||||
onChange={(e) => setAutoDecryptOnAdvert(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the server will automatically try to decrypt stored DM packets when a new
|
||||
contact sends an advertisement. This may cause brief delays on large packet backlogs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Interface</Label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This applies only to this device/browser. It does not sync to server settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Local Label</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={localLabelText}
|
||||
onChange={(e) => {
|
||||
const text = e.target.value;
|
||||
setLocalLabelText(text);
|
||||
setLocalLabel(text, localLabelColor);
|
||||
onLocalLabelChange?.({ text, color: localLabelColor });
|
||||
}}
|
||||
placeholder="e.g. Home Base, Field Radio 2"
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={localLabelColor}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
setLocalLabelColor(color);
|
||||
setLocalLabel(localLabelText, color);
|
||||
onLocalLabelChange?.({ text: localLabelText, color });
|
||||
}}
|
||||
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display a colored banner at the top of the page to identify this instance. This applies
|
||||
only to this device/browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsIdentitySection({
|
||||
config,
|
||||
health,
|
||||
appSettings,
|
||||
pageMode,
|
||||
onSave,
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onAdvertise,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
config: RadioConfig;
|
||||
health: HealthStatus | null;
|
||||
appSettings: AppSettings;
|
||||
pageMode: boolean;
|
||||
onSave: (update: RadioConfigUpdate) => Promise<void>;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
const [advertising, setAdvertising] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setName(config.name);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSaveIdentity = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const update: RadioConfigUpdate = { name };
|
||||
await onSave(update);
|
||||
|
||||
const hours = parseInt(advertIntervalHours, 10);
|
||||
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
||||
if (newAdvertInterval !== appSettings.advert_interval) {
|
||||
await onSaveAppSettings({ advert_interval: newAdvertInterval });
|
||||
}
|
||||
|
||||
toast.success('Identity settings saved');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrivateKey = async () => {
|
||||
if (!privateKey.trim()) {
|
||||
setError('Private key is required');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await onSetPrivateKey(privateKey.trim());
|
||||
setPrivateKey('');
|
||||
toast.success('Private key set, rebooting...');
|
||||
setRebooting(true);
|
||||
await onReboot();
|
||||
if (!pageMode) {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set private key');
|
||||
} finally {
|
||||
setRebooting(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvertise = async () => {
|
||||
setAdvertising(true);
|
||||
try {
|
||||
await onAdvertise();
|
||||
} finally {
|
||||
setAdvertising(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="public-key">Public Key</Label>
|
||||
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Radio Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="advert-interval"
|
||||
type="number"
|
||||
min="0"
|
||||
value={advertIntervalHours}
|
||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||
Recommended: 24 hours or higher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveIdentity} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Identity Settings'}
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||
<Input
|
||||
id="private-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="64-character hex private key"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSetPrivateKey}
|
||||
disabled={busy || rebooting || !privateKey.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{busy || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Send Advertisement</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a flood advertisement to announce your presence on the mesh network.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleAdvertise}
|
||||
disabled={advertising || !health?.radio_connected}
|
||||
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
|
||||
>
|
||||
{advertising ? 'Sending...' : 'Send Advertisement'}
|
||||
</Button>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { UseLoopbackReturn } from '../../hooks/useLoopback';
|
||||
|
||||
export function SettingsLoopbackSection({
|
||||
loopback,
|
||||
className,
|
||||
}: {
|
||||
loopback: UseLoopbackReturn;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
transportType,
|
||||
serialAvailable,
|
||||
bluetoothAvailable,
|
||||
connectSerial,
|
||||
connectBluetooth,
|
||||
disconnect,
|
||||
} = loopback;
|
||||
|
||||
const [baudRate, setBaudRate] = useState('115200');
|
||||
const [selectedTransport, setSelectedTransport] = useState<'serial' | 'ble'>(
|
||||
serialAvailable ? 'serial' : 'ble'
|
||||
);
|
||||
|
||||
const isConnecting = status === 'connecting';
|
||||
const isConnected = status === 'connected';
|
||||
const busy = isConnecting || isConnected;
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (selectedTransport === 'serial') {
|
||||
await connectSerial(parseInt(baudRate, 10) || 115200);
|
||||
} else {
|
||||
await connectBluetooth();
|
||||
}
|
||||
};
|
||||
|
||||
const neitherAvailable = !serialAvailable && !bluetoothAvailable;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No direct radio connection detected. You can bridge a radio connected to{' '}
|
||||
<em>this browser's device</em> via Web Serial or Web Bluetooth.
|
||||
</p>
|
||||
|
||||
{neitherAvailable && (
|
||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/30 p-3 text-sm text-yellow-200">
|
||||
Your browser does not support Web Serial or Web Bluetooth. Use Chrome or Edge on a secure
|
||||
context (HTTPS or localhost).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!neitherAvailable && (
|
||||
<>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm">
|
||||
Connected via {transportType === 'serial' ? 'Serial' : 'Bluetooth'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={disconnect} className="w-full">
|
||||
Disconnect Loopback
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Transport selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Transport</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={selectedTransport === 'serial' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!serialAvailable || busy}
|
||||
onClick={() => setSelectedTransport('serial')}
|
||||
>
|
||||
Serial
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTransport === 'ble' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={!bluetoothAvailable || busy}
|
||||
onClick={() => setSelectedTransport('ble')}
|
||||
>
|
||||
Bluetooth
|
||||
</Button>
|
||||
</div>
|
||||
{!serialAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web Serial not available in this browser
|
||||
</p>
|
||||
)}
|
||||
{!bluetoothAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web Bluetooth not available in this browser
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud rate (serial only) */}
|
||||
{selectedTransport === 'serial' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loopback-baud">Baud Rate</Label>
|
||||
<Input
|
||||
id="loopback-baud"
|
||||
type="number"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button onClick={handleConnect} disabled={busy} className="w-full">
|
||||
{isConnecting ? 'Connecting...' : 'Connect via Loopback'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsMqttSection({
|
||||
appSettings,
|
||||
health,
|
||||
onSaveAppSettings,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
health: HealthStatus | null;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [mqttBrokerHost, setMqttBrokerHost] = useState('');
|
||||
const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
|
||||
const [mqttUsername, setMqttUsername] = useState('');
|
||||
const [mqttPassword, setMqttPassword] = useState('');
|
||||
const [mqttUseTls, setMqttUseTls] = useState(false);
|
||||
const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
|
||||
const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
|
||||
const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
|
||||
const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
|
||||
setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
|
||||
setMqttUsername(appSettings.mqtt_username ?? '');
|
||||
setMqttPassword(appSettings.mqtt_password ?? '');
|
||||
setMqttUseTls(appSettings.mqtt_use_tls ?? false);
|
||||
setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
|
||||
setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
|
||||
setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
|
||||
setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const update: AppSettingsUpdate = {
|
||||
mqtt_broker_host: mqttBrokerHost,
|
||||
mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
|
||||
mqtt_username: mqttUsername,
|
||||
mqtt_password: mqttPassword,
|
||||
mqtt_use_tls: mqttUseTls,
|
||||
mqtt_tls_insecure: mqttTlsInsecure,
|
||||
mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
|
||||
mqtt_publish_messages: mqttPublishMessages,
|
||||
mqtt_publish_raw_packets: mqttPublishRawPackets,
|
||||
};
|
||||
await onSaveAppSettings(update);
|
||||
toast.success('MQTT settings saved');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
{health?.mqtt_status === 'connected' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-green-400">Connected</span>
|
||||
</div>
|
||||
) : health?.mqtt_status === 'disconnected' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-sm text-red-400">Disconnected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
<span className="text-sm text-muted-foreground">Disabled</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-host">Broker Host</Label>
|
||||
<Input
|
||||
id="mqtt-host"
|
||||
type="text"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={mqttBrokerHost}
|
||||
onChange={(e) => setMqttBrokerHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-port">Broker Port</Label>
|
||||
<Input
|
||||
id="mqtt-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={mqttBrokerPort}
|
||||
onChange={(e) => setMqttBrokerPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-username">Username</Label>
|
||||
<Input
|
||||
id="mqtt-username"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={mqttUsername}
|
||||
onChange={(e) => setMqttUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-password">Password</Label>
|
||||
<Input
|
||||
id="mqtt-password"
|
||||
type="password"
|
||||
placeholder="Optional"
|
||||
value={mqttPassword}
|
||||
onChange={(e) => setMqttPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttUseTls}
|
||||
onChange={(e) => setMqttUseTls(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Use TLS</span>
|
||||
</label>
|
||||
|
||||
{mqttUseTls && (
|
||||
<>
|
||||
<label className="flex items-center gap-3 cursor-pointer ml-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttTlsInsecure}
|
||||
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Skip certificate verification</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Allow self-signed or untrusted broker certificates
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
|
||||
<Input
|
||||
id="mqtt-prefix"
|
||||
type="text"
|
||||
value={mqttTopicPrefix}
|
||||
onChange={(e) => setMqttTopicPrefix(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Topics: {mqttTopicPrefix || 'meshcore'}/dm:<key>, {mqttTopicPrefix || 'meshcore'}
|
||||
/gm:<key>, {mqttTopicPrefix || 'meshcore'}
|
||||
/raw/...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttPublishMessages}
|
||||
onChange={(e) => setMqttPublishMessages(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Publish Messages</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Forward decrypted DM and channel messages
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mqttPublishRawPackets}
|
||||
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">Publish Raw Packets</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save MQTT Settings'}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||
import type { RadioConfig, RadioConfigUpdate } from '../../types';
|
||||
|
||||
export function SettingsRadioSection({
|
||||
config,
|
||||
pageMode,
|
||||
onSave,
|
||||
onReboot,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
config: RadioConfig;
|
||||
pageMode: boolean;
|
||||
onSave: (update: RadioConfigUpdate) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
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 [gettingLocation, setGettingLocation] = useState(false);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const currentPreset = useMemo(() => {
|
||||
const freqNum = parseFloat(freq);
|
||||
const bwNum = parseFloat(bw);
|
||||
const sfNum = parseInt(sf, 10);
|
||||
const crNum = parseInt(cr, 10);
|
||||
|
||||
for (const preset of RADIO_PRESETS) {
|
||||
if (
|
||||
preset.freq === freqNum &&
|
||||
preset.bw === bwNum &&
|
||||
preset.sf === sfNum &&
|
||||
preset.cr === crNum
|
||||
) {
|
||||
return preset.name;
|
||||
}
|
||||
}
|
||||
return 'custom';
|
||||
}, [freq, bw, sf, cr]);
|
||||
|
||||
const handlePresetChange = (presetName: string) => {
|
||||
if (presetName === 'custom') return;
|
||||
const preset = RADIO_PRESETS.find((p) => p.name === presetName);
|
||||
if (preset) {
|
||||
setFreq(String(preset.freq));
|
||||
setBw(String(preset.bw));
|
||||
setSf(String(preset.sf));
|
||||
setCr(String(preset.cr));
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast.error('Geolocation not supported', {
|
||||
description: 'Your browser does not support geolocation',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setGettingLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLat(position.coords.latitude.toFixed(6));
|
||||
setLon(position.coords.longitude.toFixed(6));
|
||||
setGettingLocation(false);
|
||||
toast.success('Location updated');
|
||||
},
|
||||
(err) => {
|
||||
setGettingLocation(false);
|
||||
toast.error('Failed to get location', {
|
||||
description: err.message,
|
||||
});
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const update: RadioConfigUpdate = {
|
||||
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);
|
||||
toast.success('Radio config saved, rebooting...');
|
||||
setRebooting(true);
|
||||
await onReboot();
|
||||
if (!pageMode) {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setRebooting(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preset">Preset</Label>
|
||||
<select
|
||||
id="preset"
|
||||
value={currentPreset}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="custom">Custom</option>
|
||||
{RADIO_PRESETS.map((preset) => (
|
||||
<option key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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="5"
|
||||
max="8"
|
||||
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 />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Location</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGetLocation}
|
||||
disabled={gettingLocation}
|
||||
>
|
||||
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lat" className="text-xs text-muted-foreground">
|
||||
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" className="text-xs text-muted-foreground">
|
||||
Longitude
|
||||
</Label>
|
||||
<Input
|
||||
id="lon"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lon}
|
||||
onChange={(e) => setLon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
|
||||
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { api } from '../../api';
|
||||
import type { StatisticsResponse } from '../../types';
|
||||
|
||||
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setStatsLoading(true);
|
||||
api.getStatistics().then(
|
||||
(data) => {
|
||||
if (!cancelled) {
|
||||
setStats(data);
|
||||
setStatsLoading(false);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setStatsLoading(false);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{statsLoading && !stats ? (
|
||||
<div className="py-8 text-center text-muted-foreground">Loading statistics...</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-6">
|
||||
{/* Network */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Network</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.contact_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Contacts</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.repeater_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Repeaters</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.channel_count}</div>
|
||||
<div className="text-xs text-muted-foreground">Channels</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Messages */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Messages</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_dms}</div>
|
||||
<div className="text-xs text-muted-foreground">Direct Messages</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_channel_messages}</div>
|
||||
<div className="text-xs text-muted-foreground">Channel Messages</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_outgoing}</div>
|
||||
<div className="text-xs text-muted-foreground">Sent (Outgoing)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-green-500">Decrypted</span>
|
||||
<span className="font-medium text-green-500">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-yellow-500">Undecrypted</span>
|
||||
<span className="font-medium text-yellow-500">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left font-normal pb-1"></th>
|
||||
<th className="text-right font-normal pb-1">1h</th>
|
||||
<th className="text-right font-normal pb-1">24h</th>
|
||||
<th className="text-right font-normal pb-1">7d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1">Contacts heard</td>
|
||||
<td className="text-right py-1">{stats.contacts_heard.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.contacts_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.contacts_heard.last_week}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">Repeaters heard</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.busiest_channels_24h.map((ch, i) => (
|
||||
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span>
|
||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
||||
{ch.channel_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export type SettingsSection =
|
||||
| 'radio'
|
||||
| 'identity'
|
||||
| 'connectivity'
|
||||
| 'loopback'
|
||||
| 'mqtt'
|
||||
| 'database'
|
||||
| 'bot'
|
||||
| 'statistics'
|
||||
| 'about';
|
||||
|
||||
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
'radio',
|
||||
'identity',
|
||||
'connectivity',
|
||||
'loopback',
|
||||
'database',
|
||||
'bot',
|
||||
'mqtt',
|
||||
'statistics',
|
||||
'about',
|
||||
];
|
||||
|
||||
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: '📻 Radio',
|
||||
identity: '🪪 Identity',
|
||||
connectivity: '📡 Connectivity',
|
||||
loopback: '🔁 Loopback',
|
||||
database: '🗄️ Database & Interface',
|
||||
bot: '🤖 Bots',
|
||||
mqtt: '📤 MQTT',
|
||||
statistics: '📊 Statistics',
|
||||
about: 'About',
|
||||
};
|
||||