mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e00373aa | ||
|
|
2f0d35748a | ||
|
|
7db2974481 | ||
|
|
0a20929df6 | ||
|
|
30f6f95d8e | ||
|
|
9e8cf56b31 | ||
|
|
fb535298be | ||
|
|
1f2903fc2d | ||
|
|
6466a5c355 | ||
|
|
f8e88b3737 | ||
|
|
a13e241636 | ||
|
|
8c1a58b293 | ||
|
|
bf53e8a4cb | ||
|
|
c6cd209192 | ||
|
|
e5c7ebb388 | ||
|
|
e37632de3f | ||
|
|
d36c5e3e32 | ||
|
|
bc7506b0d9 | ||
|
|
38c7277c9d | ||
|
|
20d0bd92bb | ||
|
|
e0df30b5f0 | ||
|
|
83635845b6 | ||
|
|
2e705538fd | ||
|
|
4363fd2a73 | ||
|
|
5bd3205de5 | ||
|
|
bcde3bd9d5 | ||
|
|
15a8c637e4 | ||
|
|
d38efc0421 | ||
|
|
b311c406da | ||
|
|
b5e9e4d04c | ||
|
|
ce87dd9376 | ||
|
|
5273d9139d | ||
|
|
04ac3d6ed4 | ||
|
|
1a1d3059db | ||
|
|
633510b7de | ||
|
|
7f4c1e94fd | ||
|
|
a06fefb34e | ||
|
|
4e0b6a49b0 | ||
|
|
e993009782 | ||
|
|
ad7028e508 | ||
|
|
ce9bbd1059 | ||
|
|
0c35601af3 | ||
|
|
93369f8d64 | ||
|
|
e7d1f28076 | ||
|
|
472b4a5ed2 | ||
|
|
314e4c7fff | ||
|
|
528a94d2bd | ||
|
|
fa1c086f5f | ||
|
|
d8bb747152 | ||
|
|
18a465fde8 | ||
|
|
c52e00d2b7 | ||
|
|
e17d1ba4b4 | ||
|
|
48a49ce48d | ||
|
|
9d1676818f | ||
|
|
b5edd00220 | ||
|
|
d3a7b7ce07 | ||
|
|
42ca242ee1 | ||
|
|
3e7e0669c5 | ||
|
|
bee273ab56 | ||
|
|
1842bcf43e | ||
|
|
7c68973e30 | ||
|
|
c9ede1f71f | ||
|
|
42e9628d98 | ||
|
|
1bf760121d | ||
|
|
bb4a601788 | ||
|
|
d0ed3484ce | ||
|
|
738e0b9815 | ||
|
|
97997e23e8 | ||
|
|
eaee66f836 | ||
|
|
9a99d3f17e | ||
|
|
73e717fbd8 | ||
|
|
dc87fa42b2 | ||
|
|
f650e0ab34 | ||
|
|
39b745f8b0 | ||
|
|
18e1408292 | ||
|
|
3e941a5b20 | ||
|
|
a000fc88a5 | ||
|
|
def7c8e29e | ||
|
|
9388e1f506 | ||
|
|
81bdfe09fa | ||
|
|
5e94b14b45 | ||
|
|
c3f1a43a80 | ||
|
|
3316f00271 | ||
|
|
319b84455b | ||
|
|
f107dce920 | ||
|
|
ec5b9663b2 | ||
|
|
19d7c3c98c | ||
|
|
ae0ef90fe2 | ||
|
|
56e5e0d278 | ||
|
|
5d509a88d9 | ||
|
|
946006bd7f | ||
|
|
344cee5508 | ||
|
|
0d671f361d | ||
|
|
2d781cad56 | ||
|
|
088dcb39d6 | ||
|
|
b1e3e71b68 | ||
|
|
557af55ee8 | ||
|
|
9421c10e8f | ||
|
|
b157ee14e4 | ||
|
|
e03ddcaaa7 | ||
|
|
6832516b40 | ||
|
|
5bfd9f4af2 | ||
|
|
463a0c9084 | ||
|
|
811c7e7349 | ||
|
|
0c5b37c07c | ||
|
|
7e384c12bb | ||
|
|
48bc8c6337 | ||
|
|
c3d7b8f79a | ||
|
|
b5c4413e63 | ||
|
|
9fbdbaa174 | ||
|
|
e99e522573 | ||
|
|
9d806c608b | ||
|
|
5a9489eff1 | ||
|
|
beb28b1f31 | ||
|
|
7d688fa5f8 | ||
|
|
09b68c37ba | ||
|
|
df7dbad73d | ||
|
|
060fb1ef59 | ||
|
|
b14e99ff24 | ||
|
|
77523c1b15 | ||
|
|
9673b25ab3 | ||
|
|
2732506f3c | ||
|
|
523fe3e28e | ||
|
|
3663db6ed3 | ||
|
|
5832fbd2c9 | ||
|
|
655066ed73 | ||
|
|
5cb5c2ad25 | ||
|
|
69a6922827 | ||
|
|
806252ec7e | ||
|
|
2236132df4 | ||
|
|
564cd65496 | ||
|
|
b3625b4937 | ||
|
|
20af50585b | ||
|
|
d776f3d09b | ||
|
|
075debc51b | ||
|
|
34318e4814 | ||
|
|
48dab293ae | ||
|
|
76d11b01a7 | ||
|
|
69c812cfd4 | ||
|
|
2257c091e8 | ||
|
|
55fb2390de | ||
|
|
0b91fb18bd | ||
|
|
8948f2e504 | ||
|
|
5c413bf949 | ||
|
|
b0ffa28e46 | ||
|
|
f97c846378 | ||
|
|
e0d7c8a083 | ||
|
|
11ce2be5fa | ||
|
|
1fc041538e | ||
|
|
0ac8e97ea2 | ||
|
|
e6743d2098 | ||
|
|
f472ff7cab | ||
|
|
7ac220aee1 | ||
|
|
43e38ecc5b | ||
|
|
99eddfc2ef | ||
|
|
f9eb6ebd98 | ||
|
|
8f59606867 | ||
|
|
d214da41c6 | ||
|
|
da22eb5c48 | ||
|
|
94546f90a4 | ||
|
|
f82cadb4e1 | ||
|
|
f60c656566 |
74
.github/workflows/all-quality.yml
vendored
Normal file
74
.github/workflows/all-quality.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: All Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: uv sync --dev
|
||||
|
||||
- name: Backend lint
|
||||
run: uv run ruff check app/ tests/
|
||||
|
||||
- name: Backend format check
|
||||
run: uv run ruff format --check app/ tests/
|
||||
|
||||
- name: Backend typecheck
|
||||
run: uv run pyright app/
|
||||
|
||||
- name: Backend tests
|
||||
run: PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend format check
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend tests
|
||||
run: npm run test:run
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
frontend/node_modules/
|
||||
frontend/package-lock.json
|
||||
frontend/test-results/
|
||||
|
||||
# Frontend build output (built from source by end users)
|
||||
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -21,35 +21,37 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
|
||||
|
||||
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise)
|
||||
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ Frontend (React) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼─────────────────────────────────────┘
|
||||
│ HTTP + WebSocket (/api/*)
|
||||
┌───────────────────────────┼──────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
|
||||
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
|
||||
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
|
||||
│ ↓ └───────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ RadioManager + Event Handlers │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ Routers │→ │ Services │→ │ Repositories │→ │ SQLite DB │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │
|
||||
│ ↓ │ ┌───────────┐ │
|
||||
│ ┌──────────────────────────┐ └──────────────→ │ WebSocket │ │
|
||||
│ │ Radio runtime seam + │ │ Manager │ │
|
||||
│ │ RadioManager lifecycle │ └───────────┘ │
|
||||
│ │ / event adapters │ │
|
||||
│ └──────────────────────────┘ │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ Serial / TCP / BLE
|
||||
┌──────┴──────┐
|
||||
@@ -75,13 +77,15 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
|
||||
- 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
|
||||
- Fanout integrations (MQTT, bots, webhooks, Apprise) — see `app/fanout/AGENTS_fanout.md`
|
||||
- Fanout integrations (MQTT, bots, webhooks, Apprise, SQS) — see `app/fanout/AGENTS_fanout.md`
|
||||
- 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.
|
||||
|
||||
Radio startup/setup is one place where that frontend bubbling is intentional: if post-connect setup hangs past its timeout, the backend both logs the failure and pushes a toast instructing the operator to reboot the radio and restart the server.
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Store-and-serve**: Backend stores all packets even when no client is connected
|
||||
@@ -91,12 +95,21 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
|
||||
5. **Offline-capable**: Radio operates independently; server syncs when connected
|
||||
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer fewer, stronger modules over many tiny wrapper files.
|
||||
- Split code only when the new module owns a real invariant, workflow, or contract.
|
||||
- Avoid "enterprise" indirection layers whose main job is forwarding, renaming, or prop bundling.
|
||||
- For this repo, "locally dense but semantically obvious" is better than context scattered across many files.
|
||||
- Use typed contracts at important boundaries such as API payloads, WebSocket events, and repository writes.
|
||||
- Refactors should be behavior-preserving slices with tests around the moved seam, not aesthetic reshuffles.
|
||||
|
||||
## 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.
|
||||
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.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. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
|
||||
|
||||
## Intentional Packet Handling Decision
|
||||
@@ -107,15 +120,28 @@ Raw packet handling uses two identities by design:
|
||||
|
||||
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
|
||||
|
||||
Channel metadata updates may also fan out as `channel` WebSocket events (full `Channel` payload) so clients can reflect local-only channel state such as regional flood-scope overrides without a full refetch.
|
||||
|
||||
## 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`.
|
||||
- Paths are keyed per contact + path + hop count, 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_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
|
||||
## Path Hash Modes
|
||||
|
||||
MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
|
||||
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Incoming Messages
|
||||
@@ -129,7 +155,7 @@ To improve repeater disambiguation in the network visualizer, the backend stores
|
||||
|
||||
1. User types message → clicks send
|
||||
2. `api.sendChannelMessage()` → POST to backend
|
||||
3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()`
|
||||
3. Backend route delegates to service-layer send orchestration, which acquires the radio lock and calls MeshCore commands
|
||||
4. Message stored in database with `outgoing=true`
|
||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||
|
||||
@@ -149,16 +175,17 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ ├── AGENTS.md # Backend documentation
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── services/ # Shared backend orchestration/domain services, including radio_runtime access seam
|
||||
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ ├── websocket.py # Real-time broadcasts
|
||||
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Main component
|
||||
│ │ ├── App.tsx # Frontend composition entry (hooks → AppShell)
|
||||
│ │ ├── api.ts # REST client
|
||||
│ │ ├── useWebSocket.ts # WebSocket hook
|
||||
│ │ └── components/
|
||||
@@ -196,7 +223,7 @@ MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev # http://localhost:5173, proxies /api to :8000
|
||||
```
|
||||
|
||||
@@ -210,7 +237,7 @@ Terminal 2: `cd frontend && npm run dev`
|
||||
In production, the FastAPI backend serves the compiled frontend. Build the frontend first:
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
cd frontend && npm ci && npm run build && cd ..
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
@@ -237,8 +264,11 @@ Key test files:
|
||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
||||
- `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_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
||||
|
||||
@@ -260,14 +290,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||
| GET | `/api/radio/config` | Radio configuration |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
|
||||
| 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/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| GET | `/api/contacts/name-detail` | Channel activity summary for a sender name without a resolved key |
|
||||
| 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 |
|
||||
@@ -278,7 +311,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| 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}/routing-override` | Set or clear a forced routing override |
|
||||
| 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 |
|
||||
@@ -295,6 +328,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/sync` | Pull from radio |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
@@ -340,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- Stored as 32-character hex string (TEXT PRIMARY KEY)
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||
|
||||
### Message Types
|
||||
|
||||
@@ -361,12 +396,14 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
||||
|
||||
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
|
||||
|
||||
### Fanout Bus (MQTT, Bots, Webhooks, Apprise)
|
||||
### Fanout Bus (MQTT, Bots, Webhooks, Apprise, SQS)
|
||||
|
||||
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
|
||||
`broadcast_event()` in `websocket.py` dispatches `message` and `raw_packet` events to the fanout manager. See `app/fanout/AGENTS_fanout.md` for full architecture details.
|
||||
|
||||
Community MQTT forwards raw packets only. Its derived `path` field, when present on direct packets, is a comma-separated list of hop identifiers as reported by the packet format. Token width therefore varies with the packet's path hash mode; it is intentionally not a flat per-byte rendering.
|
||||
|
||||
### Server-Side Decryption
|
||||
|
||||
The server can decrypt packets using stored keys, both in real-time and for historical packets.
|
||||
@@ -408,9 +445,26 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
**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`, `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
**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`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
|
||||
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.
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### `meshcore_py` advert parsing can crash on malformed/truncated RF log packets
|
||||
|
||||
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
|
||||
|
||||
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
|
||||
|
||||
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
|
||||
|
||||
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
|
||||
|
||||
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.
|
||||
|
||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -1,3 +1,106 @@
|
||||
## [3.2.0] - 2026-03-12
|
||||
|
||||
Feature: Improve ambiguous-sender DM handling and visibility
|
||||
Feature: Allow for toggling of node GPS broadcast
|
||||
Feature: Add path width to bot and move example to full kwargs
|
||||
Feature: Improve node map color contrast
|
||||
Bugfix: More accurate tracking of contact data
|
||||
Bugfix: Misc. frontend performance and bugfixes
|
||||
Misc: Clearer warnings on user-key linkage
|
||||
Misc: Documentation improvements
|
||||
|
||||
## [3.1.1] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.1.0] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.0.0] - 2026-03-10
|
||||
|
||||
Feature: Custom regions per-channel
|
||||
Feature: Add custom contact pathing
|
||||
Feature: Corrupt packets are more clear that they're corrupt
|
||||
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||
Feature: More consistent icons
|
||||
Feature: Add per-channel local notifications
|
||||
Feature: New themes
|
||||
Feature: Massive codebase refactor and overhaul
|
||||
Bugfix: Fix packet parsing for trace packets
|
||||
Bugfix: Refetch channels on reconnect
|
||||
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||
Bugfix: Timestamps in logs
|
||||
Bugfix: Correct wrong clock sync command
|
||||
Misc: Improve bot error bubble up
|
||||
Misc: Update to non-lib-included meshcore-decoder version
|
||||
Misc: Revise refactors to be more LLM friendly
|
||||
Misc: Fix script executability
|
||||
Misc: Better logging format with timestamp
|
||||
Misc: Repeater advert buttons separate flood and one-hop
|
||||
Misc: Preserve repeater pane on navigation away
|
||||
Misc: Clearer iconography and coloring for status bar buttons
|
||||
Misc: Search bar to top bar
|
||||
|
||||
## [2.7.9] - 2026-03-08
|
||||
|
||||
Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
|
||||
## [2.7.8] - 2026-03-08
|
||||
|
||||
|
||||
|
||||
## [2.7.8] - 2026-03-08
|
||||
|
||||
Bugfix: Improve frontend asset resolution and fixup the build/push script
|
||||
|
||||
## [2.7.1] - 2026-03-08
|
||||
|
||||
Bugfix: Fix historical DM packet length passing
|
||||
Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
||||
Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
||||
|
||||
## [2.7.0] - 2026-03-08
|
||||
|
||||
Feature: Multibyte path support
|
||||
Feature: Add multibyte statistics to statistics pane
|
||||
Feature: Add path bittage to contact info pane
|
||||
Feature: Put tools in a collapsible
|
||||
|
||||
## [2.6.1] - 2026-03-08
|
||||
|
||||
Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
||||
|
||||
## [2.6.0] - 2026-03-08
|
||||
|
||||
Feature: A11y improvements
|
||||
Feature: New themes
|
||||
Feature: Backfill channel sender identity when available
|
||||
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
||||
Bugfix: Unreads now respect blocklist
|
||||
Bugfix: Unreads can't accumulate on an open thread
|
||||
Bugfix: Channel name in broadcasts
|
||||
Bugfix: Add missing httpx dependency
|
||||
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
||||
Misc: Improved button signage for app movement
|
||||
Misc: Test, performance, and documentation improvements
|
||||
|
||||
## [2.5.0] - 2026-03-05
|
||||
|
||||
Feature: Far better accessibility across the app (with far to go)
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
|
||||
194
LICENSES.md
194
LICENSES.md
@@ -91,6 +91,192 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
</details>
|
||||
|
||||
### boto3 (1.42.66) — Apache-2.0
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### fastapi (0.128.0) — MIT
|
||||
|
||||
<details>
|
||||
@@ -144,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.2.5) — MIT
|
||||
### meshcore (2.2.29) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
@@ -562,7 +748,7 @@ THE SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### @michaelhart/meshcore-decoder (0.2.7) — MIT
|
||||
### @michaelhart/meshcore-decoder (0.3.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
@@ -779,7 +965,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### @uiw/react-codemirror (4.25.7) — MIT
|
||||
### @uiw/react-codemirror (4.25.8) — MIT
|
||||
|
||||
*License file not found in package.*
|
||||
|
||||
@@ -1141,7 +1327,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore-hashtag-cracker (1.7.0) — MIT
|
||||
### meshcore-hashtag-cracker (1.11.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
18
README.md
18
README.md
@@ -9,9 +9,10 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only)
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
**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. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. 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 is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||
|
||||
@@ -24,7 +25,7 @@ If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- Node.js LTS or current (20, 22, 24, 25)
|
||||
- [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
|
||||
|
||||
@@ -76,7 +77,7 @@ cd Remote-Terminal-for-MeshCore
|
||||
uv sync
|
||||
|
||||
# Build frontend
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
cd frontend && npm ci && npm run build && cd ..
|
||||
|
||||
# Run server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -174,7 +175,7 @@ uv run uvicorn app.main:app --reload
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
|
||||
npm run build # Production build to dist/
|
||||
```
|
||||
@@ -223,9 +224,16 @@ npm run build # build the frontend
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second `get_msg()` safety net.
|
||||
|
||||
## Additional Setup
|
||||
|
||||
<details>
|
||||
@@ -279,7 +287,7 @@ sudo -u remoteterm uv sync
|
||||
|
||||
# Build frontend (required for the backend to serve the web UI)
|
||||
cd /opt/remoteterm/frontend
|
||||
sudo -u remoteterm npm install
|
||||
sudo -u remoteterm npm ci
|
||||
sudo -u remoteterm npm run build
|
||||
|
||||
# Install and start service
|
||||
|
||||
@@ -11,6 +11,14 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
- MeshCore Python library (`meshcore` from PyPI)
|
||||
- PyCryptodome
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer strong domain modules over layers of pass-through helpers.
|
||||
- Split code when the new module owns real policy, not just a nicer name.
|
||||
- Avoid wrapper services around globals unless they materially improve testability or reduce coupling.
|
||||
- Keep workflows locally understandable; do not scatter one reasoning unit across several files without a clear contract.
|
||||
- Typed write/read contracts are preferred over loose dict-shaped repository inputs.
|
||||
|
||||
## Backend Map
|
||||
|
||||
```text
|
||||
@@ -19,16 +27,28 @@ app/
|
||||
├── 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
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
├── services/ # Shared orchestration/domain services
|
||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||
├── radio.py # RadioManager transport/session state + lock management
|
||||
├── 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
|
||||
├── events.py # Typed WS event payload serialization
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
@@ -52,13 +72,13 @@ app/
|
||||
|
||||
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.
|
||||
3. Shared message-domain services create/update `messages` and shape WS payloads.
|
||||
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
|
||||
|
||||
### Outgoing messages
|
||||
|
||||
1. Send endpoints in `routers/messages.py` call MeshCore commands.
|
||||
2. Message is persisted as outgoing.
|
||||
1. Send endpoints in `routers/messages.py` validate requests and delegate to `services/message_send.py`.
|
||||
2. Service-layer send workflows call MeshCore commands, persist outgoing messages, and wire ACK tracking.
|
||||
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. Name changes require an explicit radio config update and are rare, but the `new_timestamp=true` resend path has no time window, so a mismatch is possible if the name was changed between the original send and a later resend.
|
||||
@@ -66,12 +86,22 @@ app/
|
||||
### Connection lifecycle
|
||||
|
||||
- `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.
|
||||
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
|
||||
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
|
||||
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
|
||||
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling.
|
||||
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Multibyte routing
|
||||
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
|
||||
### Read/unread state
|
||||
|
||||
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
|
||||
@@ -103,10 +133,11 @@ app/
|
||||
|
||||
### Fanout bus
|
||||
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise) are managed through the fanout bus (`app/fanout/`).
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
|
||||
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
|
||||
- Each integration is a `FanoutModule` with scope-based filtering.
|
||||
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
|
||||
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
@@ -115,16 +146,19 @@ app/
|
||||
- `GET /health`
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config`
|
||||
- `PATCH /radio/config`
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/disconnect`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
|
||||
### Contacts
|
||||
- `GET /contacts`
|
||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `GET /contacts/name-detail` — name-only activity summary for unresolved channel senders
|
||||
- `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
|
||||
@@ -135,7 +169,7 @@ app/
|
||||
- `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}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
@@ -153,6 +187,7 @@ app/
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
@@ -199,24 +234,30 @@ app/
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
|
||||
- `channel` — single channel upsert/update (payload: full `Channel`)
|
||||
- `channel_deleted` — channel removed from database (payload: `{ key }`)
|
||||
- `error` — toast notification (reconnect failure, missing private key, etc.)
|
||||
- `error` — toast notification (reconnect failure, missing private key, stuck radio startup, etc.)
|
||||
- `success` — toast notification (historical decrypt complete, etc.)
|
||||
|
||||
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Backend WS sends go through typed serialization in `events.py`. 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)
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact)
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
||||
|
||||
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
@@ -256,16 +297,20 @@ tests/
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
├── test_decoder.py # Packet parsing/decryption
|
||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── 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_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
@@ -276,6 +321,9 @@ tests/
|
||||
├── 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_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
@@ -285,9 +333,12 @@ tests/
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
```
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
@@ -17,6 +18,9 @@ class Settings(BaseSettings):
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
enable_message_poll_fallback: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -34,6 +38,11 @@ class Settings(BaseSettings):
|
||||
)
|
||||
if self.ble_address and not self.ble_pin:
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
if self.basic_auth_partially_configured:
|
||||
raise ValueError(
|
||||
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
|
||||
"must be set together."
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -44,6 +53,15 @@ class Settings(BaseSettings):
|
||||
return "ble"
|
||||
return "serial"
|
||||
|
||||
@property
|
||||
def basic_auth_enabled(self) -> bool:
|
||||
return bool(self.basic_auth_username and self.basic_auth_password)
|
||||
|
||||
@property
|
||||
def basic_auth_partially_configured(self) -> bool:
|
||||
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
|
||||
return any_credentials_set and not self.basic_auth_enabled
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -84,10 +102,54 @@ class _RepeatSquelch(logging.Filter):
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
logging.config.dictConfig(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"uvicorn_access": {
|
||||
"()": "uvicorn.logging.AccessFormatter",
|
||||
"fmt": '%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s',
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
"use_colors": None,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
},
|
||||
"uvicorn_access": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "uvicorn_access",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["uvicorn_access"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
|
||||
@@ -15,7 +15,10 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
@@ -29,7 +32,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -71,7 +75,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
);
|
||||
|
||||
|
||||
101
app/decoder.py
101
app/decoder.py
@@ -79,11 +79,10 @@ class PacketInfo:
|
||||
route_type: RouteType
|
||||
payload_type: PayloadType
|
||||
payload_version: int
|
||||
path_length: int # Hop count encoded in the lower 6 bits of the path byte
|
||||
path_length: int # Decoded hop count (not the raw wire byte)
|
||||
path: bytes # The routing path bytes (empty if path_length is 0)
|
||||
payload: bytes
|
||||
path_hash_size: int = 1 # Bytes per hop encoded in the upper 2 bits of the path byte
|
||||
path_byte_length: int = 0
|
||||
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
@@ -95,14 +94,6 @@ def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
return format(hash_bytes[0], "02x")
|
||||
|
||||
|
||||
def decode_path_metadata(path_byte: int) -> tuple[int, int, int]:
|
||||
"""Decode the packed path byte into hop count and byte length."""
|
||||
path_hash_size = (path_byte >> 6) + 1
|
||||
path_length = path_byte & 0x3F
|
||||
path_byte_length = path_length * path_hash_size
|
||||
return path_length, path_hash_size, path_byte_length
|
||||
|
||||
|
||||
def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract just the payload from a raw packet, skipping header and path.
|
||||
@@ -110,90 +101,36 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
Packet structure:
|
||||
- Byte 0: header (route_type, payload_type, version)
|
||||
- For TRANSPORT routes: bytes 1-4 are transport codes
|
||||
- Next byte: packed path metadata
|
||||
- upper 2 bits: bytes per hop minus 1
|
||||
- lower 6 bits: hop count
|
||||
- Next hop_count * path_hash_size bytes: path data
|
||||
- Next byte: path byte (packed as [hash_mode:2][hop_count:6])
|
||||
- Next hop_count * hash_size bytes: path data
|
||||
- Remaining: payload
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
"""
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Decode packed path metadata
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length, _path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
|
||||
offset += 1
|
||||
|
||||
# Skip path data
|
||||
if len(raw_packet) < offset + path_byte_length:
|
||||
return None
|
||||
offset += path_byte_length
|
||||
|
||||
# Rest is payload
|
||||
return raw_packet[offset:]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.payload if envelope is not None else None
|
||||
|
||||
|
||||
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
|
||||
"""Parse a raw packet and extract basic info."""
|
||||
if len(raw_packet) < 2:
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
if envelope is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = RouteType(header & 0x03)
|
||||
payload_type = PayloadType((header >> 2) & 0x0F)
|
||||
payload_version = (header >> 6) & 0x03
|
||||
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present
|
||||
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Decode packed path metadata
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length, path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
|
||||
offset += 1
|
||||
|
||||
# Extract path data
|
||||
if len(raw_packet) < offset + path_byte_length:
|
||||
return None
|
||||
path = raw_packet[offset : offset + path_byte_length]
|
||||
offset += path_byte_length
|
||||
|
||||
# Rest is payload
|
||||
payload = raw_packet[offset:]
|
||||
|
||||
return PacketInfo(
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
payload_version=payload_version,
|
||||
path_length=path_length,
|
||||
path=path,
|
||||
payload=payload,
|
||||
path_hash_size=path_hash_size,
|
||||
path_byte_length=path_byte_length,
|
||||
route_type=RouteType(envelope.route_type),
|
||||
payload_type=PayloadType(envelope.payload_type),
|
||||
payload_version=envelope.payload_version,
|
||||
path_length=envelope.hop_count,
|
||||
path_hash_size=envelope.hash_size,
|
||||
path=envelope.path,
|
||||
payload=envelope.payload,
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
|
||||
def require_connected():
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance.
|
||||
|
||||
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
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance."""
|
||||
return radio_manager.require_connected()
|
||||
|
||||
@@ -4,14 +4,19 @@ 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, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.contact_reconciliation import (
|
||||
claim_prefix_messages_for_contact,
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,33 +27,17 @@ logger = logging.getLogger(__name__)
|
||||
# Track active subscriptions so we can unsubscribe before re-registering
|
||||
# This prevents handler duplication after reconnects
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
|
||||
|
||||
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
|
||||
_pending_acks: dict[str, tuple[int, float, int]] = {}
|
||||
_pending_acks = dm_ack_tracker._pending_acks
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track a pending ACK for a direct message."""
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
expected_ack,
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
"""Compatibility wrapper for pending DM ACK tracking."""
|
||||
dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Remove expired pending ACKs."""
|
||||
now = time.time()
|
||||
expired = []
|
||||
for code, (_msg_id, created_at, timeout_ms) in _pending_acks.items():
|
||||
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
|
||||
expired.append(code)
|
||||
for code in expired:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
"""Compatibility wrapper for expiring stale DM ACK entries."""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
|
||||
async def on_contact_message(event: "Event") -> None:
|
||||
@@ -90,7 +79,7 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey = contact.public_key.lower()
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
await MessageRepository.claim_prefix_messages(sender_pubkey)
|
||||
await claim_prefix_messages_for_contact(public_key=sender_pubkey, log=logger)
|
||||
|
||||
# Skip messages from repeaters - they only send CLI responses, not chat messages.
|
||||
# CLI responses are handled by the command endpoint and txt_type filter above.
|
||||
@@ -100,6 +89,20 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey[:12],
|
||||
)
|
||||
return
|
||||
elif sender_pubkey:
|
||||
placeholder_upsert = ContactUpsert(
|
||||
public_key=sender_pubkey.lower(),
|
||||
type=0,
|
||||
last_seen=received_at,
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
out_path_hash_mode=-1,
|
||||
)
|
||||
await ContactRepository.upsert(placeholder_upsert)
|
||||
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
|
||||
if contact:
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
# If the packet processor already stored this message, this returns None
|
||||
@@ -107,27 +110,22 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
sender_name = contact.name if contact else None
|
||||
path = payload.get("path")
|
||||
payload_path_len = payload.get("path_len")
|
||||
normalized_path_len = (
|
||||
payload_path_len
|
||||
if isinstance(payload_path_len, int)
|
||||
else (len(path) // 2 if path is not None else None)
|
||||
)
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=payload.get("text", ""),
|
||||
path_len = payload.get("path_len")
|
||||
message = await create_fallback_direct_message(
|
||||
conversation_key=sender_pubkey,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=normalized_path_len,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_pubkey,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
if message is None:
|
||||
# Already handled by packet processor (or exact duplicate) - nothing more to do
|
||||
logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12])
|
||||
return
|
||||
@@ -136,31 +134,6 @@ async def on_contact_message(event: "Event") -> None:
|
||||
# (likely because private key export is not available)
|
||||
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=normalized_path_len)]
|
||||
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=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
sender_name=sender_name,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
# Update contact last_contacted (contact was already fetched above)
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
|
||||
@@ -216,6 +189,7 @@ async def on_path_update(event: "Event") -> None:
|
||||
# so if path fields are absent here we treat this as informational only.
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
path_hash_mode = payload.get("path_hash_mode")
|
||||
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]
|
||||
@@ -230,7 +204,28 @@ async def on_path_update(event: "Event") -> None:
|
||||
)
|
||||
return
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, str(path), normalized_path_len)
|
||||
normalized_path_hash_mode: int | None
|
||||
if path_hash_mode is None:
|
||||
# Legacy firmware/library payloads only support 1-byte hop hashes.
|
||||
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
||||
else:
|
||||
normalized_path_hash_mode = None
|
||||
try:
|
||||
normalized_path_hash_mode = int(path_hash_mode)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid path_hash_mode in PATH_UPDATE for %s: %r",
|
||||
contact.public_key[:12],
|
||||
path_hash_mode,
|
||||
)
|
||||
normalized_path_hash_mode = None
|
||||
|
||||
await ContactRepository.update_path(
|
||||
contact.public_key,
|
||||
str(path),
|
||||
normalized_path_len,
|
||||
normalized_path_hash_mode,
|
||||
)
|
||||
|
||||
|
||||
async def on_new_contact(event: "Event") -> None:
|
||||
@@ -248,30 +243,42 @@ 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),
|
||||
"last_seen": int(time.time()),
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True)
|
||||
contact_upsert.last_seen = int(time.time())
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=public_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# 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())
|
||||
)
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=public_key,
|
||||
contact_name=adv_name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# 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",
|
||||
(
|
||||
db_contact.model_dump()
|
||||
if db_contact
|
||||
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
|
||||
),
|
||||
)
|
||||
if db_contact:
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
@@ -287,15 +294,13 @@ async def on_ack(event: "Event") -> None:
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
if ack_code in _pending_acks:
|
||||
message_id, _, _ = _pending_acks.pop(ack_code)
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is not None:
|
||||
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})
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
115
app/events.py
Normal file
115
app/events.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Typed WebSocket event contracts and serialization helpers."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast
|
||||
from app.routers.health import HealthResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WsEventType = Literal[
|
||||
"health",
|
||||
"message",
|
||||
"contact",
|
||||
"contact_resolved",
|
||||
"channel",
|
||||
"contact_deleted",
|
||||
"channel_deleted",
|
||||
"raw_packet",
|
||||
"message_acked",
|
||||
"error",
|
||||
"success",
|
||||
]
|
||||
|
||||
|
||||
class ContactDeletedPayload(TypedDict):
|
||||
public_key: str
|
||||
|
||||
|
||||
class ContactResolvedPayload(TypedDict):
|
||||
previous_public_key: str
|
||||
contact: Contact
|
||||
|
||||
|
||||
class ChannelDeletedPayload(TypedDict):
|
||||
key: str
|
||||
|
||||
|
||||
class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
message: str
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| ContactResolvedPayload
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
| RawPacketBroadcast
|
||||
| MessageAckedPayload
|
||||
| ToastPayload
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
"contact": TypeAdapter(Contact),
|
||||
"contact_resolved": TypeAdapter(ContactResolvedPayload),
|
||||
"channel": TypeAdapter(Channel),
|
||||
"contact_deleted": TypeAdapter(ContactDeletedPayload),
|
||||
"channel_deleted": TypeAdapter(ChannelDeletedPayload),
|
||||
"raw_packet": TypeAdapter(RawPacketBroadcast),
|
||||
"message_acked": TypeAdapter(MessageAckedPayload),
|
||||
"error": TypeAdapter(ToastPayload),
|
||||
"success": TypeAdapter(ToastPayload),
|
||||
}
|
||||
|
||||
|
||||
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
|
||||
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
return adapter.validate_python(data)
|
||||
|
||||
|
||||
def dump_ws_event(event_type: str, data: Any) -> str:
|
||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
|
||||
try:
|
||||
validated = adapter.validate_python(data)
|
||||
payload = adapter.dump_python(validated, mode="json")
|
||||
return json.dumps({"type": event_type, "data": payload})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to validate WebSocket payload for event %s; falling back to raw JSON envelope",
|
||||
event_type,
|
||||
)
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
|
||||
|
||||
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
|
||||
"""Return the JSON-serializable payload for a WebSocket event."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
|
||||
validated = adapter.validate_python(data)
|
||||
return adapter.dump_python(validated, mode="json")
|
||||
@@ -57,6 +57,8 @@ Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
|
||||
Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob:
|
||||
- `broker_host`, `broker_port`, `iata`, `email`
|
||||
- Only publishes raw packets (on_message is a no-op)
|
||||
- The published `raw` field is always the original packet hex.
|
||||
- When a direct packet includes a `path` field, it is emitted as comma-separated hop identifiers exactly as the packet reports them. Token width varies with the packet's path hash mode (`1`, `2`, or `3` bytes per hop); there is no legacy flat per-byte companion field.
|
||||
|
||||
### bot (bot.py)
|
||||
Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
|
||||
@@ -77,6 +79,14 @@ Push notifications via Apprise library. Config blob:
|
||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||
- `include_path` — include routing path in notification body
|
||||
|
||||
### sqs (sqs.py)
|
||||
Amazon SQS delivery. Config blob:
|
||||
- `queue_url` — target queue URL
|
||||
- `region_name` (optional), `endpoint_url` (optional)
|
||||
- `access_key_id`, `secret_access_key`, `session_token` (all optional; blank uses the normal AWS credential chain)
|
||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||
- Supports both decoded messages and raw packets via normal scope selection
|
||||
|
||||
## Adding a New Integration Type
|
||||
|
||||
### Step-by-step checklist
|
||||
@@ -130,7 +140,7 @@ Three changes needed:
|
||||
|
||||
**a)** Add to `_VALID_TYPES` set:
|
||||
```python
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "my_type"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "my_type"}
|
||||
```
|
||||
|
||||
**b)** Add a validation function:
|
||||
@@ -278,6 +288,7 @@ Migrations:
|
||||
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
|
||||
- `app/fanout/webhook.py` — Webhook fanout module
|
||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||
- `app/repository/fanout.py` — Database CRUD
|
||||
- `app/routers/fanout.py` — REST API
|
||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.path_utils import split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,15 +45,13 @@ def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
via = ""
|
||||
if include_path:
|
||||
paths = data.get("paths")
|
||||
first_path = (
|
||||
paths[0]
|
||||
if isinstance(paths, list) and len(paths) > 0 and isinstance(paths[0], dict)
|
||||
else None
|
||||
)
|
||||
if first_path is not None:
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||
path_str = first_path.get("path", "")
|
||||
path_len = first_path.get("path_len")
|
||||
else:
|
||||
path_str = None
|
||||
path_len = None
|
||||
|
||||
if msg_type == "PRIV" and path_str is None:
|
||||
via = " **via:** [`direct`]"
|
||||
@@ -61,13 +60,8 @@ def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
if path_str == "":
|
||||
via = " **via:** [`direct`]"
|
||||
else:
|
||||
path_len = first_path.get("path_len") if first_path is not None else None
|
||||
hop_chars = (
|
||||
len(path_str) // path_len
|
||||
if isinstance(path_len, int) and path_len > 0 and len(path_str) % path_len == 0
|
||||
else 2
|
||||
)
|
||||
hops = [path_str[i : i + hop_chars] for i in range(0, len(path_str), hop_chars)]
|
||||
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||
hops = split_path_hex(path_str, hop_count)
|
||||
if hops:
|
||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
||||
via = f" **via:** [{hop_list}]"
|
||||
|
||||
@@ -10,6 +10,36 @@ from app.fanout.base import FanoutModule
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
|
||||
"""Derive hop width from the first serialized message path when possible."""
|
||||
if not isinstance(path_value, str) or not path_value:
|
||||
return None
|
||||
if not isinstance(paths, list) or not paths:
|
||||
return None
|
||||
|
||||
first_path = paths[0]
|
||||
if not isinstance(first_path, dict):
|
||||
return None
|
||||
|
||||
path_hops = first_path.get("path_len")
|
||||
if not isinstance(path_hops, int) or path_hops <= 0:
|
||||
return None
|
||||
|
||||
path_hex_chars = len(path_value)
|
||||
if path_hex_chars % 2 != 0:
|
||||
return None
|
||||
|
||||
path_bytes = path_hex_chars // 2
|
||||
if path_bytes % path_hops != 0:
|
||||
return None
|
||||
|
||||
hop_width = path_bytes // path_hops
|
||||
if hop_width not in (1, 2, 3):
|
||||
return None
|
||||
|
||||
return hop_width
|
||||
|
||||
|
||||
class BotModule(FanoutModule):
|
||||
"""Wraps a single bot's code execution and response routing.
|
||||
|
||||
@@ -52,10 +82,17 @@ class BotModule(FanoutModule):
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
is_dm = msg_type == "PRIV"
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
logger.debug(
|
||||
"Bot '%s' starting for type=%s conversation=%s outgoing=%s",
|
||||
self.name,
|
||||
msg_type or "unknown",
|
||||
conversation_key[:12] if conversation_key else "(none)",
|
||||
bool(data.get("outgoing", False)),
|
||||
)
|
||||
|
||||
# Extract bot parameters from broadcast data
|
||||
if is_dm:
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
sender_key = data.get("sender_key") or conversation_key
|
||||
is_outgoing = data.get("outgoing", False)
|
||||
message_text = data.get("text", "")
|
||||
@@ -73,7 +110,6 @@ class BotModule(FanoutModule):
|
||||
contact = await ContactRepository.get_by_key(conversation_key)
|
||||
sender_name = contact.name if contact else None
|
||||
else:
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
sender_key = None
|
||||
is_outgoing = bool(data.get("outgoing", False))
|
||||
sender_name = data.get("sender_name")
|
||||
@@ -95,11 +131,11 @@ class BotModule(FanoutModule):
|
||||
|
||||
sender_timestamp = data.get("sender_timestamp")
|
||||
path_value = data.get("path")
|
||||
paths = data.get("paths")
|
||||
# Message model serializes paths as list of dicts; extract first path string
|
||||
if path_value is None:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
|
||||
|
||||
# Wait for message to settle (allows retransmissions to be deduped)
|
||||
await asyncio.sleep(2)
|
||||
@@ -124,14 +160,15 @@ class BotModule(FanoutModule):
|
||||
sender_timestamp,
|
||||
path_value,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Bot '%s' execution timed out", self.name)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Bot '%s' execution error: %s", self.name, e)
|
||||
except Exception:
|
||||
logger.exception("Bot '%s' execution error", self.name)
|
||||
return
|
||||
|
||||
if response and self._active:
|
||||
|
||||
@@ -15,6 +15,7 @@ import inspect
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
|
||||
_last_bot_send_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BotCallPlan:
|
||||
"""How to call a validated bot() function."""
|
||||
|
||||
call_style: str
|
||||
keyword_args: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
|
||||
"""Validate bot() signature and return a supported call plan."""
|
||||
try:
|
||||
sig = (
|
||||
bot_func_or_sig
|
||||
if isinstance(bot_func_or_sig, inspect.Signature)
|
||||
else inspect.signature(bot_func_or_sig)
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Bot function signature could not be inspected") from exc
|
||||
|
||||
params = sig.parameters
|
||||
param_values = tuple(params.values())
|
||||
positional_params = [
|
||||
p
|
||||
for p in param_values
|
||||
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
|
||||
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
|
||||
explicit_optional_names = tuple(
|
||||
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
|
||||
)
|
||||
unsupported_required_kwonly = [
|
||||
p.name
|
||||
for p in param_values
|
||||
if p.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
and p.default is inspect.Parameter.empty
|
||||
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
|
||||
]
|
||||
if unsupported_required_kwonly:
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Unsupported required keyword-only "
|
||||
"parameters: " + ", ".join(unsupported_required_kwonly)
|
||||
)
|
||||
|
||||
positional_capacity = len(positional_params)
|
||||
base_args = [object()] * 8
|
||||
base_keyword_args: dict[str, object] = {
|
||||
"sender_name": object(),
|
||||
"sender_key": object(),
|
||||
"message_text": object(),
|
||||
"is_dm": object(),
|
||||
"channel_key": object(),
|
||||
"channel_name": object(),
|
||||
"sender_timestamp": object(),
|
||||
"path": object(),
|
||||
}
|
||||
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
|
||||
keyword_args = dict(base_keyword_args)
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
keyword_args["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
keyword_args["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("keyword", [], keyword_args))
|
||||
|
||||
if not has_kwargs and explicit_optional_names:
|
||||
kwargs: dict[str, object] = {}
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
kwargs["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
kwargs["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("mixed_keyword", base_args, kwargs))
|
||||
|
||||
if has_varargs or positional_capacity >= 10:
|
||||
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
|
||||
if has_varargs or positional_capacity >= 9:
|
||||
candidate_specs.append(("positional_9", base_args + [False], {}))
|
||||
if has_varargs or positional_capacity >= 8:
|
||||
candidate_specs.append(("legacy", base_args, {}))
|
||||
|
||||
for call_style, args, kwargs in candidate_specs:
|
||||
try:
|
||||
sig.bind(*args, **kwargs)
|
||||
except TypeError:
|
||||
continue
|
||||
if call_style in {"keyword", "mixed_keyword"}:
|
||||
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
|
||||
return BotCallPlan(call_style=call_style)
|
||||
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Use the default bot template as a reference. "
|
||||
"Supported trailing parameters are: path; path + is_outgoing; "
|
||||
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
|
||||
"or use **kwargs for forward compatibility."
|
||||
)
|
||||
|
||||
|
||||
def execute_bot_code(
|
||||
code: str,
|
||||
sender_name: str | None,
|
||||
@@ -50,17 +147,19 @@ def execute_bot_code(
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
path_bytes_per_hop: int | None = None,
|
||||
) -> 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, is_outgoing, path_bytes_per_hop)`
|
||||
or use named parameters / `**kwargs`.
|
||||
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.
|
||||
Legacy bot functions with older signatures are detected via inspect and
|
||||
called without the newer parameters for backward compatibility.
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
@@ -73,6 +172,7 @@ def execute_bot_code(
|
||||
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
|
||||
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
|
||||
|
||||
Returns:
|
||||
Response string, list of strings, or None.
|
||||
@@ -90,8 +190,8 @@ def execute_bot_code(
|
||||
try:
|
||||
# Execute the user's code to define the bot function
|
||||
exec(code, namespace)
|
||||
except Exception as e:
|
||||
logger.warning("Bot code compilation failed: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot code compilation failed")
|
||||
return None
|
||||
|
||||
# Check if bot function was defined
|
||||
@@ -100,30 +200,28 @@ def execute_bot_code(
|
||||
return None
|
||||
|
||||
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
|
||||
call_plan = _analyze_bot_signature(bot_func)
|
||||
except ValueError as exc:
|
||||
logger.error("%s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Call the bot function with appropriate signature
|
||||
if call_style == "positional":
|
||||
if call_plan.call_style == "positional_10":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
)
|
||||
elif call_plan.call_style == "positional_9":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
@@ -135,18 +233,29 @@ def execute_bot_code(
|
||||
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,
|
||||
)
|
||||
elif call_plan.call_style == "keyword":
|
||||
keyword_args: dict[str, Any] = {}
|
||||
if "sender_name" in call_plan.keyword_args:
|
||||
keyword_args["sender_name"] = sender_name
|
||||
if "sender_key" in call_plan.keyword_args:
|
||||
keyword_args["sender_key"] = sender_key
|
||||
if "message_text" in call_plan.keyword_args:
|
||||
keyword_args["message_text"] = message_text
|
||||
if "is_dm" in call_plan.keyword_args:
|
||||
keyword_args["is_dm"] = is_dm
|
||||
if "channel_key" in call_plan.keyword_args:
|
||||
keyword_args["channel_key"] = channel_key
|
||||
if "channel_name" in call_plan.keyword_args:
|
||||
keyword_args["channel_name"] = channel_name
|
||||
if "sender_timestamp" in call_plan.keyword_args:
|
||||
keyword_args["sender_timestamp"] = sender_timestamp
|
||||
if "path" in call_plan.keyword_args:
|
||||
keyword_args["path"] = path
|
||||
if "is_outgoing" in call_plan.keyword_args:
|
||||
keyword_args["is_outgoing"] = is_outgoing
|
||||
if "path_bytes_per_hop" in call_plan.keyword_args:
|
||||
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
|
||||
result = bot_func(**keyword_args)
|
||||
else:
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
@@ -172,8 +281,8 @@ def execute_bot_code(
|
||||
logger.debug("Bot function returned unsupported type: %s", type(result))
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Bot function execution failed: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot function execution failed")
|
||||
return None
|
||||
|
||||
|
||||
@@ -249,10 +358,10 @@ async def _send_single_bot_message(
|
||||
logger.warning("Cannot send bot response: no destination")
|
||||
return # Don't update timestamp if we didn't send
|
||||
except HTTPException as e:
|
||||
logger.error("Bot failed to send response: %s", e.detail)
|
||||
logger.error("Bot failed to send response: %s", e.detail, exc_info=True)
|
||||
return # Don't update timestamp on failure
|
||||
except Exception as e:
|
||||
logger.error("Bot failed to send response: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot failed to send response")
|
||||
return # Don't update timestamp on failure
|
||||
|
||||
# Update last send time after successful send
|
||||
|
||||
@@ -23,8 +23,8 @@ from typing import Any, Protocol
|
||||
import aiomqtt
|
||||
import nacl.bindings
|
||||
|
||||
from app.decoder import decode_path_metadata
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,8 +53,15 @@ class CommunityMqttSettings(Protocol):
|
||||
community_mqtt_enabled: bool
|
||||
community_mqtt_broker_host: str
|
||||
community_mqtt_broker_port: int
|
||||
community_mqtt_transport: str
|
||||
community_mqtt_use_tls: bool
|
||||
community_mqtt_tls_verify: bool
|
||||
community_mqtt_auth_mode: str
|
||||
community_mqtt_username: str
|
||||
community_mqtt_password: str
|
||||
community_mqtt_iata: str
|
||||
community_mqtt_email: str
|
||||
community_mqtt_token_audience: str
|
||||
|
||||
|
||||
def _base64url_encode(data: bytes) -> str:
|
||||
@@ -136,36 +143,17 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
|
||||
return "0" * 16
|
||||
|
||||
try:
|
||||
header = raw_bytes[0]
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
route_type = header & 0x03
|
||||
|
||||
# Transport codes present for TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3)
|
||||
has_transport = route_type in (0x00, 0x03)
|
||||
|
||||
offset = 1 # Past header
|
||||
if has_transport:
|
||||
offset += 4 # Skip 4 bytes of transport codes
|
||||
|
||||
# Read packed path metadata. Invalid/truncated packets map to zero hash.
|
||||
if offset >= len(raw_bytes):
|
||||
envelope = parse_packet_envelope(raw_bytes)
|
||||
if envelope is None:
|
||||
return "0" * 16
|
||||
packed_path_len = raw_bytes[offset]
|
||||
path_len, _path_hash_size, path_byte_length = decode_path_metadata(packed_path_len)
|
||||
offset += 1
|
||||
|
||||
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
|
||||
if len(raw_bytes) < offset + path_byte_length:
|
||||
return "0" * 16
|
||||
payload_start = offset + path_byte_length
|
||||
payload_data = raw_bytes[payload_start:]
|
||||
|
||||
# Hash: payload_type(1 byte) [+ packed path_len as uint16_t LE for TRACE] + payload_data
|
||||
# Hash: payload_type(1 byte) [+ path_byte as uint16_t LE for TRACE] + payload_data
|
||||
# IMPORTANT: TRACE hash uses the raw wire byte (not decoded hop count) to match firmware.
|
||||
hash_obj = hashlib.sha256()
|
||||
hash_obj.update(bytes([payload_type]))
|
||||
if payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
hash_obj.update(packed_path_len.to_bytes(2, byteorder="little"))
|
||||
hash_obj.update(payload_data)
|
||||
hash_obj.update(bytes([envelope.payload_type]))
|
||||
if envelope.payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
hash_obj.update(envelope.path_byte.to_bytes(2, byteorder="little"))
|
||||
hash_obj.update(envelope.payload)
|
||||
|
||||
return hash_obj.hexdigest()[:16].upper()
|
||||
except Exception:
|
||||
@@ -186,42 +174,15 @@ def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], i
|
||||
payload_type: int | None = None
|
||||
|
||||
try:
|
||||
if len(raw_bytes) < 2:
|
||||
envelope = parse_packet_envelope(raw_bytes)
|
||||
if envelope is None or envelope.payload_version != 0:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
header = raw_bytes[0]
|
||||
payload_version = (header >> 6) & 0x03
|
||||
if payload_version != 0:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
route_type = header & 0x03
|
||||
has_transport = route_type in (0x00, 0x03)
|
||||
|
||||
offset = 1
|
||||
if has_transport:
|
||||
offset += 4
|
||||
|
||||
if len(raw_bytes) <= offset:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
path_len, path_hash_size, path_byte_length = decode_path_metadata(raw_bytes[offset])
|
||||
offset += 1
|
||||
|
||||
if len(raw_bytes) < offset + path_byte_length:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
path_bytes = raw_bytes[offset : offset + path_byte_length]
|
||||
offset += path_byte_length
|
||||
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
route = _ROUTE_MAP.get(route_type, "U")
|
||||
payload_type = envelope.payload_type
|
||||
route = _ROUTE_MAP.get(envelope.route_type, "U")
|
||||
packet_type = str(payload_type)
|
||||
payload_len = str(max(0, len(raw_bytes) - offset))
|
||||
path_values = [
|
||||
path_bytes[i : i + path_hash_size].hex()
|
||||
for i in range(0, len(path_bytes), path_hash_size)
|
||||
if i + path_hash_size <= len(path_bytes)
|
||||
]
|
||||
payload_len = str(len(envelope.payload))
|
||||
path_values = split_path_hex(envelope.path.hex(), envelope.hop_count)
|
||||
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
except Exception:
|
||||
@@ -283,7 +244,7 @@ def _build_radio_info() -> str:
|
||||
Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw
|
||||
values). Falls back to ``"0,0,0,0"`` when unavailable.
|
||||
"""
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
try:
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
@@ -333,11 +294,18 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
await super().start(settings)
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
from app.keystore import has_private_key
|
||||
from app.keystore import get_public_key, has_private_key
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
if s and not has_private_key() and not self._key_unavailable_warned:
|
||||
auth_mode = getattr(s, "community_mqtt_auth_mode", "token") if s else "token"
|
||||
if (
|
||||
s
|
||||
and auth_mode == "token"
|
||||
and get_public_key() is not None
|
||||
and not has_private_key()
|
||||
and not self._key_unavailable_warned
|
||||
):
|
||||
broadcast_error(
|
||||
"Community MQTT unavailable",
|
||||
"Radio firmware does not support private key export.",
|
||||
@@ -346,31 +314,42 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if community MQTT is enabled and keys are available."""
|
||||
from app.keystore import has_private_key
|
||||
from app.keystore import get_public_key, has_private_key
|
||||
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
return bool(s and s.community_mqtt_enabled and has_private_key())
|
||||
if not s or not s.community_mqtt_enabled:
|
||||
return False
|
||||
if get_public_key() is None:
|
||||
return False
|
||||
auth_mode = getattr(s, "community_mqtt_auth_mode", "token")
|
||||
if auth_mode == "token":
|
||||
return has_private_key()
|
||||
return True
|
||||
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
|
||||
assert public_key is not None # guaranteed by _pre_connect
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
jwt_token = _generate_jwt_token(
|
||||
private_key,
|
||||
public_key,
|
||||
audience=broker_host,
|
||||
email=s.community_mqtt_email or "",
|
||||
)
|
||||
transport = s.community_mqtt_transport or "websockets"
|
||||
use_tls = bool(s.community_mqtt_use_tls)
|
||||
tls_verify = bool(s.community_mqtt_tls_verify)
|
||||
auth_mode = s.community_mqtt_auth_mode or "token"
|
||||
secure_connection = use_tls and tls_verify
|
||||
|
||||
tls_context = ssl.create_default_context()
|
||||
tls_context: ssl.SSLContext | None = None
|
||||
if use_tls:
|
||||
tls_context = ssl.create_default_context()
|
||||
if not tls_verify:
|
||||
tls_context.check_hostname = False
|
||||
tls_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
@@ -386,16 +365,30 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
kwargs: dict[str, Any] = {
|
||||
"hostname": broker_host,
|
||||
"port": broker_port,
|
||||
"transport": "websockets",
|
||||
"transport": transport,
|
||||
"tls_context": tls_context,
|
||||
"websocket_path": "/",
|
||||
"username": f"v1_{pubkey_hex}",
|
||||
"password": jwt_token,
|
||||
"will": aiomqtt.Will(status_topic, offline_payload, retain=True),
|
||||
}
|
||||
if auth_mode == "token":
|
||||
assert private_key is not None
|
||||
token_audience = (s.community_mqtt_token_audience or "").strip() or broker_host
|
||||
jwt_token = _generate_jwt_token(
|
||||
private_key,
|
||||
public_key,
|
||||
audience=token_audience,
|
||||
email=(s.community_mqtt_email or "") if secure_connection else "",
|
||||
)
|
||||
kwargs["username"] = f"v1_{pubkey_hex}"
|
||||
kwargs["password"] = jwt_token
|
||||
elif auth_mode == "password":
|
||||
kwargs["username"] = s.community_mqtt_username or None
|
||||
kwargs["password"] = s.community_mqtt_password or None
|
||||
if transport == "websockets":
|
||||
kwargs["websocket_path"] = "/"
|
||||
return kwargs
|
||||
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
@@ -408,7 +401,8 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
if self._cached_device_info is not None:
|
||||
return self._cached_device_info
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
fallback = {"model": "unknown", "firmware_version": "unknown"}
|
||||
try:
|
||||
@@ -455,7 +449,8 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None:
|
||||
return self._cached_stats
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc:
|
||||
@@ -496,7 +491,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
) -> None:
|
||||
"""Build and publish the enriched retained status message."""
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
@@ -549,7 +544,9 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
if not self.connected:
|
||||
logger.info("Community MQTT publish failure detected, reconnecting")
|
||||
return True
|
||||
if elapsed >= _TOKEN_RENEWAL_THRESHOLD:
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
auth_mode = getattr(s, "community_mqtt_auth_mode", "token") if s else "token"
|
||||
if auth_mode == "token" and elapsed >= _TOKEN_RENEWAL_THRESHOLD:
|
||||
logger.info("Community MQTT JWT nearing expiry, reconnecting")
|
||||
return True
|
||||
return False
|
||||
@@ -557,9 +554,11 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
async def _pre_connect(self, settings: object) -> bool:
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
auth_mode = s.community_mqtt_auth_mode or "token"
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
if private_key is None or public_key is None:
|
||||
if public_key is None or (auth_mode == "token" and private_key is None):
|
||||
# Keys not available yet, wait for settings change or key export
|
||||
self.connected = False
|
||||
self._version_event.clear()
|
||||
|
||||
@@ -23,6 +23,7 @@ def _register_module_types() -> None:
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.sqs import SqsModule
|
||||
from app.fanout.webhook import WebhookModule
|
||||
|
||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||
@@ -30,6 +31,7 @@ def _register_module_types() -> None:
|
||||
_MODULE_TYPES["bot"] = BotModule
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
_MODULE_TYPES["sqs"] = SqsModule
|
||||
|
||||
|
||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||
|
||||
@@ -25,7 +25,7 @@ _BACKOFF_MIN = 5
|
||||
|
||||
def _broadcast_health() -> None:
|
||||
"""Push updated health (including MQTT status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
@@ -89,7 +89,13 @@ class BaseMqttPublisher(ABC):
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload), retain=retain)
|
||||
except Exception as e:
|
||||
logger.warning("%s publish failed on %s: %s", self._log_prefix, topic, e)
|
||||
logger.warning(
|
||||
"%s publish failed on %s: %s",
|
||||
self._log_prefix,
|
||||
topic,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
@@ -223,6 +229,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._log_prefix,
|
||||
e,
|
||||
backoff,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -13,6 +14,37 @@ from app.fanout.community_mqtt import CommunityMqttPublisher, _format_raw_packet
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_PACKET_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
_TOPIC_TEMPLATE_FIELD_CANONICAL = {
|
||||
"iata": "IATA",
|
||||
"public_key": "PUBLIC_KEY",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_topic_template(topic_template: str) -> str:
|
||||
"""Normalize packet topic template fields to canonical uppercase placeholders."""
|
||||
template = topic_template.strip() or _DEFAULT_PACKET_TOPIC_TEMPLATE
|
||||
parts: list[str] = []
|
||||
try:
|
||||
parsed = string.Formatter().parse(template)
|
||||
for literal_text, field_name, format_spec, conversion in parsed:
|
||||
parts.append(literal_text)
|
||||
if field_name is None:
|
||||
continue
|
||||
normalized_field = _TOPIC_TEMPLATE_FIELD_CANONICAL.get(field_name.lower())
|
||||
if normalized_field is None:
|
||||
raise ValueError(f"Unsupported topic template field(s): {field_name}")
|
||||
replacement = ["{", normalized_field]
|
||||
if conversion:
|
||||
replacement.extend(["!", conversion])
|
||||
if format_spec:
|
||||
replacement.extend([":", format_spec])
|
||||
replacement.append("}")
|
||||
parts.append("".join(replacement))
|
||||
except ValueError:
|
||||
raise
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _config_to_settings(config: dict) -> SimpleNamespace:
|
||||
@@ -21,11 +53,24 @@ def _config_to_settings(config: dict) -> SimpleNamespace:
|
||||
community_mqtt_enabled=True,
|
||||
community_mqtt_broker_host=config.get("broker_host", "mqtt-us-v1.letsmesh.net"),
|
||||
community_mqtt_broker_port=config.get("broker_port", 443),
|
||||
community_mqtt_transport=config.get("transport", "websockets"),
|
||||
community_mqtt_use_tls=config.get("use_tls", True),
|
||||
community_mqtt_tls_verify=config.get("tls_verify", True),
|
||||
community_mqtt_auth_mode=config.get("auth_mode", "token"),
|
||||
community_mqtt_username=config.get("username", ""),
|
||||
community_mqtt_password=config.get("password", ""),
|
||||
community_mqtt_iata=config.get("iata", ""),
|
||||
community_mqtt_email=config.get("email", ""),
|
||||
community_mqtt_token_audience=config.get("token_audience", ""),
|
||||
)
|
||||
|
||||
|
||||
def _render_packet_topic(topic_template: str, *, iata: str, public_key: str) -> str:
|
||||
"""Render the configured raw-packet publish topic."""
|
||||
template = _normalize_topic_template(topic_template)
|
||||
return template.format(IATA=iata, PUBLIC_KEY=public_key)
|
||||
|
||||
|
||||
class MqttCommunityModule(FanoutModule):
|
||||
"""Wraps a CommunityMqttPublisher for community packet sharing."""
|
||||
|
||||
@@ -64,7 +109,7 @@ async def _publish_community_packet(
|
||||
"""Format and publish a raw packet to the community broker."""
|
||||
try:
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
@@ -81,9 +126,13 @@ async def _publish_community_packet(
|
||||
if not _IATA_RE.fullmatch(iata):
|
||||
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
|
||||
return
|
||||
topic = f"meshcore/{iata}/{pubkey_hex}/packets"
|
||||
topic = _render_packet_topic(
|
||||
str(config.get("topic_template", _DEFAULT_PACKET_TOPIC_TEMPLATE)),
|
||||
iata=iata,
|
||||
public_key=pubkey_hex,
|
||||
)
|
||||
|
||||
await publisher.publish(topic, packet)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Community MQTT broadcast error: %s", e)
|
||||
logger.warning("Community MQTT broadcast error: %s", e, exc_info=True)
|
||||
|
||||
142
app/fanout/sqs.py
Normal file
142
app/fanout/sqs.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Fanout module for Amazon SQS delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_payload(data: dict, *, event_type: str) -> str:
|
||||
"""Serialize a fanout event into a stable JSON envelope."""
|
||||
return json.dumps(
|
||||
{
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def _is_fifo_queue(queue_url: str) -> bool:
|
||||
"""Return True when the configured queue URL points at an SQS FIFO queue."""
|
||||
return queue_url.rstrip("/").endswith(".fifo")
|
||||
|
||||
|
||||
def _build_message_group_id(data: dict, *, event_type: str) -> str:
|
||||
"""Choose a stable FIFO group ID from the event identity."""
|
||||
if event_type == "message":
|
||||
conversation_key = str(data.get("conversation_key", "")).strip()
|
||||
if conversation_key:
|
||||
return f"message-{conversation_key}"
|
||||
return "message-default"
|
||||
return "raw-packets"
|
||||
|
||||
|
||||
def _build_message_deduplication_id(data: dict, *, event_type: str, body: str) -> str:
|
||||
"""Choose a deterministic deduplication ID for FIFO queues."""
|
||||
if event_type == "message":
|
||||
message_id = data.get("id")
|
||||
if isinstance(message_id, int):
|
||||
return f"message-{message_id}"
|
||||
else:
|
||||
observation_id = data.get("observation_id")
|
||||
if isinstance(observation_id, str) and observation_id.strip():
|
||||
return f"raw-{observation_id}"
|
||||
packet_id = data.get("id")
|
||||
if isinstance(packet_id, int):
|
||||
return f"raw-{packet_id}"
|
||||
return hashlib.sha256(body.encode()).hexdigest()
|
||||
|
||||
|
||||
class SqsModule(FanoutModule):
|
||||
"""Delivers message and raw-packet events to an Amazon SQS queue."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
kwargs: dict[str, str] = {}
|
||||
region_name = str(self.config.get("region_name", "")).strip()
|
||||
endpoint_url = str(self.config.get("endpoint_url", "")).strip()
|
||||
access_key_id = str(self.config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(self.config.get("secret_access_key", "")).strip()
|
||||
session_token = str(self.config.get("session_token", "")).strip()
|
||||
|
||||
if region_name:
|
||||
kwargs["region_name"] = region_name
|
||||
if endpoint_url:
|
||||
kwargs["endpoint_url"] = endpoint_url
|
||||
if access_key_id and secret_access_key:
|
||||
kwargs["aws_access_key_id"] = access_key_id
|
||||
kwargs["aws_secret_access_key"] = secret_access_key
|
||||
if session_token:
|
||||
kwargs["aws_session_token"] = session_token
|
||||
|
||||
self._client = boto3.client("sqs", **kwargs)
|
||||
self._last_error = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._client = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
await self._send(data, event_type="message")
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
await self._send(data, event_type="raw_packet")
|
||||
|
||||
async def _send(self, data: dict, *, event_type: str) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
queue_url = str(self.config.get("queue_url", "")).strip()
|
||||
if not queue_url:
|
||||
return
|
||||
|
||||
body = _build_payload(data, event_type=event_type)
|
||||
request_kwargs: dict[str, object] = {
|
||||
"QueueUrl": queue_url,
|
||||
"MessageBody": body,
|
||||
"MessageAttributes": {
|
||||
"event_type": {
|
||||
"DataType": "String",
|
||||
"StringValue": event_type,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if _is_fifo_queue(queue_url):
|
||||
request_kwargs["MessageGroupId"] = _build_message_group_id(data, event_type=event_type)
|
||||
request_kwargs["MessageDeduplicationId"] = _build_message_deduplication_id(
|
||||
data, event_type=event_type, body=body
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
||||
self._last_error = None
|
||||
except (ClientError, BotoCoreError) as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not str(self.config.get("queue_url", "")).strip():
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -7,6 +7,32 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_CACHE_CONTROL = "no-store"
|
||||
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
|
||||
|
||||
|
||||
class CacheControlStaticFiles(StaticFiles):
|
||||
"""StaticFiles variant that adds a fixed Cache-Control header."""
|
||||
|
||||
def __init__(self, *args, cache_control: str, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cache_control = cache_control
|
||||
|
||||
def file_response(self, *args, **kwargs):
|
||||
response = super().file_response(*args, **kwargs)
|
||||
response.headers["Cache-Control"] = self.cache_control
|
||||
return response
|
||||
|
||||
|
||||
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
|
||||
return FileResponse(path, headers={"Cache-Control": cache_control})
|
||||
|
||||
|
||||
def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
"""Return True when the requested file is the SPA shell index.html."""
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
@@ -57,7 +83,11 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
return False
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
app.mount(
|
||||
"/assets",
|
||||
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
|
||||
name="assets",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Frontend assets directory missing at %s; /assets files will not be served",
|
||||
@@ -67,7 +97,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
@@ -114,9 +144,14 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
raise HTTPException(status_code=404, detail="Not found") from None
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
cache_control = (
|
||||
INDEX_CACHE_CONTROL
|
||||
if _is_index_file(file_path, index_file)
|
||||
else STATIC_FILE_CACHE_CONTROL
|
||||
)
|
||||
return _file_response(file_path, cache_control=cache_control)
|
||||
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
logger.info("Serving frontend from %s", frontend_dir)
|
||||
return True
|
||||
@@ -129,7 +164,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
|
||||
async def frontend_not_built():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
|
||||
},
|
||||
content={"detail": "Frontend not built. Run: cd frontend && npm ci && npm run build"},
|
||||
)
|
||||
|
||||
41
app/main.py
41
app/main.py
@@ -1,15 +1,18 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings as server_settings
|
||||
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 RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
@@ -29,11 +32,25 @@ from app.routers import (
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _startup_radio_connect_and_setup() -> None:
|
||||
"""Connect/setup the radio in the background so HTTP serving can start immediately."""
|
||||
try:
|
||||
connected = await radio_manager.reconnect_and_prepare(broadcast_on_success=True)
|
||||
if connected:
|
||||
logger.info("Connected to radio")
|
||||
else:
|
||||
logger.warning("Failed to connect to radio on startup")
|
||||
except Exception:
|
||||
logger.exception("Failed to connect to radio on startup")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage database and radio connection lifecycle."""
|
||||
@@ -47,13 +64,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await ensure_default_channels()
|
||||
|
||||
try:
|
||||
await radio_manager.connect()
|
||||
logger.info("Connected to radio")
|
||||
await radio_manager.post_connect_setup()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
|
||||
@@ -62,12 +72,21 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
try:
|
||||
await fanout_manager.load_from_db()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start fanout modules: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Failed to start fanout modules")
|
||||
|
||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||
app.state.startup_radio_task = startup_radio_task
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down")
|
||||
if startup_radio_task and not startup_radio_task.done():
|
||||
startup_radio_task.cancel()
|
||||
try:
|
||||
await startup_radio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await fanout_manager.stop_all()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_message_polling()
|
||||
@@ -98,6 +117,8 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
add_optional_basic_auth_middleware(app, server_settings)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
|
||||
@@ -13,9 +13,6 @@ from hashlib import sha256
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app.decoder import extract_payload, parse_packet
|
||||
from app.models import Contact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -306,13 +303,34 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 38)
|
||||
applied += 1
|
||||
|
||||
# Migration 39: Persist exact contact out_path_hash_mode from radio sync
|
||||
# Migration 39: Persist contacts.out_path_hash_mode for multibyte path round-tripping
|
||||
if version < 39:
|
||||
logger.info("Applying migration 39: add contacts.out_path_hash_mode")
|
||||
await _migrate_039_add_contact_out_path_hash_mode(conn)
|
||||
await set_version(conn, 39)
|
||||
applied += 1
|
||||
|
||||
# Migration 40: Distinguish advert paths by hop count as well as bytes
|
||||
if version < 40:
|
||||
logger.info("Applying migration 40: rebuild contact_advert_paths uniqueness with path_len")
|
||||
await _migrate_040_rebuild_contact_advert_paths_identity(conn)
|
||||
await set_version(conn, 40)
|
||||
applied += 1
|
||||
|
||||
# Migration 41: Persist optional routing overrides separately from learned paths
|
||||
if version < 41:
|
||||
logger.info("Applying migration 41: add contacts routing override columns")
|
||||
await _migrate_041_add_contact_routing_override_columns(conn)
|
||||
await set_version(conn, 41)
|
||||
applied += 1
|
||||
|
||||
# Migration 42: Persist optional per-channel flood-scope overrides
|
||||
if version < 42:
|
||||
logger.info("Applying migration 42: add channels flood_scope_override column")
|
||||
await _migrate_042_add_channel_flood_scope_override(conn)
|
||||
await set_version(conn, 42)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -448,11 +466,14 @@ async def _migrate_004_add_payload_hash_column(conn: aiosqlite.Connection) -> No
|
||||
|
||||
def _extract_payload_for_hash(raw_packet: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract payload from a raw packet for hashing (migration-local copy of decoder logic).
|
||||
Extract payload from a raw packet for hashing using canonical framing validation.
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
"""
|
||||
return extract_payload(raw_packet)
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.payload if envelope is not None else None
|
||||
|
||||
|
||||
async def _migrate_005_backfill_payload_hashes(conn: aiosqlite.Connection) -> None:
|
||||
@@ -602,14 +623,14 @@ async def _migrate_006_replace_path_len_with_path(conn: aiosqlite.Connection) ->
|
||||
|
||||
def _extract_path_from_packet(raw_packet: bytes) -> str | None:
|
||||
"""
|
||||
Extract path hex string from a raw packet (migration-local copy of decoder logic).
|
||||
Extract path hex string from a raw packet using canonical framing validation.
|
||||
|
||||
Returns the path as a hex string, or None if packet is malformed.
|
||||
"""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None:
|
||||
return None
|
||||
return packet_info.path.hex()
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.path.hex() if envelope is not None else None
|
||||
|
||||
|
||||
async def _migrate_007_backfill_message_paths(conn: aiosqlite.Connection) -> None:
|
||||
@@ -757,7 +778,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
||||
Create app_settings table for persistent application preferences.
|
||||
|
||||
This table stores:
|
||||
- max_radio_contacts: Max non-repeater contacts to keep on radio for DM ACKs
|
||||
- max_radio_contacts: Configured radio contact capacity baseline for maintenance thresholds
|
||||
- favorites: JSON array of favorite conversations [{type, id}, ...]
|
||||
- auto_decrypt_dm_on_advert: Whether to attempt historical DM decryption on new contact
|
||||
- sidebar_sort_order: 'recent' or 'alpha' for sidebar sorting
|
||||
@@ -1636,7 +1657,7 @@ async def _migrate_026_rename_advert_paths_table(conn: aiosqlite.Connection) ->
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
@@ -1660,7 +1681,7 @@ async def _migrate_026_rename_advert_paths_table(conn: aiosqlite.Connection) ->
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex),
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
@@ -2241,41 +2262,184 @@ async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
|
||||
|
||||
|
||||
async def _migrate_039_add_contact_out_path_hash_mode(conn: aiosqlite.Connection) -> None:
|
||||
"""Add contacts.out_path_hash_mode and backfill best-effort values for existing rows."""
|
||||
tables = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'contacts'"
|
||||
"""Add contacts.out_path_hash_mode and backfill legacy rows.
|
||||
|
||||
Historical databases predate multibyte routing support. Backfill rules:
|
||||
- contacts with last_path_len = -1 are flood routes -> out_path_hash_mode = -1
|
||||
- all other existing contacts default to 0 (1-byte legacy hop identifiers)
|
||||
"""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await tables.fetchone() is None:
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
columns_cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
columns = {row["name"] for row in await columns_cursor.fetchall()}
|
||||
column_cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
columns = {row[1] for row in await column_cursor.fetchall()}
|
||||
|
||||
if "out_path_hash_mode" not in columns:
|
||||
await conn.execute("ALTER TABLE contacts ADD COLUMN out_path_hash_mode INTEGER")
|
||||
columns.add("out_path_hash_mode")
|
||||
added_column = False
|
||||
|
||||
if not {"last_path", "last_path_len"}.issubset(columns):
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
rows_cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_path, last_path_len, out_path_hash_mode
|
||||
FROM contacts
|
||||
"""
|
||||
)
|
||||
rows = await rows_cursor.fetchall()
|
||||
for row in rows:
|
||||
if row["out_path_hash_mode"] is not None:
|
||||
continue
|
||||
if row["last_path_len"] is None or row["last_path_len"] < 0:
|
||||
continue
|
||||
derived = Contact._derive_out_path_hash_mode(row["last_path"], row["last_path_len"])
|
||||
try:
|
||||
await conn.execute(
|
||||
"UPDATE contacts SET out_path_hash_mode = ? WHERE public_key = ?",
|
||||
(derived, row["public_key"]),
|
||||
"ALTER TABLE contacts ADD COLUMN out_path_hash_mode INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
added_column = True
|
||||
logger.debug("Added out_path_hash_mode to contacts table")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("contacts.out_path_hash_mode already exists, skipping add")
|
||||
else:
|
||||
raise
|
||||
|
||||
if "last_path_len" not in columns:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
if added_column:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET out_path_hash_mode = CASE
|
||||
WHEN last_path_len = -1 THEN -1
|
||||
ELSE 0
|
||||
END
|
||||
"""
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET out_path_hash_mode = CASE
|
||||
WHEN last_path_len = -1 THEN -1
|
||||
ELSE 0
|
||||
END
|
||||
WHERE out_path_hash_mode NOT IN (-1, 0, 1, 2)
|
||||
OR (last_path_len = -1 AND out_path_hash_mode != -1)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_040_rebuild_contact_advert_paths_identity(
|
||||
conn: aiosqlite.Connection,
|
||||
) -> None:
|
||||
"""Rebuild contact_advert_paths so uniqueness includes path_len.
|
||||
|
||||
Multi-byte routing can produce the same path_hex bytes with a different hop count,
|
||||
which changes the hop boundaries and therefore the semantic next-hop identity.
|
||||
"""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contact_advert_paths'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
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, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute("DROP INDEX IF EXISTS idx_contact_advert_paths_recent")
|
||||
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()
|
||||
return
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact_advert_paths_new (
|
||||
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, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths_new
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
SELECT
|
||||
public_key,
|
||||
path_hex,
|
||||
path_len,
|
||||
MIN(first_seen),
|
||||
MAX(last_seen),
|
||||
SUM(heard_count)
|
||||
FROM contact_advert_paths
|
||||
GROUP BY public_key, path_hex, path_len
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE contact_advert_paths")
|
||||
await conn.execute("ALTER TABLE contact_advert_paths_new RENAME TO contact_advert_paths")
|
||||
await conn.execute("DROP INDEX IF EXISTS idx_contact_advert_paths_recent")
|
||||
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()
|
||||
|
||||
|
||||
async def _migrate_041_add_contact_routing_override_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable routing-override columns to contacts."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
for column_name, column_type in (
|
||||
("route_override_path", "TEXT"),
|
||||
("route_override_len", "INTEGER"),
|
||||
("route_override_hash_mode", "INTEGER"),
|
||||
):
|
||||
try:
|
||||
await conn.execute(f"ALTER TABLE contacts ADD COLUMN {column_name} {column_type}")
|
||||
logger.debug("Added %s to contacts table", column_name)
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("contacts.%s already exists, skipping", column_name)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_042_add_channel_flood_scope_override(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable per-channel flood-scope override column."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
await conn.execute("ALTER TABLE channels ADD COLUMN flood_scope_override TEXT")
|
||||
logger.debug("Added flood_scope_override to channels table")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("channels.flood_scope_override already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
221
app/models.py
221
app/models.py
@@ -2,6 +2,66 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route
|
||||
|
||||
|
||||
class ContactUpsert(BaseModel):
|
||||
"""Typed write contract for contacts persisted to SQLite."""
|
||||
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
type: int = 0
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool | None = None
|
||||
last_contacted: int | None = None
|
||||
first_seen: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
|
||||
return cls.model_validate(
|
||||
{
|
||||
**contact.model_dump(exclude={"last_read_at"}),
|
||||
**changes,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_radio_dict(
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
radio_data.get("out_path_len", -1),
|
||||
radio_data.get(
|
||||
"out_path_hash_mode",
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
flags=radio_data.get("flags", 0),
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
on_radio=on_radio,
|
||||
)
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
@@ -10,7 +70,10 @@ class Contact(BaseModel):
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int | None = None
|
||||
out_path_hash_mode: int = 0
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
@@ -20,25 +83,21 @@ class Contact(BaseModel):
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def _derive_out_path_hash_mode(path_hex: str | None, path_len: int) -> int:
|
||||
"""Infer the contact path hash mode from stored path bytes and hop count."""
|
||||
if path_len < 0:
|
||||
return -1
|
||||
if path_len == 0 or not path_hex:
|
||||
return 0
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
if len(path_hex) % 2 != 0:
|
||||
return 0
|
||||
|
||||
path_bytes = len(path_hex) // 2
|
||||
if path_bytes == 0 or path_bytes % path_len != 0:
|
||||
return 0
|
||||
|
||||
bytes_per_hop = path_bytes // path_len
|
||||
if bytes_per_hop < 1:
|
||||
return 0
|
||||
return bytes_per_hop - 1
|
||||
def effective_route(self) -> tuple[str, int, int]:
|
||||
if self.has_route_override():
|
||||
return normalize_contact_route(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
return normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
@@ -46,43 +105,32 @@ class Contact(BaseModel):
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
"""
|
||||
out_path_hash_mode = self.out_path_hash_mode
|
||||
if out_path_hash_mode is None:
|
||||
out_path_hash_mode = self._derive_out_path_hash_mode(self.last_path, self.last_path_len)
|
||||
|
||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
"type": self.type,
|
||||
"flags": self.flags,
|
||||
"out_path": self.last_path or "",
|
||||
"out_path_len": self.last_path_len,
|
||||
"out_path": last_path,
|
||||
"out_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"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,
|
||||
}
|
||||
|
||||
def to_upsert(self, **changes) -> ContactUpsert:
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
return ContactUpsert.from_contact(self, **changes)
|
||||
|
||||
@staticmethod
|
||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
||||
"""Convert radio contact data to database format dict.
|
||||
|
||||
This is the inverse of to_radio_dict(), used when syncing contacts
|
||||
from radio to database.
|
||||
"""
|
||||
return {
|
||||
"public_key": public_key,
|
||||
"name": radio_data.get("adv_name"),
|
||||
"type": radio_data.get("type", 0),
|
||||
"flags": radio_data.get("flags", 0),
|
||||
"last_path": radio_data.get("out_path"),
|
||||
"last_path_len": radio_data.get("out_path_len", -1),
|
||||
"out_path_hash_mode": radio_data.get("out_path_hash_mode"),
|
||||
"lat": radio_data.get("adv_lat"),
|
||||
"lon": radio_data.get("adv_lon"),
|
||||
"last_advert": radio_data.get("last_advert"),
|
||||
"on_radio": on_radio,
|
||||
}
|
||||
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
|
||||
return ContactUpsert.from_radio_dict(
|
||||
public_key,
|
||||
radio_data,
|
||||
on_radio=on_radio,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
@@ -96,6 +144,18 @@ class CreateContactRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactRoutingOverrideRequest(BaseModel):
|
||||
"""Request to set, force, or clear a contact routing override."""
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override and resets learned routing to flood, "
|
||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||
"comma-separated 1/2/3-byte hop hex values"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
@@ -106,7 +166,8 @@ class ContactAdvertPath(BaseModel):
|
||||
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, or null for direct"
|
||||
default=None,
|
||||
description="First hop toward us as a full hop identifier, 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")
|
||||
@@ -164,11 +225,61 @@ class ContactDetail(BaseModel):
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NameOnlyContactDetail(BaseModel):
|
||||
"""Channel activity summary for a sender name that is not tied to a known key."""
|
||||
|
||||
name: str
|
||||
channel_message_count: int = 0
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactAnalyticsHourlyBucket(BaseModel):
|
||||
"""A single hourly activity bucket for contact analytics."""
|
||||
|
||||
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
|
||||
last_24h_count: int = 0
|
||||
last_week_average: float = 0
|
||||
all_time_average: float = 0
|
||||
|
||||
|
||||
class ContactAnalyticsWeeklyBucket(BaseModel):
|
||||
"""A single weekly activity bucket for contact analytics."""
|
||||
|
||||
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class ContactAnalytics(BaseModel):
|
||||
"""Unified contact analytics payload for keyed and name-only lookups."""
|
||||
|
||||
lookup_type: Literal["contact", "name"]
|
||||
name: str
|
||||
contact: Contact | None = None
|
||||
name_first_seen_at: int | None = None
|
||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
||||
dm_message_count: int = 0
|
||||
channel_message_count: int = 0
|
||||
includes_direct_messages: bool = False
|
||||
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)
|
||||
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
|
||||
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
key: str = Field(description="Channel key (32-char hex)")
|
||||
name: str
|
||||
is_hashtag: bool = False
|
||||
on_radio: bool = False
|
||||
flood_scope_override: str | None = Field(
|
||||
default=None,
|
||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
|
||||
|
||||
@@ -205,7 +316,10 @@ class MessagePath(BaseModel):
|
||||
|
||||
path: str = Field(description="Hex-encoded routing path")
|
||||
received_at: int = Field(description="Unix timestamp when this path was received")
|
||||
path_len: int | None = Field(default=None, description="Number of hops in the path, when known")
|
||||
path_len: int | None = Field(
|
||||
default=None,
|
||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
||||
)
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -448,8 +562,8 @@ 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)"
|
||||
"Configured radio contact capacity used for maintenance thresholds; "
|
||||
"favorites reload first, then background fill targets about 80% of this value"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
@@ -497,7 +611,7 @@ class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
@@ -518,6 +632,16 @@ class ContactActivityCounts(BaseModel):
|
||||
last_week: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
total_packets: int
|
||||
single_byte: int
|
||||
double_byte: int
|
||||
triple_byte: int
|
||||
single_byte_pct: float
|
||||
double_byte_pct: float
|
||||
triple_byte_pct: float
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
@@ -531,3 +655,4 @@ class StatisticsResponse(BaseModel):
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
|
||||
@@ -29,20 +29,27 @@ from app.decoder import (
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
Message,
|
||||
MessagePath,
|
||||
Contact,
|
||||
ContactUpsert,
|
||||
RawPacketBroadcast,
|
||||
RawPacketDecryptedInfo,
|
||||
)
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
from app.services.messages import (
|
||||
create_message_from_decrypted as _create_message_from_decrypted,
|
||||
)
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,77 +57,6 @@ logger = logging.getLogger(__name__)
|
||||
_raw_observation_counter = count(1)
|
||||
|
||||
|
||||
async def _handle_duplicate_message(
|
||||
packet_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
received: int,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record.
|
||||
|
||||
Called when MessageRepository.create returns None (INSERT OR IGNORE hit a duplicate).
|
||||
Looks up the existing message, adds the new path, increments ack count for outgoing
|
||||
messages, and broadcasts the update to clients.
|
||||
"""
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
label = "message" if msg_type == "CHAN" else "DM"
|
||||
logger.warning(
|
||||
"Duplicate %s for %s but couldn't find existing",
|
||||
label,
|
||||
conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len=path_len)
|
||||
else:
|
||||
# Get current paths for broadcast
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
# Increment ack count for outgoing messages (echo confirmation)
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
# 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 [],
|
||||
},
|
||||
)
|
||||
|
||||
# Mark this packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
@@ -133,105 +69,21 @@ async def create_message_from_decrypted(
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted channel packet content.
|
||||
|
||||
This is the shared logic for storing decrypted channel messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
channel_key: Hex string channel key
|
||||
channel_name: Channel name (e.g. "#general"), for bot context
|
||||
sender: Sender name (will be prefixed to message) or None
|
||||
message_text: The decrypted message content
|
||||
timestamp: Sender timestamp from the packet
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path
|
||||
realtime: If False, skip fanout dispatch (used for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
received = received_at or int(time.time())
|
||||
normalized_path_len = (
|
||||
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
|
||||
)
|
||||
|
||||
# Format the message text with sender prefix if present
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
|
||||
# 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",
|
||||
text=text,
|
||||
conversation_key=channel_key_normalized,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
"""Store a decrypted channel message via the shared message service."""
|
||||
return await _create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key,
|
||||
sender=sender,
|
||||
message_text=message_text,
|
||||
timestamp=timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=normalized_path_len,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected - this happens when:
|
||||
# 1. Our own outgoing message echoes back (flood routing)
|
||||
# 2. Same message arrives via multiple paths before first is committed
|
||||
# In either case, add the path to the existing message.
|
||||
await _handle_duplicate_message(
|
||||
packet_id,
|
||||
"CHAN",
|
||||
channel_key_normalized,
|
||||
text,
|
||||
timestamp,
|
||||
path,
|
||||
normalized_path_len,
|
||||
received,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# 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, path_len=normalized_path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
).model_dump(),
|
||||
path_len=path_len,
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
packet_id: int,
|
||||
@@ -244,114 +96,20 @@ async def create_dm_message_from_decrypted(
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
This is the shared logic for storing decrypted direct messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
decrypted: DecryptedDirectMessage from decoder
|
||||
their_public_key: The contact's full 64-char public key (conversation_key)
|
||||
our_public_key: Our public key (to determine direction), or None
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path
|
||||
outgoing: Whether this is an outgoing message (we sent it)
|
||||
realtime: If False, skip fanout dispatch (used for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
# 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)
|
||||
if contact and contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
their_public_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
received = received_at or int(time.time())
|
||||
normalized_path_len = (
|
||||
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
|
||||
)
|
||||
|
||||
# conversation_key is always the other party's public key
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
# Resolve sender name for incoming messages (used for name-based blocking)
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
"""Store a decrypted direct message via the shared message service."""
|
||||
return await _create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=their_public_key,
|
||||
our_public_key=our_public_key,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=normalized_path_len,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected
|
||||
await _handle_duplicate_message(
|
||||
packet_id,
|
||||
"PRIV",
|
||||
conversation_key,
|
||||
decrypted.message,
|
||||
decrypted.timestamp,
|
||||
path,
|
||||
normalized_path_len,
|
||||
received,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
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,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
).model_dump(),
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def run_historical_dm_decryption(
|
||||
private_key_bytes: bytes,
|
||||
@@ -410,6 +168,7 @@ async def run_historical_dm_decryption(
|
||||
# Extract path from the raw packet for storage
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
path_len = packet_info.path_length if packet_info else None
|
||||
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
@@ -418,7 +177,7 @@ async def run_historical_dm_decryption(
|
||||
our_public_key=our_public_key_bytes.hex(),
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
)
|
||||
@@ -658,7 +417,6 @@ async def _process_advertisement(
|
||||
Process an advertisement packet.
|
||||
|
||||
Extracts contact info and updates the database/broadcasts to clients.
|
||||
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
|
||||
"""
|
||||
# Parse packet to get path info if not already provided
|
||||
if packet_info is None:
|
||||
@@ -703,9 +461,11 @@ async def _process_advertisement(
|
||||
assert existing is not None # Guaranteed by the conditions that set use_existing_path
|
||||
path_len = existing.last_path_len if existing.last_path_len is not None else -1
|
||||
path_hex = existing.last_path or ""
|
||||
out_path_hash_mode = existing.out_path_hash_mode
|
||||
else:
|
||||
path_len = new_path_len
|
||||
path_hex = new_path_hex
|
||||
out_path_hash_mode = packet_info.path_hash_size - 1
|
||||
|
||||
logger.debug(
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
||||
@@ -729,56 +489,53 @@ async def _process_advertisement(
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
path_len=new_path_len,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
|
||||
# Record name history
|
||||
if advert.name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
type=contact_type,
|
||||
lat=advert.lat,
|
||||
lon=advert.lon,
|
||||
last_advert=advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
last_seen=timestamp,
|
||||
last_path=path_hex,
|
||||
last_path_len=path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
contact_data = {
|
||||
"public_key": advert.public_key.lower(),
|
||||
"name": advert.name,
|
||||
"type": contact_type,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_advert": advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
"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],
|
||||
)
|
||||
if advert.name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(
|
||||
advert.public_key, advert.name
|
||||
)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
advert.name,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=advert.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=advert.public_key,
|
||||
contact_name=advert.name,
|
||||
timestamp=timestamp,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# 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())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
broadcast_event("contact", contact_data)
|
||||
broadcast_event(
|
||||
"contact",
|
||||
Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump(),
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -789,14 +546,6 @@ async def _process_advertisement(
|
||||
if settings.auto_decrypt_dm_on_advert:
|
||||
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
|
||||
|
||||
# If this is not a repeater, trigger recent contacts sync to radio
|
||||
# This ensures we can auto-ACK DMs from recent contacts
|
||||
if contact_type != CONTACT_TYPE_REPEATER:
|
||||
# Import here to avoid circular import
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
|
||||
|
||||
async def _process_direct_message(
|
||||
raw_bytes: bytes,
|
||||
|
||||
@@ -1,32 +1,246 @@
|
||||
"""Helpers for working with hex-encoded routing paths."""
|
||||
"""
|
||||
Centralized helpers for MeshCore multi-byte path encoding.
|
||||
|
||||
The path_len wire byte is packed as [hash_mode:2][hop_count:6]:
|
||||
- hash_size = (hash_mode) + 1 → 1, 2, or 3 bytes per hop
|
||||
- hop_count = lower 6 bits → 0–63 hops
|
||||
- wire bytes = hop_count × hash_size
|
||||
|
||||
Mode 3 (hash_size=4) is reserved and rejected.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
MAX_PATH_SIZE = 64
|
||||
|
||||
|
||||
def get_path_hop_width(path_hex: str | None, path_len: int | None) -> int:
|
||||
"""Return hop width in hex chars, falling back to legacy 1-byte hops."""
|
||||
if not path_hex:
|
||||
return 2
|
||||
if isinstance(path_len, int) and path_len > 0 and len(path_hex) % path_len == 0:
|
||||
hop_width = len(path_hex) // path_len
|
||||
if hop_width > 0 and hop_width % 2 == 0:
|
||||
return hop_width
|
||||
return 2
|
||||
@dataclass(frozen=True)
|
||||
class ParsedPacketEnvelope:
|
||||
"""Canonical packet framing parse matching MeshCore Packet::readFrom()."""
|
||||
|
||||
header: int
|
||||
route_type: int
|
||||
payload_type: int
|
||||
payload_version: int
|
||||
path_byte: int
|
||||
hop_count: int
|
||||
hash_size: int
|
||||
path_byte_len: int
|
||||
path: bytes
|
||||
payload: bytes
|
||||
payload_offset: int
|
||||
|
||||
|
||||
def split_path_hops(path_hex: str | None, path_len: int | None) -> list[str]:
|
||||
"""Split a hex path string into hop-sized chunks."""
|
||||
if not path_hex:
|
||||
def decode_path_byte(path_byte: int) -> tuple[int, int]:
|
||||
"""Decode a packed path byte into (hop_count, hash_size).
|
||||
|
||||
Returns:
|
||||
(hop_count, hash_size) where hash_size is 1, 2, or 3.
|
||||
|
||||
Raises:
|
||||
ValueError: If hash_mode is 3 (reserved).
|
||||
"""
|
||||
hash_mode = (path_byte >> 6) & 0x03
|
||||
if hash_mode == 3:
|
||||
raise ValueError(f"Reserved path hash mode 3 (path_byte=0x{path_byte:02X})")
|
||||
hop_count = path_byte & 0x3F
|
||||
hash_size = hash_mode + 1
|
||||
return hop_count, hash_size
|
||||
|
||||
|
||||
def path_wire_len(hop_count: int, hash_size: int) -> int:
|
||||
"""Wire byte length of path data."""
|
||||
return hop_count * hash_size
|
||||
|
||||
|
||||
def validate_path_byte(path_byte: int) -> tuple[int, int, int]:
|
||||
"""Validate a packed path byte using firmware-equivalent rules.
|
||||
|
||||
Returns:
|
||||
(hop_count, hash_size, byte_len)
|
||||
|
||||
Raises:
|
||||
ValueError: If the encoding uses reserved mode 3 or exceeds MAX_PATH_SIZE.
|
||||
"""
|
||||
hop_count, hash_size = decode_path_byte(path_byte)
|
||||
byte_len = path_wire_len(hop_count, hash_size)
|
||||
if byte_len > MAX_PATH_SIZE:
|
||||
raise ValueError(
|
||||
f"Invalid path length {byte_len} bytes exceeds MAX_PATH_SIZE={MAX_PATH_SIZE}"
|
||||
)
|
||||
return hop_count, hash_size, byte_len
|
||||
|
||||
|
||||
def parse_packet_envelope(raw_packet: bytes) -> ParsedPacketEnvelope | None:
|
||||
"""Parse packet framing using firmware Packet::readFrom() semantics.
|
||||
|
||||
Validation matches the firmware's path checks:
|
||||
- reserved mode 3 is invalid
|
||||
- hop_count * hash_size must not exceed MAX_PATH_SIZE
|
||||
- at least one payload byte must remain after the path
|
||||
"""
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
payload_type = (header >> 2) & 0x0F
|
||||
payload_version = (header >> 6) & 0x03
|
||||
|
||||
offset = 1
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_byte = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
hop_count, hash_size, path_byte_len = validate_path_byte(path_byte)
|
||||
if len(raw_packet) < offset + path_byte_len:
|
||||
return None
|
||||
|
||||
path = raw_packet[offset : offset + path_byte_len]
|
||||
offset += path_byte_len
|
||||
|
||||
if offset >= len(raw_packet):
|
||||
return None
|
||||
|
||||
return ParsedPacketEnvelope(
|
||||
header=header,
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
payload_version=payload_version,
|
||||
path_byte=path_byte,
|
||||
hop_count=hop_count,
|
||||
hash_size=hash_size,
|
||||
path_byte_len=path_byte_len,
|
||||
path=path,
|
||||
payload=raw_packet[offset:],
|
||||
payload_offset=offset,
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def split_path_hex(path_hex: str, hop_count: int) -> list[str]:
|
||||
"""Split a hex path string into per-hop chunks using the known hop count.
|
||||
|
||||
If hop_count is 0 or the hex length doesn't divide evenly, falls back
|
||||
to 2-char (1-byte) chunks for backward compatibility.
|
||||
"""
|
||||
if not path_hex or hop_count <= 0:
|
||||
return []
|
||||
|
||||
hop_width = get_path_hop_width(path_hex, path_len)
|
||||
normalized = path_hex.lower()
|
||||
return [
|
||||
normalized[i : i + hop_width]
|
||||
for i in range(0, len(normalized), hop_width)
|
||||
if i + hop_width <= len(normalized)
|
||||
]
|
||||
chars_per_hop = len(path_hex) // hop_count
|
||||
if chars_per_hop < 2 or chars_per_hop % 2 != 0 or chars_per_hop * hop_count != len(path_hex):
|
||||
# Inconsistent — fall back to legacy 2-char split
|
||||
return [path_hex[i : i + 2] for i in range(0, len(path_hex), 2)]
|
||||
return [path_hex[i : i + chars_per_hop] for i in range(0, len(path_hex), chars_per_hop)]
|
||||
|
||||
|
||||
def first_path_hop(path_hex: str | None, path_len: int | None) -> str | None:
|
||||
"""Return the first hop from a hex path string, if any."""
|
||||
hops = split_path_hops(path_hex, path_len)
|
||||
def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
|
||||
"""Extract the first hop identifier from a path hex string.
|
||||
|
||||
Returns None for empty/direct paths.
|
||||
"""
|
||||
hops = split_path_hex(path_hex, hop_count)
|
||||
return hops[0] if hops else None
|
||||
|
||||
|
||||
def normalize_contact_route(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Normalize stored contact route fields.
|
||||
|
||||
Handles legacy/bad rows where the packed wire path byte was stored directly
|
||||
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||
Returns `(path_hex, hop_count, hash_mode)`.
|
||||
"""
|
||||
normalized_path = path_hex or ""
|
||||
|
||||
try:
|
||||
normalized_len = int(path_len) if path_len is not None else -1
|
||||
except (TypeError, ValueError):
|
||||
normalized_len = -1
|
||||
|
||||
try:
|
||||
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
|
||||
except (TypeError, ValueError):
|
||||
normalized_mode = None
|
||||
|
||||
if normalized_len < -1 or normalized_len > 63:
|
||||
packed = normalized_len & 0xFF
|
||||
if packed == 0xFF:
|
||||
return "", -1, -1
|
||||
decoded_mode = (packed >> 6) & 0x03
|
||||
if decoded_mode != 0x03:
|
||||
normalized_len = packed & 0x3F
|
||||
normalized_mode = decoded_mode
|
||||
|
||||
if normalized_len == -1:
|
||||
return "", -1, -1
|
||||
|
||||
if normalized_mode not in (0, 1, 2):
|
||||
normalized_mode = 0
|
||||
|
||||
if normalized_path:
|
||||
bytes_per_hop = normalized_mode + 1
|
||||
actual_bytes = len(normalized_path) // 2
|
||||
expected_bytes = normalized_len * bytes_per_hop
|
||||
if actual_bytes > expected_bytes >= 0:
|
||||
normalized_path = normalized_path[: expected_bytes * 2]
|
||||
elif (
|
||||
actual_bytes < expected_bytes
|
||||
and bytes_per_hop > 0
|
||||
and actual_bytes % bytes_per_hop == 0
|
||||
):
|
||||
normalized_len = actual_bytes // bytes_per_hop
|
||||
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def normalize_route_override(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
) -> tuple[str | None, int | None, int | None]:
|
||||
"""Normalize optional route-override fields while preserving the unset state."""
|
||||
if path_len is None:
|
||||
return None, None, None
|
||||
|
||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||
path_hex,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
||||
"""Parse a comma-separated explicit hop route into stored contact fields."""
|
||||
hops = [hop.strip().lower() for hop in route_text.split(",") if hop.strip()]
|
||||
if not hops:
|
||||
raise ValueError("Explicit path must include at least one hop")
|
||||
|
||||
hop_chars = len(hops[0])
|
||||
if hop_chars not in (2, 4, 6):
|
||||
raise ValueError("Each hop must be 1, 2, or 3 bytes of hex")
|
||||
|
||||
for hop in hops:
|
||||
if len(hop) != hop_chars:
|
||||
raise ValueError("All hops must use the same width")
|
||||
try:
|
||||
bytes.fromhex(hop)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Each hop must be valid hex") from exc
|
||||
|
||||
hash_size = hop_chars // 2
|
||||
if path_wire_len(len(hops), hash_size) > MAX_PATH_SIZE:
|
||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||
|
||||
return "".join(hops), len(hops), hash_size - 1
|
||||
|
||||
258
app/radio.py
258
app/radio.py
@@ -1,12 +1,11 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import inspect
|
||||
import logging
|
||||
import platform
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore import EventType, MeshCore
|
||||
from meshcore import MeshCore
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -122,6 +121,7 @@ class RadioManager:
|
||||
def __init__(self):
|
||||
self._meshcore: MeshCore | None = None
|
||||
self._connection_info: str | None = None
|
||||
self._connection_desired: bool = True
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._last_connected: bool = False
|
||||
self._reconnect_lock: asyncio.Lock | None = None
|
||||
@@ -129,8 +129,8 @@ class RadioManager:
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self._path_hash_mode: int = 0
|
||||
self._path_hash_mode_supported: bool = False
|
||||
self.path_hash_mode: int = 0
|
||||
self.path_hash_mode_supported: bool = False
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -218,98 +218,10 @@ class RadioManager:
|
||||
self._release_operation_lock(name)
|
||||
|
||||
async def post_connect_setup(self) -> None:
|
||||
"""Full post-connection setup: handlers, key export, sync, advertisements, polling.
|
||||
"""Run shared post-connection orchestration after transport setup succeeds."""
|
||||
from app.services.radio_lifecycle import run_post_connect_setup
|
||||
|
||||
Called after every successful connection or reconnection.
|
||||
Idempotent — safe to call repeatedly (periodic tasks have start guards).
|
||||
"""
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
from app.radio_sync import (
|
||||
drain_pending_messages,
|
||||
send_advertisement,
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
|
||||
if not self._meshcore:
|
||||
return
|
||||
|
||||
if self._setup_lock is None:
|
||||
self._setup_lock = asyncio.Lock()
|
||||
|
||||
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)
|
||||
|
||||
# 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 radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
await self.refresh_path_hash_mode_info(mc)
|
||||
|
||||
# Apply flood scope from settings (best-effort; older firmware
|
||||
# may not support set_flood_scope)
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
scope = app_settings.flood_scope
|
||||
try:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r", scope or "(disabled)")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"set_flood_scope failed (firmware may not support it): %s", exc
|
||||
)
|
||||
|
||||
# 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(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# 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 mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
self._release_operation_lock("post_connect_setup")
|
||||
|
||||
# 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
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
await run_post_connect_setup(self)
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
@@ -336,46 +248,39 @@ class RadioManager:
|
||||
return self._setup_complete
|
||||
|
||||
@property
|
||||
def path_hash_mode_info(self) -> tuple[int, bool]:
|
||||
return self._path_hash_mode, self._path_hash_mode_supported
|
||||
def connection_desired(self) -> bool:
|
||||
return self._connection_desired
|
||||
|
||||
def set_path_hash_mode_info(self, mode: int, supported: bool) -> None:
|
||||
self._path_hash_mode = mode if supported and 0 <= mode <= 2 else 0
|
||||
self._path_hash_mode_supported = supported
|
||||
def resume_connection(self) -> None:
|
||||
"""Allow connection monitor and manual reconnects to establish transport again."""
|
||||
self._connection_desired = True
|
||||
|
||||
async def refresh_path_hash_mode_info(self, mc: MeshCore | None = None) -> tuple[int, bool]:
|
||||
target = mc or self._meshcore
|
||||
if target is None:
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
return self.path_hash_mode_info
|
||||
async def pause_connection(self) -> None:
|
||||
"""Stop automatic reconnect attempts and tear down any current transport."""
|
||||
self._connection_desired = False
|
||||
self._last_connected = False
|
||||
await self.disconnect()
|
||||
|
||||
commands = getattr(target, "commands", None)
|
||||
send_device_query = getattr(commands, "send_device_query", None)
|
||||
if commands is None or not callable(send_device_query):
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
return self.path_hash_mode_info
|
||||
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
|
||||
"""Disable library-managed reconnects so manual teardown fully releases transport."""
|
||||
connection_manager = getattr(mc, "connection_manager", None)
|
||||
if connection_manager is None:
|
||||
return
|
||||
|
||||
if hasattr(connection_manager, "auto_reconnect"):
|
||||
connection_manager.auto_reconnect = False
|
||||
|
||||
reconnect_task = getattr(connection_manager, "_reconnect_task", None)
|
||||
if reconnect_task is None or not isinstance(reconnect_task, asyncio.Task | asyncio.Future):
|
||||
return
|
||||
|
||||
reconnect_task.cancel()
|
||||
try:
|
||||
result = send_device_query()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query device info for path hash mode: %s", exc)
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
return self.path_hash_mode_info
|
||||
|
||||
if result is None or getattr(result, "type", None) == EventType.ERROR:
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
return self.path_hash_mode_info
|
||||
|
||||
payload_obj = getattr(result, "payload", None)
|
||||
payload = payload_obj if isinstance(payload_obj, dict) else {}
|
||||
mode = payload.get("path_hash_mode")
|
||||
if isinstance(mode, int) and 0 <= mode <= 2:
|
||||
self.set_path_hash_mode_info(mode, True)
|
||||
else:
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
return self.path_hash_mode_info
|
||||
await reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
connection_manager._reconnect_task = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
@@ -415,7 +320,6 @@ class RadioManager:
|
||||
self._connection_info = f"Serial: {port}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
logger.debug("Serial connection established")
|
||||
|
||||
async def _connect_tcp(self) -> None:
|
||||
@@ -433,7 +337,6 @@ class RadioManager:
|
||||
self._connection_info = f"TCP: {host}:{port}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
logger.debug("TCP connection established")
|
||||
|
||||
async def _connect_ble(self) -> None:
|
||||
@@ -451,17 +354,20 @@ class RadioManager:
|
||||
self._connection_info = f"BLE: {address}"
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
self.set_path_hash_mode_info(0, 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()
|
||||
mc = self._meshcore
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
await mc.disconnect()
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
self._meshcore = None
|
||||
self._setup_complete = False
|
||||
self.set_path_hash_mode_info(0, False)
|
||||
self.path_hash_mode = 0
|
||||
self.path_hash_mode_supported = False
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
@@ -477,6 +383,10 @@ class RadioManager:
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
|
||||
async with self._reconnect_lock:
|
||||
if not self._connection_desired:
|
||||
logger.info("Reconnect skipped because connection is paused by operator")
|
||||
return False
|
||||
|
||||
# If we became connected while waiting for the lock (another
|
||||
# reconnect succeeded ahead of us), skip the redundant attempt.
|
||||
if self.is_connected:
|
||||
@@ -497,6 +407,11 @@ class RadioManager:
|
||||
# Try to connect (will auto-detect if no port specified)
|
||||
await self.connect()
|
||||
|
||||
if not self._connection_desired:
|
||||
logger.info("Reconnect completed after pause request; disconnecting transport")
|
||||
await self.disconnect()
|
||||
return False
|
||||
|
||||
if self.is_connected:
|
||||
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
||||
if broadcast_on_success:
|
||||
@@ -507,83 +422,18 @@ class RadioManager:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Reconnection failed: %s", e)
|
||||
logger.warning("Reconnection failed: %s", e, exc_info=True)
|
||||
broadcast_error("Reconnection failed", str(e))
|
||||
return False
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
"""Start background task to monitor connection and auto-reconnect."""
|
||||
from app.services.radio_lifecycle import connection_monitor_loop
|
||||
|
||||
if self._reconnect_task is not None:
|
||||
return
|
||||
|
||||
async def monitor_loop():
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 5
|
||||
UNRESPONSIVE_THRESHOLD = 3
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
# Detect status change
|
||||
if self._last_connected and not current_connected:
|
||||
# Connection lost
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, self._connection_info)
|
||||
self._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not current_connected:
|
||||
# Attempt reconnection on every loop while disconnected
|
||||
if not self.is_reconnecting and await self.reconnect(
|
||||
broadcast_on_success=False
|
||||
):
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically).
|
||||
# Always run setup before reporting healthy.
|
||||
logger.info("Radio connection restored")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
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)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task is being cancelled, exit cleanly
|
||||
break
|
||||
except Exception as e:
|
||||
consecutive_setup_failures += 1
|
||||
if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD:
|
||||
logger.error(
|
||||
"Post-connect setup has failed %d times in a row. "
|
||||
"The radio port appears open but the radio is not "
|
||||
"responding to commands. Common causes: another "
|
||||
"process has the serial port open (check for other "
|
||||
"RemoteTerm instances, serial monitors, etc.), the "
|
||||
"firmware is in repeater mode (not client), or the "
|
||||
"radio needs a power cycle. Will keep retrying.",
|
||||
consecutive_setup_failures,
|
||||
)
|
||||
elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD:
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
# After the threshold, silently retry (avoid log spam)
|
||||
|
||||
self._reconnect_task = asyncio.create_task(monitor_loop())
|
||||
self._reconnect_task = asyncio.create_task(connection_monitor_loop(self))
|
||||
logger.info("Radio connection monitor started")
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
|
||||
@@ -4,32 +4,74 @@ Radio sync and offload management.
|
||||
This module handles syncing contacts and channels from the radio to the database,
|
||||
then removing them from the radio to free up space for new discoveries.
|
||||
|
||||
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
|
||||
Also handles loading favorites plus recently active contacts TO the radio for DM ACK support.
|
||||
Also handles periodic message polling as a fallback for platforms where push events
|
||||
don't work reliably.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from meshcore import EventType, MeshCore
|
||||
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.models import Contact
|
||||
from app.radio import RadioOperationBusyError, radio_manager
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"""Return key contact fields for sync failure diagnostics."""
|
||||
return {
|
||||
"type": contact.type,
|
||||
"flags": contact.flags,
|
||||
"last_path": contact.last_path,
|
||||
"last_path_len": contact.last_path_len,
|
||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
||||
"route_override_path": contact.route_override_path,
|
||||
"route_override_len": contact.route_override_len,
|
||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||
"last_advert": contact.last_advert,
|
||||
"lat": contact.lat,
|
||||
"lon": contact.lon,
|
||||
"on_radio": contact.on_radio,
|
||||
}
|
||||
|
||||
|
||||
async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
try:
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
log=logger,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Background contact reconciliation failed for %s: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -59,9 +101,12 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st
|
||||
# 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 in seconds when aggressive fallback is enabled.
|
||||
MESSAGE_POLL_INTERVAL = 10
|
||||
|
||||
# Always-on audit interval when aggressive fallback is disabled.
|
||||
MESSAGE_POLL_AUDIT_INTERVAL = 3600
|
||||
|
||||
# Periodic advertisement task handle
|
||||
_advert_task: asyncio.Task | None = None
|
||||
|
||||
@@ -100,9 +145,58 @@ async def pause_polling():
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Sync interval in seconds (5 minutes)
|
||||
# Periodic maintenance check interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
# Reload non-favorite contacts up to 80% of configured radio capacity after offload.
|
||||
RADIO_CONTACT_REFILL_RATIO = 0.80
|
||||
|
||||
# Trigger a full offload/reload once occupancy reaches 95% of configured capacity.
|
||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||
|
||||
|
||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||
capacity = max(1, max_contacts)
|
||||
refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5)))
|
||||
full_sync_trigger = max(
|
||||
refill_target,
|
||||
min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)),
|
||||
)
|
||||
return refill_target, full_sync_trigger
|
||||
|
||||
|
||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
capacity = app_settings.max_radio_contacts
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||
return False
|
||||
|
||||
current_contacts = len(result.payload or {})
|
||||
if current_contacts >= full_sync_trigger:
|
||||
logger.info(
|
||||
"Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
"""
|
||||
@@ -136,26 +230,14 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
for public_key, contact_data in contacts.items():
|
||||
# Save to database
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
ContactUpsert.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],
|
||||
asyncio.create_task(
|
||||
_reconcile_contact_messages_background(
|
||||
public_key,
|
||||
contact_data.get("adv_name"),
|
||||
)
|
||||
adv_name = contact_data.get("adv_name")
|
||||
if adv_name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(
|
||||
public_key, adv_name
|
||||
)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
# Remove from radio
|
||||
@@ -257,7 +339,8 @@ async def ensure_default_channels() -> None:
|
||||
Ensure default channels exist in the database.
|
||||
These will be configured on the radio when needed for sending.
|
||||
|
||||
The Public channel is protected - it always exists with the canonical name.
|
||||
This seeds the canonical Public channel row in the database if it is missing
|
||||
or misnamed. It does not make the channel undeletable through the router.
|
||||
"""
|
||||
# Public channel - no hashtag, specific well-known key
|
||||
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
@@ -284,10 +367,10 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
# 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 favorites plus a working-set fill back onto the radio immediately
|
||||
# so they do not stay in on_radio=False limbo after offload. 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 {
|
||||
@@ -368,11 +451,10 @@ async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
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()
|
||||
aggressive_fallback = settings.enable_message_poll_fallback
|
||||
await asyncio.sleep(
|
||||
MESSAGE_POLL_INTERVAL if aggressive_fallback else MESSAGE_POLL_AUDIT_INTERVAL
|
||||
)
|
||||
|
||||
if radio_manager.is_connected and not is_polling_paused():
|
||||
try:
|
||||
@@ -383,10 +465,24 @@ async def _message_poll_loop():
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
if count > 0:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
|
||||
@@ -401,7 +497,16 @@ def start_message_polling():
|
||||
global _message_poll_task
|
||||
if _message_poll_task is None or _message_poll_task.done():
|
||||
_message_poll_task = asyncio.create_task(_message_poll_loop())
|
||||
logger.info("Started periodic message polling (interval: %ds)", MESSAGE_POLL_INTERVAL)
|
||||
if settings.enable_message_poll_fallback:
|
||||
logger.info(
|
||||
"Started periodic message polling task (aggressive fallback, interval: %ds)",
|
||||
MESSAGE_POLL_INTERVAL,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Started periodic message audit task (interval: %ds)",
|
||||
MESSAGE_POLL_AUDIT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_message_polling():
|
||||
@@ -468,7 +573,7 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
||||
logger.warning("Failed to send advertisement: %s", result.payload)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Error sending advertisement: %s", e)
|
||||
logger.warning("Error sending advertisement: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -499,8 +604,7 @@ async def _periodic_advert_loop():
|
||||
logger.info("Periodic advertisement task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic advertisement loop: %s", e)
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_periodic_advert():
|
||||
@@ -539,7 +643,7 @@ async def sync_radio_time(mc: MeshCore) -> bool:
|
||||
logger.debug("Synced radio time to %d", now)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to sync radio time: %s", e)
|
||||
logger.warning("Failed to sync radio time: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -548,6 +652,7 @@ async def _periodic_sync_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_INTERVAL)
|
||||
cleanup_expired_acks()
|
||||
if not radio_manager.is_connected:
|
||||
continue
|
||||
|
||||
@@ -556,8 +661,8 @@ async def _periodic_sync_loop():
|
||||
"periodic_sync",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all(mc)
|
||||
if await should_run_full_periodic_sync(mc):
|
||||
await sync_and_offload_all(mc)
|
||||
await sync_radio_time(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic sync: radio busy")
|
||||
@@ -565,7 +670,7 @@ async def _periodic_sync_loop():
|
||||
logger.info("Periodic sync task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic sync: %s", e)
|
||||
logger.error("Error in periodic sync: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_periodic_sync():
|
||||
@@ -598,13 +703,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
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
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
@@ -622,6 +733,12 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
@@ -631,40 +748,116 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
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:
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
|
||||
limit=max_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:
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
|
||||
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
async def ensure_contact_on_radio(
|
||||
public_key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
mc: MeshCore | None = None,
|
||||
) -> dict:
|
||||
"""Ensure one contact is loaded on the radio for ACK/routing support."""
|
||||
global _last_contact_sync
|
||||
|
||||
now = time.time()
|
||||
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
|
||||
logger.debug(
|
||||
"Single-contact sync throttled (last sync %ds ago)",
|
||||
int(now - _last_contact_sync),
|
||||
)
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning("Cannot sync favorite contact '%s': ambiguous key prefix", public_key)
|
||||
return {"loaded": 0, "error": "Ambiguous contact key prefix"}
|
||||
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug("Cannot sync unresolved prefix-only contact %s to radio", public_key)
|
||||
return {"loaded": 0, "error": "Full contact key not yet known"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync favorite contact to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"ensure_contact_on_radio",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping favorite contact sync: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
except Exception as e:
|
||||
logger.error("Error syncing favorite contact to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict:
|
||||
"""Load the provided contacts onto the radio."""
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in selected_contacts:
|
||||
# Check if already on radio
|
||||
for contact in contacts:
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only contact %s during radio load", contact.public_key
|
||||
)
|
||||
continue
|
||||
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())
|
||||
radio_contact_payload = contact.to_radio_dict()
|
||||
result = await mc.commands.add_contact(radio_contact_payload)
|
||||
if result.type == EventType.OK:
|
||||
loaded += 1
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
@@ -687,7 +880,14 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning("Error loading contact %s: %s", contact.public_key[:12], e)
|
||||
logger.warning(
|
||||
"Error loading contact %s with fields=%s radio_payload=%s: %s",
|
||||
contact.public_key[:12],
|
||||
_contact_sync_debug_fields(contact),
|
||||
locals().get("radio_contact_payload"),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0 or failed > 0:
|
||||
logger.info(
|
||||
@@ -708,8 +908,10 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
"""
|
||||
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.
|
||||
Fill order is favorites, then recently contacted non-repeaters,
|
||||
then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
up to the configured capacity; additional non-favorite fill stops at the
|
||||
80% refill target.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Args:
|
||||
@@ -731,6 +933,7 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
# If caller provided a MeshCore instance, use it directly (caller holds the lock)
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
@@ -743,11 +946,12 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping contact sync to radio: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e)
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
20
app/region_scope.py
Normal file
20
app/region_scope.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Helpers for normalizing MeshCore flood-scope / region names."""
|
||||
|
||||
|
||||
def normalize_region_scope(scope: str | None) -> str:
|
||||
"""Normalize a user-facing region scope into MeshCore's internal form.
|
||||
|
||||
Region names are now user-facing plain strings like ``Esperance``.
|
||||
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
|
||||
|
||||
Backward compatibility:
|
||||
- blank/None stays disabled (`""`)
|
||||
- existing leading ``#`` is preserved
|
||||
"""
|
||||
|
||||
stripped = (scope or "").strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
if stripped.startswith("#"):
|
||||
return stripped
|
||||
return f"#{stripped}"
|
||||
@@ -10,8 +10,8 @@ class ChannelRepository:
|
||||
"""Upsert a channel. Key is 32-char hex string."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio, flood_scope_override)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_hashtag = excluded.is_hashtag,
|
||||
@@ -25,7 +25,11 @@ class ChannelRepository:
|
||||
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 = ?",
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
(key.upper(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -35,6 +39,7 @@ class ChannelRepository:
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
@@ -42,7 +47,11 @@ class ChannelRepository:
|
||||
@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"
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
@@ -51,6 +60,7 @@ class ChannelRepository:
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
@@ -79,6 +89,16 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_flood_scope_override(key: str, flood_scope_override: str | None) -> bool:
|
||||
"""Set or clear a channel's flood-scope override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET flood_scope_override = ? WHERE key = ?",
|
||||
(flood_scope_override, 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."""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
@@ -7,8 +8,9 @@ from app.models import (
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
ContactUpsert,
|
||||
)
|
||||
from app.path_utils import first_path_hop
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
@@ -22,22 +24,55 @@ class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: dict[str, Any]) -> None:
|
||||
def _coerce_contact_upsert(
|
||||
contact: ContactUpsert | Contact | Mapping[str, Any],
|
||||
) -> ContactUpsert:
|
||||
if isinstance(contact, ContactUpsert):
|
||||
return contact
|
||||
if isinstance(contact, Contact):
|
||||
return contact.to_upsert()
|
||||
return ContactUpsert.model_validate(contact)
|
||||
|
||||
@staticmethod
|
||||
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
||||
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
contact_row.last_path,
|
||||
contact_row.last_path_len,
|
||||
contact_row.out_path_hash_mode,
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
contact_row.route_override_path,
|
||||
contact_row.route_override_len,
|
||||
contact_row.route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
out_path_hash_mode,
|
||||
last_advert, lat, lon, last_seen, on_radio, last_contacted,
|
||||
first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
route_override_path, route_override_len,
|
||||
route_override_hash_mode,
|
||||
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,
|
||||
out_path_hash_mode = COALESCE(
|
||||
excluded.out_path_hash_mode, contacts.out_path_hash_mode
|
||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
||||
route_override_path = COALESCE(
|
||||
excluded.route_override_path, contacts.route_override_path
|
||||
),
|
||||
route_override_len = COALESCE(
|
||||
excluded.route_override_len, contacts.route_override_len
|
||||
),
|
||||
route_override_hash_mode = COALESCE(
|
||||
excluded.route_override_hash_mode, contacts.route_override_hash_mode
|
||||
),
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
@@ -48,20 +83,23 @@ class ContactRepository:
|
||||
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("out_path_hash_mode"),
|
||||
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"),
|
||||
contact_row.public_key.lower(),
|
||||
contact_row.name,
|
||||
contact_row.type,
|
||||
contact_row.flags,
|
||||
last_path,
|
||||
last_path_len,
|
||||
out_path_hash_mode,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
contact_row.last_advert,
|
||||
contact_row.lat,
|
||||
contact_row.lon,
|
||||
contact_row.last_seen if contact_row.last_seen is not None else int(time.time()),
|
||||
contact_row.on_radio,
|
||||
contact_row.last_contacted,
|
||||
contact_row.first_seen,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
@@ -69,14 +107,41 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
row["last_path"],
|
||||
row["last_path_len"],
|
||||
row["out_path_hash_mode"],
|
||||
)
|
||||
available_columns = set(row.keys())
|
||||
route_override_path = (
|
||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||
)
|
||||
route_override_len = (
|
||||
row["route_override_len"] if "route_override_len" in available_columns else None
|
||||
)
|
||||
route_override_hash_mode = (
|
||||
row["route_override_hash_mode"]
|
||||
if "route_override_hash_mode" in available_columns
|
||||
else None
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
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"],
|
||||
out_path_hash_mode=row["out_path_hash_mode"],
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
route_override_path=route_override_path,
|
||||
route_override_len=route_override_len,
|
||||
route_override_hash_mode=route_override_hash_mode,
|
||||
last_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
@@ -103,6 +168,9 @@ class ContactRepository:
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
exact = await ContactRepository.get_by_key(normalized_prefix)
|
||||
if exact:
|
||||
return exact
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
@@ -188,17 +256,28 @@ class ContactRepository:
|
||||
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).
|
||||
"""
|
||||
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently interacted-with non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently advert-heard non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
@@ -208,22 +287,69 @@ class ContactRepository:
|
||||
|
||||
@staticmethod
|
||||
async def update_path(
|
||||
public_key: str, path: str, path_len: int, out_path_hash_mode: int | None = None
|
||||
public_key: str,
|
||||
path: str,
|
||||
path_len: int,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
if out_path_hash_mode is 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()),
|
||||
)
|
||||
else:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_path = ?, last_path_len = ?, out_path_hash_mode = ?, last_seen = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(path, path_len, out_path_hash_mode, int(time.time()), public_key.lower()),
|
||||
)
|
||||
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
|
||||
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
|
||||
last_seen = ? WHERE public_key = ?""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_path_len,
|
||||
normalized_hash_mode,
|
||||
int(time.time()),
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_routing_override(
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = ?, route_override_len = ?, route_override_hash_mode = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_len,
|
||||
normalized_hash_mode,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_routing_override(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = NULL,
|
||||
route_override_len = NULL,
|
||||
route_override_hash_mode = NULL
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
@@ -283,6 +409,103 @@ class ContactRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def promote_prefix_placeholders(full_key: str) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts to a resolved full key.
|
||||
|
||||
Returns the placeholder public keys that were merged into the full key.
|
||||
"""
|
||||
normalized_full_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
|
||||
FROM contacts
|
||||
WHERE length(public_key) < 64
|
||||
AND ? LIKE public_key || '%'
|
||||
ORDER BY length(public_key) DESC, public_key
|
||||
""",
|
||||
(normalized_full_key,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
promoted_keys: list[str] = []
|
||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
||||
|
||||
for row in rows:
|
||||
old_key = row["public_key"]
|
||||
if old_key == normalized_full_key:
|
||||
continue
|
||||
|
||||
match_cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS match_count
|
||||
FROM contacts
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE ? || '%'
|
||||
""",
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
continue
|
||||
|
||||
if full_exists:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
else:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
||||
(normalized_full_key, old_key),
|
||||
)
|
||||
full_exists = True
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
await db.conn.commit()
|
||||
return promoted_keys
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
@@ -306,10 +529,11 @@ class ContactAdvertPathRepository:
|
||||
@staticmethod
|
||||
def _row_to_path(row) -> ContactAdvertPath:
|
||||
path = row["path_hex"] or ""
|
||||
next_hop = first_path_hop(path, row["path_len"])
|
||||
path_len = row["path_len"]
|
||||
next_hop = first_hop_hex(path, path_len)
|
||||
return ContactAdvertPath(
|
||||
path=path,
|
||||
path_len=row["path_len"],
|
||||
path_len=path_len,
|
||||
next_hop=next_hop,
|
||||
first_seen=row["first_seen"],
|
||||
last_seen=row["last_seen"],
|
||||
@@ -322,7 +546,7 @@ class ContactAdvertPathRepository:
|
||||
path_hex: str,
|
||||
timestamp: int,
|
||||
max_paths: int = 10,
|
||||
path_len: int | None = None,
|
||||
hop_count: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Upsert a unique advert path observation for a contact and prune to N most recent.
|
||||
@@ -332,19 +556,18 @@ class ContactAdvertPathRepository:
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
normalized_path = path_hex.lower()
|
||||
normalized_path_len = path_len if isinstance(path_len, int) else len(normalized_path) // 2
|
||||
path_len = hop_count if hop_count is not None else 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
|
||||
ON CONFLICT(public_key, path_hex, path_len) 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, normalized_path_len, timestamp, timestamp),
|
||||
(normalized_key, normalized_path, path_len, timestamp, timestamp),
|
||||
)
|
||||
|
||||
# Keep only the N most recent unique paths per contact.
|
||||
@@ -352,8 +575,8 @@ class ContactAdvertPathRepository:
|
||||
"""
|
||||
DELETE FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
AND path_hex NOT IN (
|
||||
SELECT path_hex
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import Message, MessagePath
|
||||
from app.models import (
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Message,
|
||||
MessagePath,
|
||||
)
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@dataclass
|
||||
class _SearchQuery:
|
||||
free_text: str
|
||||
user_terms: list[str]
|
||||
channel_terms: list[str]
|
||||
|
||||
_SEARCH_OPERATOR_RE = re.compile(
|
||||
r'(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||
lower_key = public_key.lower()
|
||||
return (
|
||||
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
|
||||
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
|
||||
[lower_key, lower_key],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _name_activity_filter(sender_name: str) -> tuple[str, list[Any]]:
|
||||
return "type = 'CHAN' AND sender_name = ?", [sender_name]
|
||||
|
||||
@staticmethod
|
||||
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
|
||||
"""Parse paths JSON string to list of MessagePath objects."""
|
||||
@@ -44,10 +75,10 @@ class MessageRepository:
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
|
||||
path_entry: dict[str, Any] = {"path": path, "received_at": received_at}
|
||||
path_entry["path_len"] = normalized_path_len
|
||||
paths_json = json.dumps([path_entry])
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
paths_json = json.dumps([entry])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
@@ -78,7 +109,10 @@ class MessageRepository:
|
||||
|
||||
@staticmethod
|
||||
async def add_path(
|
||||
message_id: int, path: str, received_at: int | None = None, path_len: int | None = None
|
||||
message_id: int,
|
||||
path: str,
|
||||
received_at: int | None = None,
|
||||
path_len: int | None = None,
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -89,10 +123,10 @@ class MessageRepository:
|
||||
|
||||
# Atomic append: use json_insert to avoid read-modify-write race when
|
||||
# multiple duplicate packets arrive concurrently for the same message.
|
||||
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
|
||||
new_entry_dict: dict[str, Any] = {"path": path, "received_at": ts}
|
||||
new_entry_dict["path_len"] = normalized_path_len
|
||||
new_entry = json.dumps(new_entry_dict)
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
new_entry = json.dumps(entry)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
COALESCE(paths, '[]'), '$[#]', json(?)
|
||||
@@ -128,7 +162,8 @@ class MessageRepository:
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE public_key LIKE messages.conversation_key || '%'
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
@@ -145,8 +180,16 @@ class MessageRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET sender_key = ?
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
|
||||
(public_key.lower(), name),
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE name = ?
|
||||
) = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM contacts
|
||||
WHERE public_key = ? AND name = ?
|
||||
)""",
|
||||
(public_key.lower(), name, name, public_key.lower(), name),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
@@ -164,6 +207,92 @@ class MessageRepository:
|
||||
else:
|
||||
return "AND conversation_key LIKE ?", f"{conversation_key}%"
|
||||
|
||||
@staticmethod
|
||||
def _unescape_search_quoted_value(value: str) -> str:
|
||||
return value.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
@staticmethod
|
||||
def _parse_search_query(q: str) -> _SearchQuery:
|
||||
user_terms: list[str] = []
|
||||
channel_terms: list[str] = []
|
||||
fragments: list[str] = []
|
||||
last_end = 0
|
||||
|
||||
for match in MessageRepository._SEARCH_OPERATOR_RE.finditer(q):
|
||||
fragments.append(q[last_end : match.start()])
|
||||
raw_value = match.group(2) if match.group(2) is not None else match.group(3) or ""
|
||||
value = MessageRepository._unescape_search_quoted_value(raw_value)
|
||||
if match.group(1).lower() == "user":
|
||||
user_terms.append(value)
|
||||
else:
|
||||
channel_terms.append(value)
|
||||
last_end = match.end()
|
||||
|
||||
if not user_terms and not channel_terms:
|
||||
return MessageRepository._SearchQuery(free_text=q, user_terms=[], channel_terms=[])
|
||||
|
||||
fragments.append(q[last_end:])
|
||||
free_text = " ".join(fragment.strip() for fragment in fragments if fragment.strip())
|
||||
return MessageRepository._SearchQuery(
|
||||
free_text=free_text,
|
||||
user_terms=user_terms,
|
||||
channel_terms=channel_terms,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_hex_prefix(value: str) -> bool:
|
||||
return bool(value) and all(ch in "0123456789abcdefABCDEF" for ch in value)
|
||||
|
||||
@staticmethod
|
||||
def _build_channel_scope_clause(value: str) -> tuple[str, list[Any]]:
|
||||
params: list[Any] = [value]
|
||||
clause = "(messages.type = 'CHAN' AND (channels.name = ? COLLATE NOCASE"
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
if len(value) == 32:
|
||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
||||
params.append(value.upper())
|
||||
else:
|
||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||
|
||||
clause += "))"
|
||||
return clause, params
|
||||
|
||||
@staticmethod
|
||||
def _build_user_scope_clause(value: str) -> tuple[str, list[Any]]:
|
||||
params: list[Any] = [value, value]
|
||||
clause = (
|
||||
"((messages.type = 'PRIV' AND contacts.name = ? COLLATE NOCASE)"
|
||||
" OR (messages.type = 'CHAN' AND sender_name = ? COLLATE NOCASE)"
|
||||
)
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
lower_value = value.lower()
|
||||
priv_key_clause: str
|
||||
chan_key_clause: str
|
||||
if len(value) == 64:
|
||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
||||
chan_key_clause = "LOWER(sender_key) = ?"
|
||||
params.extend([lower_value, lower_value])
|
||||
else:
|
||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
||||
params.extend([escaped_prefix, escaped_prefix])
|
||||
|
||||
clause += (
|
||||
f" OR (messages.type = 'PRIV' AND {priv_key_clause})"
|
||||
f" OR (messages.type = 'CHAN' AND sender_key IS NOT NULL AND {chan_key_clause})"
|
||||
)
|
||||
|
||||
clause += ")"
|
||||
return clause, params
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
@@ -197,15 +326,24 @@ class MessageRepository:
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||
query = (
|
||||
"SELECT messages.* FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
||||
"WHERE 1=1"
|
||||
)
|
||||
params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f" AND NOT (messages.outgoing=0 AND ("
|
||||
f"(messages.type='PRIV' AND LOWER(messages.conversation_key) IN ({placeholders}))"
|
||||
f" OR (messages.type='CHAN' AND messages.sender_key IS NOT NULL"
|
||||
f" AND LOWER(messages.sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
@@ -214,36 +352,57 @@ class MessageRepository:
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
|
||||
f" AND sender_name IN ({placeholders}))"
|
||||
f" AND NOT (messages.outgoing=0 AND messages.sender_name IS NOT NULL"
|
||||
f" AND messages.sender_name IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
query += " AND messages.type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
query += f" {clause}"
|
||||
query += f" {clause.replace('conversation_key', 'messages.conversation_key')}"
|
||||
params.append(norm_key)
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
if search_query and search_query.user_terms:
|
||||
scope_clauses: list[str] = []
|
||||
for term in search_query.user_terms:
|
||||
clause, clause_params = MessageRepository._build_user_scope_clause(term)
|
||||
scope_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
query += f" AND ({' OR '.join(scope_clauses)})"
|
||||
|
||||
if search_query and search_query.channel_terms:
|
||||
scope_clauses = []
|
||||
for term in search_query.channel_terms:
|
||||
clause, clause_params = MessageRepository._build_channel_scope_clause(term)
|
||||
scope_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
query += f" AND ({' OR '.join(scope_clauses)})"
|
||||
|
||||
if search_query and search_query.free_text:
|
||||
escaped_q = MessageRepository._escape_like(search_query.free_text)
|
||||
query += " AND messages.text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{escaped_q}%")
|
||||
|
||||
# Forward cursor (after/after_id) — mutually exclusive with before/before_id
|
||||
if after is not None and after_id is not None:
|
||||
query += " AND (received_at > ? OR (received_at = ? AND id > ?))"
|
||||
query += (
|
||||
" AND (messages.received_at > ? OR (messages.received_at = ? AND messages.id > ?))"
|
||||
)
|
||||
params.extend([after, after, after_id])
|
||||
query += " ORDER BY received_at ASC, id ASC LIMIT ?"
|
||||
query += " ORDER BY messages.received_at ASC, messages.id ASC LIMIT ?"
|
||||
params.append(limit)
|
||||
else:
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
query += (
|
||||
" AND (messages.received_at < ?"
|
||||
" OR (messages.received_at = ? AND messages.id < ?))"
|
||||
)
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
query += " ORDER BY messages.received_at DESC, messages.id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
@@ -502,13 +661,61 @@ class MessageRepository:
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
# Last message times for all conversations (including read ones),
|
||||
# excluding blocked incoming traffic so refresh matches live WS behavior.
|
||||
last_time_filters: list[str] = []
|
||||
last_time_params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
last_time_filters.append(
|
||||
f"""
|
||||
NOT (
|
||||
type = 'PRIV'
|
||||
AND outgoing = 0
|
||||
AND LOWER(conversation_key) IN ({placeholders})
|
||||
)
|
||||
"""
|
||||
)
|
||||
last_time_params.extend(blocked_keys)
|
||||
last_time_filters.append(
|
||||
f"""
|
||||
NOT (
|
||||
type = 'CHAN'
|
||||
AND outgoing = 0
|
||||
AND sender_key IS NOT NULL
|
||||
AND LOWER(sender_key) IN ({placeholders})
|
||||
)
|
||||
"""
|
||||
)
|
||||
last_time_params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
last_time_filters.append(
|
||||
f"""
|
||||
NOT (
|
||||
type = 'CHAN'
|
||||
AND outgoing = 0
|
||||
AND sender_name IS NOT NULL
|
||||
AND sender_name IN ({placeholders})
|
||||
)
|
||||
"""
|
||||
)
|
||||
last_time_params.extend(blocked_names)
|
||||
|
||||
last_time_where_sql = (
|
||||
f"WHERE {' AND '.join(last_time_filters)}" if last_time_filters else ""
|
||||
)
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
{last_time_where_sql}
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
""",
|
||||
last_time_params,
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -542,6 +749,26 @@ class MessageRepository:
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def count_channel_messages_by_sender_name(sender_name: str) -> int:
|
||||
"""Count channel messages attributed to a display name."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_name = ?",
|
||||
(sender_name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_first_channel_message_by_sender_name(sender_name: str) -> int | None:
|
||||
"""Get the earliest stored channel message timestamp for a display name."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(received_at) AS first_seen FROM messages WHERE type = 'CHAN' AND sender_name = ?",
|
||||
(sender_name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["first_seen"] if row and row["first_seen"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_stats(conversation_key: str) -> dict:
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
||||
@@ -629,3 +856,135 @@ class MessageRepository:
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms_by_sender_name(
|
||||
sender_name: str, limit: int = 5
|
||||
) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a display name has sent the most messages."""
|
||||
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_name = ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY cnt DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(sender_name, limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def _get_activity_hour_buckets(where_sql: str, params: list[Any]) -> dict[int, int]:
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT received_at / 3600 AS hour_bucket, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE {where_sql}
|
||||
GROUP BY hour_bucket
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {int(row["hour_bucket"]): row["cnt"] for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _build_hourly_activity(
|
||||
hour_counts: dict[int, int], now: int
|
||||
) -> list[ContactAnalyticsHourlyBucket]:
|
||||
current_hour = now // 3600
|
||||
if hour_counts:
|
||||
min_hour = min(hour_counts)
|
||||
else:
|
||||
min_hour = current_hour
|
||||
|
||||
buckets: list[ContactAnalyticsHourlyBucket] = []
|
||||
for hour_bucket in range(current_hour - 23, current_hour + 1):
|
||||
last_24h_count = hour_counts.get(hour_bucket, 0)
|
||||
|
||||
week_total = 0
|
||||
week_samples = 0
|
||||
all_time_total = 0
|
||||
all_time_samples = 0
|
||||
compare_hour = hour_bucket
|
||||
while compare_hour >= min_hour:
|
||||
count = hour_counts.get(compare_hour, 0)
|
||||
all_time_total += count
|
||||
all_time_samples += 1
|
||||
if week_samples < 7:
|
||||
week_total += count
|
||||
week_samples += 1
|
||||
compare_hour -= 24
|
||||
|
||||
buckets.append(
|
||||
ContactAnalyticsHourlyBucket(
|
||||
bucket_start=hour_bucket * 3600,
|
||||
last_24h_count=last_24h_count,
|
||||
last_week_average=round(week_total / week_samples, 2) if week_samples else 0,
|
||||
all_time_average=round(all_time_total / all_time_samples, 2)
|
||||
if all_time_samples
|
||||
else 0,
|
||||
)
|
||||
)
|
||||
return buckets
|
||||
|
||||
@staticmethod
|
||||
async def _get_weekly_activity(
|
||||
where_sql: str,
|
||||
params: list[Any],
|
||||
now: int,
|
||||
weeks: int = 26,
|
||||
) -> list[ContactAnalyticsWeeklyBucket]:
|
||||
bucket_seconds = 7 * 24 * 3600
|
||||
current_day_start = (now // 86400) * 86400
|
||||
start = current_day_start - (weeks - 1) * bucket_seconds
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT (received_at - ?) / ? AS bucket_idx, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE {where_sql} AND received_at >= ?
|
||||
GROUP BY bucket_idx
|
||||
""",
|
||||
[start, bucket_seconds, *params, start],
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
counts = {int(row["bucket_idx"]): row["cnt"] for row in rows}
|
||||
|
||||
return [
|
||||
ContactAnalyticsWeeklyBucket(
|
||||
bucket_start=start + bucket_idx * bucket_seconds,
|
||||
message_count=counts.get(bucket_idx, 0),
|
||||
)
|
||||
for bucket_idx in range(weeks)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_contact_activity_series(
|
||||
public_key: str,
|
||||
now: int | None = None,
|
||||
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
|
||||
"""Get combined DM + channel activity series for a keyed contact."""
|
||||
ts = now if now is not None else int(time.time())
|
||||
where_sql, params = MessageRepository._contact_activity_filter(public_key)
|
||||
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
|
||||
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
|
||||
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
|
||||
return hourly, weekly
|
||||
|
||||
@staticmethod
|
||||
async def get_sender_name_activity_series(
|
||||
sender_name: str,
|
||||
now: int | None = None,
|
||||
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
|
||||
"""Get channel-only activity series for a sender name."""
|
||||
ts = now if now is not None else int(time.time())
|
||||
where_sql, params = MessageRepository._name_activity_filter(sender_name)
|
||||
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
|
||||
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
|
||||
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
|
||||
return hourly, weekly
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, Favorite
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -269,6 +270,53 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total_packets = single_byte + double_byte + triple_byte
|
||||
if total_packets == 0:
|
||||
return {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
"double_byte": 0,
|
||||
"triple_byte": 0,
|
||||
"single_byte_pct": 0.0,
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_packets": total_packets,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total_packets) * 100,
|
||||
"double_byte_pct": (double_byte / total_packets) * 100,
|
||||
"triple_byte_pct": (triple_byte / total_packets) * 100,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
"""Aggregate all statistics from existing tables."""
|
||||
@@ -348,6 +396,7 @@ class StatisticsRepository:
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
@@ -362,4 +411,5 @@ class StatisticsRepository:
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@@ -7,14 +7,20 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
|
||||
def _broadcast_channel_update(channel: Channel) -> None:
|
||||
broadcast_event("channel", channel.model_dump())
|
||||
|
||||
|
||||
class CreateChannelRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=32)
|
||||
key: str | None = Field(
|
||||
@@ -23,6 +29,12 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
@@ -90,12 +102,12 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
return Channel(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
|
||||
|
||||
_broadcast_channel_update(stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
@@ -114,6 +126,9 @@ async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, l
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
|
||||
if key_hex is not None:
|
||||
count += 1
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is not None:
|
||||
_broadcast_channel_update(stored)
|
||||
logger.debug(
|
||||
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
|
||||
)
|
||||
@@ -136,6 +151,28 @@ async def mark_channel_read(key: str) -> dict:
|
||||
return {"status": "ok", "key": channel.key}
|
||||
|
||||
|
||||
@router.post("/{key}/flood-scope-override", response_model=Channel)
|
||||
async def set_channel_flood_scope_override(
|
||||
key: str, request: ChannelFloodScopeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel flood-scope override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
override = normalize_region_scope(request.flood_scope_override) or None
|
||||
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
@@ -146,8 +183,6 @@ async def delete_channel(key: str) -> dict:
|
||||
logger.info("Deleting channel %s from database", key)
|
||||
await ChannelRepository.delete(key)
|
||||
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("channel_deleted", {"key": key})
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -10,14 +10,17 @@ from app.models import (
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactAnalytics,
|
||||
ContactDetail,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NameOnlyContactDetail,
|
||||
NearestRepeater,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
from app.path_utils import first_path_hop
|
||||
from app.radio import radio_manager
|
||||
from app.path_utils import parse_explicit_hop_route
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
@@ -25,6 +28,11 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,6 +68,143 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
|
||||
"""Push the current effective route to the radio when the contact is already loaded."""
|
||||
if not radio_manager.is_connected or not contact.on_radio:
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(operation_name) as mc:
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s: %s",
|
||||
contact.public_key[:12],
|
||||
result.payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s",
|
||||
contact.public_key[:12],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
for old_key in previous_public_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
|
||||
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)
|
||||
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
|
||||
contact.public_key
|
||||
)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
|
||||
for key, name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
first_hop_stats: dict[str, dict] = {}
|
||||
for p in advert_paths:
|
||||
prefix = p.next_hop
|
||||
if prefix:
|
||||
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
|
||||
)
|
||||
|
||||
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 ContactAnalytics(
|
||||
lookup_type="contact",
|
||||
name=contact.name or contact.public_key[:12],
|
||||
contact=contact,
|
||||
name_history=name_history,
|
||||
dm_message_count=dm_count,
|
||||
channel_message_count=chan_count,
|
||||
includes_direct_messages=True,
|
||||
most_active_rooms=most_active_rooms,
|
||||
advert_paths=advert_paths,
|
||||
advert_frequency=advert_frequency,
|
||||
nearest_repeaters=nearest_repeaters,
|
||||
hourly_activity=hourly_activity,
|
||||
weekly_activity=weekly_activity,
|
||||
)
|
||||
|
||||
|
||||
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
|
||||
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
|
||||
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
|
||||
for key, room_name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
return ContactAnalytics(
|
||||
lookup_type="name",
|
||||
name=name,
|
||||
name_first_seen_at=name_first_seen_at,
|
||||
channel_message_count=chan_count,
|
||||
includes_direct_messages=False,
|
||||
most_active_rooms=most_active_rooms,
|
||||
hourly_activity=hourly_activity,
|
||||
weekly_activity=weekly_activity,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -83,6 +228,26 @@ async def list_repeater_advert_paths(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics", response_model=ContactAnalytics)
|
||||
async def get_contact_analytics(
|
||||
public_key: str | None = Query(default=None),
|
||||
name: str | None = Query(default=None, min_length=1, max_length=200),
|
||||
) -> ContactAnalytics:
|
||||
"""Get unified contact analytics for either a keyed contact or a sender name."""
|
||||
if bool(public_key) == bool(name):
|
||||
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
|
||||
|
||||
if public_key:
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await _build_keyed_contact_analytics(contact)
|
||||
|
||||
assert name is not None
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
return await _build_name_only_contact_analytics(normalized_name)
|
||||
|
||||
|
||||
@router.post("", response_model=Contact)
|
||||
async def create_contact(
|
||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
||||
@@ -103,71 +268,61 @@ async def create_contact(
|
||||
if existing:
|
||||
# Update name if provided
|
||||
if request.name:
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": existing.public_key,
|
||||
"name": request.name,
|
||||
"type": existing.type,
|
||||
"flags": existing.flags,
|
||||
"last_path": existing.last_path,
|
||||
"last_path_len": existing.last_path_len,
|
||||
"last_advert": existing.last_advert,
|
||||
"lat": existing.lat,
|
||||
"lon": existing.lon,
|
||||
"last_seen": existing.last_seen,
|
||||
"on_radio": existing.on_radio,
|
||||
"last_contacted": existing.last_contacted,
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=request.public_key,
|
||||
log=logger,
|
||||
)
|
||||
if promoted_keys:
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await _broadcast_contact_resolution(promoted_keys, existing)
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(
|
||||
background_tasks, request.public_key, request.name or existing.name
|
||||
)
|
||||
|
||||
await _broadcast_contact_update(existing)
|
||||
return existing
|
||||
|
||||
# Create new contact
|
||||
lower_key = request.public_key.lower()
|
||||
contact_data = {
|
||||
"public_key": lower_key,
|
||||
"name": request.name,
|
||||
"type": 0, # Unknown
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=lower_key,
|
||||
name=request.name,
|
||||
out_path_hash_mode=-1,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
logger.info("Created contact %s", lower_key[:12])
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=lower_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12])
|
||||
|
||||
# Backfill sender_key on channel messages that match this contact's name
|
||||
if request.name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Trigger historical decryption if requested
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
|
||||
return Contact(**contact_data)
|
||||
stored = await ContactRepository.get_by_key(lower_key)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
|
||||
await _broadcast_contact_update(stored)
|
||||
await _broadcast_contact_resolution(promoted_keys, stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.get("/{public_key}/detail", response_model=ContactDetail)
|
||||
@@ -178,73 +333,33 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
|
||||
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 hops in advert paths
|
||||
first_hop_stats: dict[str, dict] = {} # first hop -> {heard_count, path_len, last_seen}
|
||||
for p in advert_paths:
|
||||
prefix = first_path_hop(p.path, p.path_len)
|
||||
if prefix:
|
||||
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)
|
||||
|
||||
analytics = await _build_keyed_contact_analytics(contact)
|
||||
assert analytics.contact is not None
|
||||
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,
|
||||
contact=analytics.contact,
|
||||
name_history=analytics.name_history,
|
||||
dm_message_count=analytics.dm_message_count,
|
||||
channel_message_count=analytics.channel_message_count,
|
||||
most_active_rooms=analytics.most_active_rooms,
|
||||
advert_paths=analytics.advert_paths,
|
||||
advert_frequency=analytics.advert_frequency,
|
||||
nearest_repeaters=analytics.nearest_repeaters,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/name-detail", response_model=NameOnlyContactDetail)
|
||||
async def get_name_only_contact_detail(
|
||||
name: str = Query(min_length=1, max_length=200),
|
||||
) -> NameOnlyContactDetail:
|
||||
"""Get channel activity summary for a sender name without a resolved key."""
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
analytics = await _build_name_only_contact_analytics(normalized_name)
|
||||
return NameOnlyContactDetail(
|
||||
name=analytics.name,
|
||||
channel_message_count=analytics.channel_message_count,
|
||||
most_active_rooms=analytics.most_active_rooms,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,21 +399,22 @@ async def sync_contacts_from_radio() -> dict:
|
||||
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)
|
||||
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=lower_key,
|
||||
log=logger,
|
||||
)
|
||||
synced_keys.append(lower_key)
|
||||
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])
|
||||
adv_name = contact_data.get("adv_name")
|
||||
if adv_name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=contact_data.get("adv_name"),
|
||||
log=logger,
|
||||
)
|
||||
stored = await ContactRepository.get_by_key(lower_key)
|
||||
if stored is not None:
|
||||
await _broadcast_contact_update(stored)
|
||||
await _broadcast_contact_resolution(promoted_keys, stored)
|
||||
count += 1
|
||||
|
||||
# Clear on_radio for contacts not found on the radio
|
||||
@@ -347,6 +463,7 @@ async def add_contact_to_radio(public_key: str) -> dict:
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
return {"status": "ok", "message": "Contact already on radio"}
|
||||
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
@@ -458,29 +575,49 @@ 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."""
|
||||
@router.post("/{public_key}/routing-override")
|
||||
async def set_contact_routing_override(
|
||||
public_key: str, request: ContactRoutingOverrideRequest
|
||||
) -> dict:
|
||||
"""Set, force, or clear an explicit routing override for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, out_path_hash_mode=-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:
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info(
|
||||
"Cleared routing override and reset learned path to flood for %s",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif route_text == "-1":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", -1, -1)
|
||||
logger.info("Set forced flood routing override for %s", contact.public_key[:12])
|
||||
elif route_text == "0":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", 0, 0)
|
||||
logger.info("Set forced direct routing override for %s", contact.public_key[:12])
|
||||
else:
|
||||
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])
|
||||
path_hex, path_len, hash_mode = parse_explicit_hop_route(route_text)
|
||||
except ValueError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
|
||||
# Broadcast updated contact so frontend refreshes
|
||||
from app.websocket import broadcast_event
|
||||
await ContactRepository.set_routing_override(
|
||||
contact.public_key,
|
||||
path_hex,
|
||||
path_len,
|
||||
hash_mode,
|
||||
)
|
||||
logger.info(
|
||||
"Set explicit routing override for %s: %d hop(s), %d-byte IDs",
|
||||
contact.public_key[:12],
|
||||
path_len,
|
||||
hash_mode + 1,
|
||||
)
|
||||
|
||||
updated_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated_contact:
|
||||
broadcast_event("contact", updated_contact.model_dump())
|
||||
await _best_effort_push_contact_to_radio(updated_contact, "set_routing_override_on_radio")
|
||||
await _broadcast_contact_update(updated_contact)
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
@@ -1,20 +1,66 @@
|
||||
"""REST API for fanout config CRUD."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.fanout.bot_exec import _analyze_bot_signature
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
_DEFAULT_COMMUNITY_MQTT_BROKER_HOST = "mqtt-us-v1.letsmesh.net"
|
||||
_DEFAULT_COMMUNITY_MQTT_BROKER_PORT = 443
|
||||
_DEFAULT_COMMUNITY_MQTT_TRANSPORT = "websockets"
|
||||
_DEFAULT_COMMUNITY_MQTT_AUTH_MODE = "token"
|
||||
_COMMUNITY_MQTT_TEMPLATE_FIELD_CANONICAL = {
|
||||
"iata": "IATA",
|
||||
"public_key": "PUBLIC_KEY",
|
||||
}
|
||||
_ALLOWED_COMMUNITY_MQTT_TRANSPORTS = {"tcp", "websockets"}
|
||||
_ALLOWED_COMMUNITY_MQTT_AUTH_MODES = {"token", "password", "none"}
|
||||
|
||||
|
||||
def _normalize_community_topic_template(topic_template: str) -> str:
|
||||
"""Normalize Community MQTT topic template placeholders to canonical uppercase form."""
|
||||
template = topic_template.strip() or _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE
|
||||
parts: list[str] = []
|
||||
try:
|
||||
parsed = string.Formatter().parse(template)
|
||||
for literal_text, field_name, format_spec, conversion in parsed:
|
||||
parts.append(literal_text)
|
||||
if field_name is None:
|
||||
continue
|
||||
normalized_field = _COMMUNITY_MQTT_TEMPLATE_FIELD_CANONICAL.get(field_name.lower())
|
||||
if normalized_field is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"topic_template may only use {{IATA}} and {{PUBLIC_KEY}}; got {field_name}"
|
||||
),
|
||||
)
|
||||
replacement = ["{", normalized_field]
|
||||
if conversion:
|
||||
replacement.extend(["!", conversion])
|
||||
if format_spec:
|
||||
replacement.extend([":", format_spec])
|
||||
replacement.append("}")
|
||||
parts.append("".join(replacement))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid topic_template: {exc}") from None
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class FanoutConfigCreate(BaseModel):
|
||||
@@ -43,6 +89,46 @@ def _validate_mqtt_private_config(config: dict) -> None:
|
||||
|
||||
def _validate_mqtt_community_config(config: dict) -> None:
|
||||
"""Validate mqtt_community config blob. Normalizes IATA to uppercase."""
|
||||
broker_host = str(config.get("broker_host", _DEFAULT_COMMUNITY_MQTT_BROKER_HOST)).strip()
|
||||
if not broker_host:
|
||||
broker_host = _DEFAULT_COMMUNITY_MQTT_BROKER_HOST
|
||||
config["broker_host"] = broker_host
|
||||
|
||||
port = config.get("broker_port", _DEFAULT_COMMUNITY_MQTT_BROKER_PORT)
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
|
||||
config["broker_port"] = port
|
||||
|
||||
transport = str(config.get("transport", _DEFAULT_COMMUNITY_MQTT_TRANSPORT)).strip().lower()
|
||||
if transport not in _ALLOWED_COMMUNITY_MQTT_TRANSPORTS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="transport must be 'websockets' or 'tcp'",
|
||||
)
|
||||
config["transport"] = transport
|
||||
config["use_tls"] = bool(config.get("use_tls", True))
|
||||
config["tls_verify"] = bool(config.get("tls_verify", True))
|
||||
|
||||
auth_mode = str(config.get("auth_mode", _DEFAULT_COMMUNITY_MQTT_AUTH_MODE)).strip().lower()
|
||||
if auth_mode not in _ALLOWED_COMMUNITY_MQTT_AUTH_MODES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="auth_mode must be 'token', 'password', or 'none'",
|
||||
)
|
||||
config["auth_mode"] = auth_mode
|
||||
username = str(config.get("username", "")).strip()
|
||||
password = str(config.get("password", "")).strip()
|
||||
if auth_mode == "password" and (not username or not password):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="username and password are required when auth_mode is 'password'",
|
||||
)
|
||||
config["username"] = username
|
||||
config["password"] = password
|
||||
|
||||
token_audience = str(config.get("token_audience", "")).strip()
|
||||
config["token_audience"] = token_audience
|
||||
|
||||
iata = config.get("iata", "").upper().strip()
|
||||
if not iata or not _IATA_RE.fullmatch(iata):
|
||||
raise HTTPException(
|
||||
@@ -51,20 +137,88 @@ def _validate_mqtt_community_config(config: dict) -> None:
|
||||
)
|
||||
config["iata"] = iata
|
||||
|
||||
topic_template = str(
|
||||
config.get("topic_template", _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE)
|
||||
).strip()
|
||||
if not topic_template:
|
||||
topic_template = _DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE
|
||||
|
||||
config["topic_template"] = _normalize_community_topic_template(topic_template)
|
||||
|
||||
|
||||
def _validate_bot_config(config: dict) -> None:
|
||||
"""Validate bot config blob (syntax-check the code)."""
|
||||
"""Validate bot config blob (syntax-check the code and supported signature)."""
|
||||
code = config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
tree = ast.parse(code, filename="<bot_code>", mode="exec")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
bot_def = next(
|
||||
(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if bot_def is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Bot code must define a callable bot() function. "
|
||||
"Use the default bot template as a reference."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
parameters: list[inspect.Parameter] = []
|
||||
positional_args = [
|
||||
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
|
||||
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
|
||||
]
|
||||
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
|
||||
sentinel_default = object()
|
||||
|
||||
for index, (arg, kind) in enumerate(positional_args):
|
||||
has_default = index >= positional_defaults_start
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
arg.arg,
|
||||
kind=kind,
|
||||
default=sentinel_default if has_default else inspect.Parameter.empty,
|
||||
)
|
||||
)
|
||||
if bot_def.args.vararg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
|
||||
)
|
||||
for kwonly_arg, kw_default in zip(
|
||||
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
|
||||
):
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
kwonly_arg.arg,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
default=(
|
||||
sentinel_default if kw_default is not None else inspect.Parameter.empty
|
||||
),
|
||||
)
|
||||
)
|
||||
if bot_def.args.kwarg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
|
||||
)
|
||||
|
||||
_analyze_bot_signature(inspect.Signature(parameters))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
|
||||
|
||||
def _validate_apprise_config(config: dict) -> None:
|
||||
"""Validate apprise config blob."""
|
||||
@@ -88,6 +242,39 @@ def _validate_webhook_config(config: dict) -> None:
|
||||
raise HTTPException(status_code=400, detail="headers must be a JSON object")
|
||||
|
||||
|
||||
def _validate_sqs_config(config: dict) -> None:
|
||||
"""Validate sqs config blob."""
|
||||
queue_url = str(config.get("queue_url", "")).strip()
|
||||
if not queue_url:
|
||||
raise HTTPException(status_code=400, detail="queue_url is required for sqs")
|
||||
if not queue_url.startswith(("https://", "http://")):
|
||||
raise HTTPException(status_code=400, detail="queue_url must start with http:// or https://")
|
||||
|
||||
endpoint_url = str(config.get("endpoint_url", "")).strip()
|
||||
if endpoint_url and not endpoint_url.startswith(("https://", "http://")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="endpoint_url must start with http:// or https://",
|
||||
)
|
||||
|
||||
access_key_id = str(config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(config.get("secret_access_key", "")).strip()
|
||||
session_token = str(config.get("session_token", "")).strip()
|
||||
has_static_keypair = bool(access_key_id) and bool(secret_access_key)
|
||||
has_partial_keypair = bool(access_key_id) != bool(secret_access_key)
|
||||
|
||||
if has_partial_keypair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="access_key_id and secret_access_key must be set together for sqs",
|
||||
)
|
||||
if session_token and not has_static_keypair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_token requires access_key_id and secret_access_key for sqs",
|
||||
)
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
@@ -102,7 +289,7 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
detail="scope.messages must be 'all', 'none', or a filter object",
|
||||
)
|
||||
return {"messages": messages, "raw_packets": "none"}
|
||||
# For mqtt_private, validate scope values
|
||||
# For mqtt_private and sqs, validate scope values
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
@@ -149,6 +336,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
_validate_webhook_config(body.config)
|
||||
elif body.type == "apprise":
|
||||
_validate_apprise_config(body.config)
|
||||
elif body.type == "sqs":
|
||||
_validate_sqs_config(body.config)
|
||||
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
|
||||
@@ -204,6 +393,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
_validate_webhook_config(config_to_validate)
|
||||
elif existing["type"] == "apprise":
|
||||
_validate_apprise_config(config_to_validate)
|
||||
elif existing["type"] == "sqs":
|
||||
_validate_sqs_config(config_to_validate)
|
||||
|
||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||
if updated is None:
|
||||
|
||||
@@ -5,8 +5,8 @@ from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.radio import radio_manager
|
||||
from app.repository import RawPacketRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
@@ -14,6 +14,8 @@ router = APIRouter(tags=["health"])
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
radio_state: str = "disconnected"
|
||||
connection_info: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
@@ -45,9 +47,41 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
|
||||
if not isinstance(setup_in_progress, bool):
|
||||
setup_in_progress = False
|
||||
|
||||
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
|
||||
if not isinstance(setup_complete, bool):
|
||||
setup_complete = radio_connected
|
||||
if not radio_connected:
|
||||
setup_complete = False
|
||||
|
||||
connection_desired = getattr(radio_manager, "connection_desired", True)
|
||||
if not isinstance(connection_desired, bool):
|
||||
connection_desired = True
|
||||
|
||||
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
|
||||
if not isinstance(is_reconnecting, bool):
|
||||
is_reconnecting = False
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
if not connection_desired:
|
||||
radio_state = "paused"
|
||||
elif radio_initializing:
|
||||
radio_state = "initializing"
|
||||
elif radio_connected:
|
||||
radio_state = "connected"
|
||||
elif is_reconnecting:
|
||||
radio_state = "connecting"
|
||||
else:
|
||||
radio_state = "disconnected"
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"radio_initializing": radio_initializing,
|
||||
"radio_state": radio_state,
|
||||
"connection_info": connection_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
|
||||
@@ -2,7 +2,6 @@ import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
@@ -12,9 +11,14 @@ from app.models import (
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.services.message_send import (
|
||||
resend_channel_message_record,
|
||||
send_channel_message_to_channel,
|
||||
send_direct_message_to_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
@@ -104,82 +108,23 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Contact not found in database: {request.destination}"
|
||||
)
|
||||
|
||||
# Always add/update the contact on radio before sending.
|
||||
# The library cache (get_contact_by_key_prefix) can be stale after radio reboot,
|
||||
# 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
|
||||
|
||||
# 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])
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing message
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=request.text,
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if message_id is None:
|
||||
if len(db_contact.public_key) < 64:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
status_code=409,
|
||||
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
|
||||
)
|
||||
|
||||
# Update last_contacted for the contact
|
||||
await ContactRepository.update_last_contacted(db_contact.public_key.lower(), now)
|
||||
|
||||
# Track the expected ACK for this message
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000) # default 10s
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack(ack_code, message_id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
|
||||
|
||||
message = Message(
|
||||
id=message_id,
|
||||
type="PRIV",
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
return await send_direct_message_to_contact(
|
||||
contact=db_contact,
|
||||
text=request.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
track_pending_ack_fn=track_pending_ack,
|
||||
now_fn=time.time,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
# Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
TEMP_RADIO_SLOT = 0
|
||||
@@ -216,112 +161,19 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
TEMP_RADIO_SLOT,
|
||||
expected_hash,
|
||||
)
|
||||
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
|
||||
|
||||
our_public_key: str | None = None
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
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,
|
||||
)
|
||||
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,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
)
|
||||
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,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=db_channel.name,
|
||||
).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,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
paths=paths,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=db_channel.name,
|
||||
return await send_channel_message_to_channel(
|
||||
channel=db_channel,
|
||||
channel_key_upper=request.channel_key.upper(),
|
||||
key_bytes=key_bytes,
|
||||
text=request.text,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=TEMP_RADIO_SLOT,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
@@ -366,97 +218,14 @@ async def resend_channel_message(
|
||||
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
|
||||
|
||||
resend_public_key: str | None = 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 ""
|
||||
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
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,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
)
|
||||
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,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=db_channel.name,
|
||||
).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}
|
||||
return await resend_channel_message_record(
|
||||
message=msg,
|
||||
channel=db_channel,
|
||||
new_timestamp=new_timestamp,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=TEMP_RADIO_SLOT,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
RadioCommandRejectedError,
|
||||
apply_radio_config_update,
|
||||
import_private_key_and_refresh_keystore,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
AdvertLocationSource = Literal["off", "current"]
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.reconnect_and_prepare(
|
||||
broadcast_on_success=broadcast_on_success,
|
||||
)
|
||||
|
||||
|
||||
class RadioSettings(BaseModel):
|
||||
freq: float = Field(description="Frequency in MHz")
|
||||
@@ -29,13 +47,17 @@ class RadioConfigResponse(BaseModel):
|
||||
lon: float
|
||||
tx_power: int = Field(description="Transmit power in dBm")
|
||||
max_tx_power: int = Field(description="Maximum transmit power in dBm")
|
||||
radio: RadioSettings
|
||||
path_hash_mode: int = Field(
|
||||
default=0, description="Default outbound path hash mode (0=1 byte, 1=2 bytes, 2=3 bytes)"
|
||||
default=0, description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)"
|
||||
)
|
||||
path_hash_mode_supported: bool = Field(
|
||||
default=False, description="Whether the connected radio/firmware exposes path hash mode"
|
||||
default=False, description="Whether firmware supports path hash mode setting"
|
||||
)
|
||||
advert_location_source: AdvertLocationSource = Field(
|
||||
default="current",
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
radio: RadioSettings
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -43,56 +65,34 @@ class RadioConfigUpdate(BaseModel):
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
|
||||
path_hash_mode: int | None = Field(
|
||||
default=None, ge=0, le=2, description="Default outbound path hash mode"
|
||||
)
|
||||
radio: RadioSettings | None = None
|
||||
path_hash_mode: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=2,
|
||||
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
|
||||
)
|
||||
advert_location_source: AdvertLocationSource | None = Field(
|
||||
default=None,
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
private_key: str = Field(description="Private key as hex string")
|
||||
|
||||
|
||||
async def _set_path_hash_mode(mc, mode: int):
|
||||
"""Set path hash mode using either the new helper or raw command fallback."""
|
||||
commands = getattr(mc, "commands", None)
|
||||
if commands is None:
|
||||
raise HTTPException(status_code=503, detail="Radio command interface unavailable")
|
||||
|
||||
set_path_hash_mode = cast(
|
||||
Callable[[int], Awaitable[Any]] | None, getattr(commands, "set_path_hash_mode", None)
|
||||
)
|
||||
send_raw = cast(
|
||||
Callable[[bytes, list[EventType]], Awaitable[Any]] | None,
|
||||
getattr(commands, "send", None),
|
||||
)
|
||||
|
||||
if callable(set_path_hash_mode):
|
||||
result = await set_path_hash_mode(mode)
|
||||
elif callable(send_raw):
|
||||
data = b"\x3d\x00" + int(mode).to_bytes(1, "little")
|
||||
result = await send_raw(data, [EventType.OK, EventType.ERROR])
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Installed meshcore interface library cannot set path hash mode",
|
||||
)
|
||||
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to set path hash mode on radio")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
mc = require_connected()
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
||||
|
||||
path_hash_mode, path_hash_mode_supported = radio_manager.path_hash_mode_info
|
||||
adv_loc_policy = info.get("adv_loc_policy", 1)
|
||||
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
||||
|
||||
return RadioConfigResponse(
|
||||
public_key=info.get("public_key", ""),
|
||||
@@ -101,14 +101,15 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
lon=info.get("adv_lon", 0.0),
|
||||
tx_power=info.get("tx_power", 0),
|
||||
max_tx_power=info.get("max_tx_power", 0),
|
||||
path_hash_mode=path_hash_mode,
|
||||
path_hash_mode_supported=path_hash_mode_supported,
|
||||
radio=RadioSettings(
|
||||
freq=info.get("radio_freq", 0.0),
|
||||
bw=info.get("radio_bw", 0.0),
|
||||
sf=info.get("radio_sf", 0),
|
||||
cr=info.get("radio_cr", 0),
|
||||
),
|
||||
path_hash_mode=radio_manager.path_hash_mode,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
advert_location_source=advert_location_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,57 +119,18 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
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.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.path_hash_mode is not None:
|
||||
current_mode, supported = radio_manager.path_hash_mode_info
|
||||
if not supported:
|
||||
current_mode, supported = await radio_manager.refresh_path_hash_mode_info(mc)
|
||||
if not supported:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Connected radio/firmware does not expose path hash mode",
|
||||
)
|
||||
if current_mode != update.path_hash_mode:
|
||||
logger.info("Setting path hash mode to %d", update.path_hash_mode)
|
||||
await _set_path_hash_mode(mc, update.path_hash_mode)
|
||||
radio_manager.set_path_hash_mode_info(update.path_hash_mode, True)
|
||||
|
||||
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,
|
||||
try:
|
||||
await apply_radio_config_update(
|
||||
mc,
|
||||
update,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
set_path_hash_mode=lambda mode: setattr(radio_manager, "path_hash_mode", mode),
|
||||
sync_radio_time_fn=sync_radio_time,
|
||||
)
|
||||
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)
|
||||
|
||||
# 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()
|
||||
except PathHashModeUnsupportedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RadioCommandRejectedError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_radio_config()
|
||||
|
||||
@@ -185,30 +147,16 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
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."
|
||||
),
|
||||
)
|
||||
try:
|
||||
await import_private_key_and_refresh_keystore(
|
||||
mc,
|
||||
key_bytes,
|
||||
export_and_store_private_key_fn=export_and_store_private_key,
|
||||
)
|
||||
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -238,6 +186,8 @@ async def send_advertisement() -> dict:
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
radio_manager.resume_connection()
|
||||
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
@@ -245,14 +195,8 @@ async def _attempt_reconnect() -> dict:
|
||||
"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()
|
||||
success = await _reconnect_and_prepare(broadcast_on_success=True)
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed after reconnect")
|
||||
raise HTTPException(
|
||||
@@ -260,9 +204,28 @@ async def _attempt_reconnect() -> dict:
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect_radio() -> dict:
|
||||
"""Disconnect from the radio and pause automatic reconnect attempts."""
|
||||
logger.info("Manual radio disconnect requested")
|
||||
await radio_manager.pause_connection()
|
||||
broadcast_health(False, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Disconnected. Automatic reconnect is paused.",
|
||||
"connected": False,
|
||||
"paused": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reboot")
|
||||
async def reboot_radio() -> dict:
|
||||
"""Reboot the radio, or reconnect if not currently connected.
|
||||
@@ -297,8 +260,11 @@ async def reconnect_radio() -> dict:
|
||||
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
if not await _prepare_connected(broadcast_on_success=True):
|
||||
raise HTTPException(status_code=503, detail="Radio connection is paused")
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -6,13 +6,13 @@ import time
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import UnreadCounts
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/read-state", tags=["read-state"])
|
||||
|
||||
@@ -25,9 +25,9 @@ from app.models import (
|
||||
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
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
@@ -451,7 +451,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
|
||||
- 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
|
||||
- clock, clock sync, time <epoch_seconds>
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,7 +19,8 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=1,
|
||||
le=1000,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
"Configured radio contact capacity used for maintenance thresholds and "
|
||||
"background refill behavior"
|
||||
),
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
@@ -123,8 +125,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
stripped = update.flood_scope.strip()
|
||||
kwargs["flood_scope"] = stripped
|
||||
kwargs["flood_scope"] = normalize_region_scope(update.flood_scope)
|
||||
flood_scope_changed = True
|
||||
|
||||
if kwargs:
|
||||
@@ -132,7 +133,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
|
||||
# Apply flood scope to radio immediately if changed
|
||||
if flood_scope_changed:
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if radio_manager.is_connected:
|
||||
try:
|
||||
@@ -161,12 +162,12 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
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
|
||||
# When a contact is newly favorited, load just that contact to the radio
|
||||
# immediately so DM ACK support does not wait for the next maintenance cycle.
|
||||
if request.type == "contact" and not is_favorited:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.routers.health import build_health_data
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
121
app/security.py
Normal file
121
app/security.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AUTH_REALM = "RemoteTerm"
|
||||
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
||||
|
||||
|
||||
class BasicAuthMiddleware:
|
||||
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
|
||||
|
||||
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
|
||||
self.app = app
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.realm = realm
|
||||
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
|
||||
|
||||
def _is_authorized(self, scope: dict[str, Any]) -> bool:
|
||||
headers = Headers(scope=scope)
|
||||
authorization = headers.get("authorization")
|
||||
if not authorization:
|
||||
return False
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if not token or scheme.lower() != "basic":
|
||||
return False
|
||||
|
||||
token = token.strip()
|
||||
try:
|
||||
decoded = base64.b64decode(token, validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
logger.debug("Rejecting malformed basic auth header")
|
||||
return False
|
||||
|
||||
username, sep, password = decoded.partition(":")
|
||||
if not sep:
|
||||
return False
|
||||
|
||||
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
|
||||
password, self.password
|
||||
)
|
||||
|
||||
async def _send_http_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_websocket_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
scope_type = scope["type"]
|
||||
if scope_type not in {"http", "websocket"}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if self._is_authorized(scope):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if scope_type == "http":
|
||||
await self._send_http_unauthorized(send)
|
||||
return
|
||||
|
||||
await self._send_websocket_unauthorized(send)
|
||||
|
||||
|
||||
def add_optional_basic_auth_middleware(app, settings) -> None:
|
||||
"""Enable app-wide basic auth when configured via environment variables."""
|
||||
if not settings.basic_auth_enabled:
|
||||
return
|
||||
|
||||
app.add_middleware(
|
||||
BasicAuthMiddleware,
|
||||
username=settings.basic_auth_username,
|
||||
password=settings.basic_auth_password,
|
||||
)
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend service-layer helpers."""
|
||||
133
app/services/contact_reconciliation.py
Normal file
133
app/services/contact_reconciliation.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Shared contact/message reconciliation helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def promote_prefix_contacts_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts once a full key is known."""
|
||||
normalized_key = public_key.lower()
|
||||
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
|
||||
if promoted:
|
||||
(log or logger).info(
|
||||
"Promoted %d prefix contact placeholder(s) for %s",
|
||||
len(promoted),
|
||||
normalized_key[:12],
|
||||
)
|
||||
return promoted
|
||||
|
||||
|
||||
async def claim_prefix_messages_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> int:
|
||||
"""Promote prefix-key DMs to a resolved full public key."""
|
||||
normalized_key = public_key.lower()
|
||||
claimed = await message_repository.claim_prefix_messages(normalized_key)
|
||||
if claimed > 0:
|
||||
(log or logger).info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
normalized_key[:12],
|
||||
)
|
||||
return claimed
|
||||
|
||||
|
||||
async def backfill_channel_sender_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> int:
|
||||
"""Backfill channel sender attribution once a contact name is known."""
|
||||
if not contact_name:
|
||||
return 0
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
backfilled = await message_repository.backfill_channel_sender_key(
|
||||
normalized_key,
|
||||
contact_name,
|
||||
)
|
||||
if backfilled > 0:
|
||||
(log or logger).info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
contact_name,
|
||||
)
|
||||
return backfilled
|
||||
|
||||
|
||||
async def reconcile_contact_messages(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Apply message reconciliation once a contact's identity is resolved."""
|
||||
claimed = await claim_prefix_messages_for_contact(
|
||||
public_key=public_key,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
backfilled = await backfill_channel_sender_for_contact(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
return claimed, backfilled
|
||||
|
||||
|
||||
async def record_contact_name(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
timestamp: int,
|
||||
contact_name_history_repository=ContactNameHistoryRepository,
|
||||
) -> bool:
|
||||
"""Record contact name history when a non-empty name is available."""
|
||||
if not contact_name:
|
||||
return False
|
||||
|
||||
await contact_name_history_repository.record_name(
|
||||
public_key.lower(),
|
||||
contact_name,
|
||||
timestamp,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def record_contact_name_and_reconcile(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
timestamp: int,
|
||||
message_repository=MessageRepository,
|
||||
contact_name_history_repository=ContactNameHistoryRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Record name history, then reconcile message identity for the contact."""
|
||||
await record_contact_name(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
timestamp=timestamp,
|
||||
contact_name_history_repository=contact_name_history_repository,
|
||||
)
|
||||
return await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
43
app/services/dm_ack_tracker.py
Normal file
43
app/services/dm_ack_tracker.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared pending ACK tracking for outgoing direct messages."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PendingAck = tuple[int, float, int]
|
||||
|
||||
_pending_acks: dict[str, PendingAck] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track an expected ACK code for an outgoing direct message."""
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
expected_ack,
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Remove stale pending ACK entries."""
|
||||
now = time.time()
|
||||
expired_codes = [
|
||||
code
|
||||
for code, (_message_id, created_at, timeout_ms) in _pending_acks.items()
|
||||
if now - created_at > (timeout_ms / 1000) * 2
|
||||
]
|
||||
for code in expired_codes:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
|
||||
|
||||
def pop_pending_ack(ack_code: str) -> int | None:
|
||||
"""Claim the tracked message ID for an ACK code if present."""
|
||||
pending = _pending_acks.pop(ack_code, None)
|
||||
if pending is None:
|
||||
return None
|
||||
message_id, _, _ = pending
|
||||
return message_id
|
||||
359
app/services/message_send.py
Normal file
359
app/services/message_send.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Shared send/resend orchestration for outgoing messages."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
||||
from app.services.messages import (
|
||||
build_message_model,
|
||||
create_outgoing_channel_message,
|
||||
create_outgoing_direct_message,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], None]
|
||||
NowFn = Callable[[], float]
|
||||
|
||||
|
||||
async def send_channel_message_with_effective_scope(
|
||||
*,
|
||||
mc,
|
||||
channel,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
timestamp_bytes: bytes,
|
||||
action_label: str,
|
||||
temp_radio_slot: int,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
if override_scope:
|
||||
settings = await app_settings_repository.get()
|
||||
baseline_scope = normalize_region_scope(settings.flood_scope)
|
||||
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
logger.info(
|
||||
"Temporarily applying channel flood_scope override for %s: %r",
|
||||
channel.name,
|
||||
override_scope,
|
||||
)
|
||||
override_result = await mc.commands.set_flood_scope(override_scope)
|
||||
if override_result is not None and override_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel flood_scope override for %s: %s",
|
||||
channel.name,
|
||||
override_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply regional override {override_scope!r} before {action_label}: "
|
||||
f"{override_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=temp_radio_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
temp_radio_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
|
||||
return await mc.commands.send_chan_msg(
|
||||
chan=temp_radio_slot,
|
||||
msg=text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
finally:
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
try:
|
||||
restore_result = await mc.commands.set_flood_scope(
|
||||
baseline_scope if baseline_scope else ""
|
||||
)
|
||||
if restore_result is not None and restore_result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to restore baseline flood_scope after sending to %s: %s",
|
||||
channel.name,
|
||||
restore_result.payload,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Restored baseline flood_scope after channel send: %r",
|
||||
baseline_scope or "(disabled)",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore baseline flood_scope after sending to %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def send_direct_message_to_contact(
|
||||
*,
|
||||
contact,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
track_pending_ack_fn: TrackAckFn,
|
||||
now_fn: NowFn,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
) -> Any:
|
||||
"""Send a direct message and persist/broadcast the outgoing row."""
|
||||
contact_data = contact.to_radio_dict()
|
||||
contact_ensured_on_radio = False
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", 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)
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
|
||||
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not cached_contact:
|
||||
cached_contact = contact_data
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
|
||||
logger.info("Sending direct message to %s", contact.public_key[:12])
|
||||
now = int(now_fn())
|
||||
result = await mc.commands.send_msg(
|
||||
dst=cached_contact,
|
||||
msg=text,
|
||||
timestamp=now,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
if contact_ensured_on_radio and not contact.on_radio:
|
||||
await contact_repository.set_on_radio(contact.public_key.lower(), True)
|
||||
|
||||
message = await create_outgoing_direct_message(
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
await contact_repository.update_last_contacted(contact.public_key.lower(), now)
|
||||
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000)
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack_fn(ack_code, message.id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message.id)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def send_channel_message_to_channel(
|
||||
*,
|
||||
channel,
|
||||
channel_key_upper: str,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
message_repository=MessageRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message and persist/broadcast the outgoing row."""
|
||||
now: int | None = None
|
||||
radio_name = ""
|
||||
our_public_key: str | None = None
|
||||
text_with_sender = text
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_with_sender = f"{radio_name}: {text}" if radio_name else text
|
||||
logger.info("Sending channel message to %s: %s", channel.name, text[:50])
|
||||
|
||||
now = int(now_fn())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="sending message",
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
if now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
outgoing_message = await create_outgoing_channel_message(
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if outgoing_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
message_id = outgoing_message.id
|
||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return build_message_model(
|
||||
message_id=message_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
|
||||
|
||||
async def resend_channel_message_record(
|
||||
*,
|
||||
message,
|
||||
channel,
|
||||
new_timestamp: bool,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
message_repository=MessageRepository,
|
||||
) -> dict[str, Any]:
|
||||
"""Resend a stored outgoing channel message."""
|
||||
try:
|
||||
key_bytes = bytes.fromhex(message.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid channel key format: {message.conversation_key}",
|
||||
) from None
|
||||
|
||||
now: int | None = None
|
||||
if new_timestamp:
|
||||
now = int(now_fn())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = message.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
resend_public_key: str | None = None
|
||||
radio_name = ""
|
||||
|
||||
async with radio_manager.radio_operation("resend_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_to_send = message.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="resending message",
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message.id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message.id,
|
||||
new_message.id,
|
||||
channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_message.id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message.id, channel.name)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
474
app/services/messages.py
Normal file
474
app/services/messages.py
Normal file
@@ -0,0 +1,474 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Message, MessagePath
|
||||
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
LOG_MESSAGE_PREVIEW_LEN = 32
|
||||
|
||||
|
||||
def _truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
|
||||
"""Return a compact single-line message preview for log output."""
|
||||
normalized = " ".join(text.split())
|
||||
if len(normalized) <= max_chars:
|
||||
return normalized
|
||||
return f"{normalized[:max_chars].rstrip()}..."
|
||||
|
||||
|
||||
def _format_channel_log_target(channel_name: str | None, channel_key: str) -> str:
|
||||
"""Return a human-friendly channel label for logs."""
|
||||
return channel_name or channel_key
|
||||
|
||||
|
||||
def _format_contact_log_target(contact_name: str | None, public_key: str) -> str:
|
||||
"""Return a human-friendly DM target label for logs."""
|
||||
return contact_name or public_key[:12]
|
||||
|
||||
|
||||
def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
return (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def build_message_model(
|
||||
*,
|
||||
message_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
received_at: int,
|
||||
paths: list[MessagePath] | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
sender_key: str | None = None,
|
||||
outgoing: bool = False,
|
||||
acked: int = 0,
|
||||
sender_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
) -> Message:
|
||||
"""Build a Message model with the canonical backend payload shape."""
|
||||
return Message(
|
||||
id=message_id,
|
||||
type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
acked=acked,
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
|
||||
|
||||
def broadcast_message(
|
||||
*,
|
||||
message: Message,
|
||||
broadcast_fn: BroadcastFn,
|
||||
realtime: bool | None = None,
|
||||
) -> None:
|
||||
"""Broadcast a message payload, preserving the caller's broadcast signature."""
|
||||
payload = message.model_dump()
|
||||
if realtime is None:
|
||||
broadcast_fn("message", payload)
|
||||
else:
|
||||
broadcast_fn("message", payload, realtime=realtime)
|
||||
|
||||
|
||||
def broadcast_message_acked(
|
||||
*,
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Broadcast a message_acked payload."""
|
||||
broadcast_fn(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def increment_ack_and_broadcast(
|
||||
*,
|
||||
message_id: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int:
|
||||
"""Increment a message's ACK count and broadcast the update."""
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
broadcast_fn("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
return ack_count
|
||||
|
||||
|
||||
async def handle_duplicate_message(
|
||||
*,
|
||||
packet_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
label = "message" if msg_type == "CHAN" else "DM"
|
||||
logger.warning(
|
||||
"Duplicate %s for %s but couldn't find existing",
|
||||
label,
|
||||
conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
*,
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
sender: str | None,
|
||||
message_text: str,
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted channel message."""
|
||||
received = received_at or int(time.time())
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
channel_key_normalized = channel_key.upper()
|
||||
|
||||
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
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel_key_normalized,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=packet_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
|
||||
_truncate_for_log(text),
|
||||
_format_channel_log_target(channel_name, channel_key_normalized),
|
||||
msg_id,
|
||||
channel_key_normalized,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
message=build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
*,
|
||||
packet_id: int,
|
||||
decrypted: "DecryptedDirectMessage",
|
||||
their_public_key: str,
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted direct message."""
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
if contact and contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
their_public_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
received = received_at or int(time.time())
|
||||
conversation_key = their_public_key.lower()
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=packet_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
|
||||
_truncate_for_log(decrypted.message),
|
||||
_format_contact_log_target(contact.name if contact else None, conversation_key),
|
||||
msg_id,
|
||||
conversation_key,
|
||||
outgoing,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
message=build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_fallback_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast a CONTACT_MSG_RECV fallback direct message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="PRIV",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing direct message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="PRIV",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_channel_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing channel message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
114
app/services/radio_commands.py
Normal file
114
app/services/radio_commands.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RadioCommandServiceError(RuntimeError):
|
||||
"""Base error for reusable radio command workflows."""
|
||||
|
||||
|
||||
class PathHashModeUnsupportedError(RadioCommandServiceError):
|
||||
"""Raised when firmware does not support path hash mode updates."""
|
||||
|
||||
|
||||
class RadioCommandRejectedError(RadioCommandServiceError):
|
||||
"""Raised when the radio reports an error for a command."""
|
||||
|
||||
|
||||
class KeystoreRefreshError(RadioCommandServiceError):
|
||||
"""Raised when server-side keystore refresh fails after import."""
|
||||
|
||||
|
||||
async def apply_radio_config_update(
|
||||
mc,
|
||||
update,
|
||||
*,
|
||||
path_hash_mode_supported: bool,
|
||||
set_path_hash_mode: Callable[[int], None],
|
||||
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
|
||||
) -> None:
|
||||
"""Apply a validated radio-config update to the connected radio."""
|
||||
if update.advert_location_source is not None:
|
||||
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
|
||||
logger.info(
|
||||
"Setting advert location policy to %s",
|
||||
update.advert_location_source,
|
||||
)
|
||||
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set advert location policy: {result.payload}"
|
||||
)
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
if update.lat is not None or update.lon is not None:
|
||||
current_info = mc.self_info
|
||||
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
|
||||
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
|
||||
logger.info("Setting radio coordinates to %f, %f", lat, lon)
|
||||
await mc.commands.set_coords(lat=lat, lon=lon)
|
||||
|
||||
if update.tx_power is not None:
|
||||
logger.info("Setting TX power to %d dBm", update.tx_power)
|
||||
await mc.commands.set_tx_power(val=update.tx_power)
|
||||
|
||||
if update.radio is not None:
|
||||
logger.info(
|
||||
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
|
||||
update.radio.freq,
|
||||
update.radio.bw,
|
||||
update.radio.sf,
|
||||
update.radio.cr,
|
||||
)
|
||||
await mc.commands.set_radio(
|
||||
freq=update.radio.freq,
|
||||
bw=update.radio.bw,
|
||||
sf=update.radio.sf,
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
if update.path_hash_mode is not None:
|
||||
if not path_hash_mode_supported:
|
||||
raise PathHashModeUnsupportedError("Firmware does not support path hash mode setting")
|
||||
|
||||
logger.info("Setting path hash mode to %d", update.path_hash_mode)
|
||||
result = await mc.commands.set_path_hash_mode(update.path_hash_mode)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set path hash mode: {result.payload}")
|
||||
set_path_hash_mode(update.path_hash_mode)
|
||||
|
||||
await sync_radio_time_fn(mc)
|
||||
|
||||
# Commands like set_name() write to flash but don't update cached self_info.
|
||||
# send_appstart() forces a fresh SELF_INFO so the response reflects changes.
|
||||
await mc.commands.send_appstart()
|
||||
|
||||
|
||||
async def import_private_key_and_refresh_keystore(
|
||||
mc,
|
||||
key_bytes: bytes,
|
||||
*,
|
||||
export_and_store_private_key_fn: Callable[[Any], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Import a private key and refresh the in-memory keystore immediately."""
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
if result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to import private key: {result.payload}")
|
||||
|
||||
keystore_refreshed = await export_and_store_private_key_fn(mc)
|
||||
if not keystore_refreshed:
|
||||
logger.warning("Keystore refresh failed after import, retrying once")
|
||||
keystore_refreshed = await export_and_store_private_key_fn(mc)
|
||||
|
||||
if not keystore_refreshed:
|
||||
raise KeystoreRefreshError(
|
||||
"Private key imported on radio, but server-side keystore refresh failed. "
|
||||
"Reconnect to apply the new key for DM decryption."
|
||||
)
|
||||
277
app/services/radio_lifecycle.py
Normal file
277
app/services/radio_lifecycle.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
async def run_post_connect_setup(radio_manager) -> None:
|
||||
"""Run shared radio initialization after a transport connection succeeds."""
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
from app.radio_sync import (
|
||||
drain_pending_messages,
|
||||
send_advertisement,
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
|
||||
if not radio_manager.meshcore:
|
||||
return
|
||||
|
||||
if radio_manager._setup_lock is None:
|
||||
radio_manager._setup_lock = asyncio.Lock()
|
||||
|
||||
async def _setup_body() -> None:
|
||||
if not radio_manager.meshcore:
|
||||
return
|
||||
radio_manager._setup_in_progress = True
|
||||
radio_manager._setup_complete = False
|
||||
try:
|
||||
# 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 radio_manager._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
mc = radio_manager.meshcore
|
||||
if not mc:
|
||||
return
|
||||
|
||||
# Register event handlers against the locked, current transport.
|
||||
register_event_handlers(mc)
|
||||
|
||||
await export_and_store_private_key(mc)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
|
||||
# Apply flood scope from settings (best-effort; older firmware
|
||||
# may not support set_flood_scope)
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
scope = normalize_region_scope(app_settings.flood_scope)
|
||||
try:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r", scope or "(disabled)")
|
||||
except Exception as exc:
|
||||
logger.warning("set_flood_scope failed (firmware may not support it): %s", exc)
|
||||
|
||||
# Query path hash mode support (best-effort; older firmware won't report it).
|
||||
# If the library's parsed payload is missing path_hash_mode (e.g. stale
|
||||
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
|
||||
reader = mc._reader
|
||||
_original_handle_rx = reader.handle_rx
|
||||
_captured_frame: list[bytes] = []
|
||||
|
||||
async def _capture_handle_rx(data: bytearray) -> None:
|
||||
from meshcore.packets import PacketType
|
||||
|
||||
if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value:
|
||||
_captured_frame.append(bytes(data))
|
||||
return await _original_handle_rx(data)
|
||||
|
||||
reader.handle_rx = _capture_handle_rx
|
||||
radio_manager.path_hash_mode = 0
|
||||
radio_manager.path_hash_mode_supported = False
|
||||
try:
|
||||
device_query = await mc.commands.send_device_query()
|
||||
if device_query and "path_hash_mode" in device_query.payload:
|
||||
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
elif _captured_frame:
|
||||
# Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode
|
||||
raw = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if fw_ver >= 10 and len(raw) >= 82:
|
||||
radio_manager.path_hash_mode = raw[81]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
logger.warning(
|
||||
"path_hash_mode=%d extracted from raw frame "
|
||||
"(stale .pyc? try: rm %s)",
|
||||
radio_manager.path_hash_mode,
|
||||
getattr(
|
||||
__import__("meshcore.reader", fromlist=["reader"]),
|
||||
"__cached__",
|
||||
"meshcore __pycache__/reader.*.pyc",
|
||||
),
|
||||
)
|
||||
if radio_manager.path_hash_mode_supported:
|
||||
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
|
||||
else:
|
||||
logger.debug("Firmware does not report path_hash_mode")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query path_hash_mode: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# 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(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# 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 mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
# 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()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
radio_manager._setup_in_progress = False
|
||||
|
||||
async with radio_manager._setup_lock:
|
||||
await asyncio.wait_for(_setup_body(), timeout=POST_CONNECT_SETUP_TIMEOUT_SECONDS)
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
|
||||
|
||||
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> bool:
|
||||
"""Finish setup for an already-connected radio and optionally broadcast health."""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
if not radio_manager.connection_desired:
|
||||
if radio_manager.is_connected:
|
||||
await radio_manager.disconnect()
|
||||
return False
|
||||
|
||||
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
break
|
||||
except asyncio.TimeoutError as exc:
|
||||
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
|
||||
attempt,
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
"Post-connect setup timed out after %ds on %d attempts. Initial radio offload "
|
||||
"took too long; something is probably wrong.",
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS,
|
||||
)
|
||||
broadcast_error(
|
||||
"Radio startup appears stuck",
|
||||
"Initial radio offload took too long. Reboot the radio and restart the server.",
|
||||
)
|
||||
raise RuntimeError("Post-connect setup timed out") from exc
|
||||
|
||||
if not radio_manager.connection_desired:
|
||||
if radio_manager.is_connected:
|
||||
await radio_manager.disconnect()
|
||||
return False
|
||||
|
||||
radio_manager._last_connected = True
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
return True
|
||||
|
||||
|
||||
async def reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
*,
|
||||
broadcast_on_success: bool = True,
|
||||
) -> bool:
|
||||
"""Reconnect the transport, then run post-connect setup before reporting healthy."""
|
||||
connected = await radio_manager.reconnect(broadcast_on_success=False)
|
||||
if not connected:
|
||||
return False
|
||||
|
||||
return await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def connection_monitor_loop(radio_manager) -> None:
|
||||
"""Monitor radio health and keep transport/setup state converged."""
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
check_interval_seconds = 5
|
||||
unresponsive_threshold = 3
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
current_connected = radio_manager.is_connected
|
||||
connection_desired = radio_manager.connection_desired
|
||||
|
||||
if radio_manager._last_connected and not current_connected:
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, radio_manager.connection_info)
|
||||
radio_manager._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not connection_desired:
|
||||
if current_connected:
|
||||
logger.info("Radio connection paused by operator; disconnecting transport")
|
||||
await radio_manager.disconnect()
|
||||
consecutive_setup_failures = 0
|
||||
continue
|
||||
|
||||
if not current_connected:
|
||||
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
broadcast_on_success=True,
|
||||
):
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif not radio_manager._last_connected and current_connected:
|
||||
logger.info("Radio connection restored")
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif (
|
||||
current_connected
|
||||
and not radio_manager.is_setup_complete
|
||||
and not radio_manager.is_setup_in_progress
|
||||
):
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
consecutive_setup_failures += 1
|
||||
if consecutive_setup_failures == unresponsive_threshold:
|
||||
logger.error(
|
||||
"Post-connect setup has failed %d times in a row. "
|
||||
"The radio port appears open but the radio is not "
|
||||
"responding to commands. Common causes: another "
|
||||
"process has the serial port open (check for other "
|
||||
"RemoteTerm instances, serial monitors, etc.), the "
|
||||
"firmware is in repeater mode (not client), or the "
|
||||
"radio needs a power cycle. Will keep retrying.",
|
||||
consecutive_setup_failures,
|
||||
)
|
||||
elif consecutive_setup_failures < unresponsive_threshold:
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
93
app/services/radio_runtime.py
Normal file
93
app/services/radio_runtime.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Shared access seam over the global RadioManager instance.
|
||||
|
||||
This module deliberately keeps behavior thin and forwarding-only. The goal is
|
||||
to reduce direct `app.radio.radio_manager` imports across routers and helpers
|
||||
without changing radio lifecycle, lock, or connection semantics.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
import app.radio as radio_module
|
||||
|
||||
|
||||
class RadioRuntime:
|
||||
"""Thin forwarding wrapper around the process-global RadioManager."""
|
||||
|
||||
def __init__(self, manager_or_getter=None):
|
||||
if manager_or_getter is None:
|
||||
self._manager_getter: Callable[[], Any] = lambda: radio_module.radio_manager
|
||||
elif callable(manager_or_getter):
|
||||
self._manager_getter = manager_or_getter
|
||||
else:
|
||||
self._manager_getter = lambda: manager_or_getter
|
||||
|
||||
@property
|
||||
def manager(self) -> Any:
|
||||
return self._manager_getter()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Forward unknown attributes to the current global manager."""
|
||||
return getattr(self.manager, name)
|
||||
|
||||
@staticmethod
|
||||
def _is_local_runtime_attr(name: str) -> bool:
|
||||
return name.startswith("_") or hasattr(RadioRuntime, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if self._is_local_runtime_attr(name):
|
||||
object.__setattr__(self, name, value)
|
||||
return
|
||||
setattr(self.manager, name, value)
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
if self._is_local_runtime_attr(name):
|
||||
object.__delattr__(self, name)
|
||||
return
|
||||
delattr(self.manager, name)
|
||||
|
||||
def require_connected(self):
|
||||
"""Return MeshCore when available, mirroring existing HTTP semantics."""
|
||||
if self.is_setup_in_progress:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
if not self.is_connected:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
mc = self.meshcore
|
||||
if mc is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
return mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def radio_operation(self, name: str, **kwargs):
|
||||
async with self.manager.radio_operation(name, **kwargs) as mc:
|
||||
yield mc
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
await self.manager.start_connection_monitor()
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
await self.manager.stop_connection_monitor()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
await self.manager.disconnect()
|
||||
|
||||
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
return await prepare_connected_radio(
|
||||
self.manager, broadcast_on_success=broadcast_on_success
|
||||
)
|
||||
|
||||
async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
from app.services.radio_lifecycle import reconnect_and_prepare_radio
|
||||
|
||||
return await reconnect_and_prepare_radio(
|
||||
self.manager,
|
||||
broadcast_on_success=broadcast_on_success,
|
||||
)
|
||||
|
||||
|
||||
radio_runtime = RadioRuntime()
|
||||
@@ -1,12 +1,13 @@
|
||||
"""WebSocket manager for real-time updates."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.events import dump_ws_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout for individual WebSocket send operations (seconds)
|
||||
@@ -45,7 +46,7 @@ class WebSocketManager:
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
message = dump_ws_event(event_type, data)
|
||||
|
||||
# Copy connection list under lock to avoid holding lock during I/O
|
||||
async with self._lock:
|
||||
@@ -81,7 +82,7 @@ class WebSocketManager:
|
||||
|
||||
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
|
||||
"""Send an event to a specific client."""
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
message = dump_ws_event(event_type, data)
|
||||
try:
|
||||
await websocket.send_text(message)
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
# Set your serial device for passthrough here! #
|
||||
################################################
|
||||
devices:
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0
|
||||
- /dev/ttyACM0:/dev/ttyUSB0
|
||||
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
install-links=true
|
||||
@@ -12,17 +12,29 @@ Keep it aligned with `frontend/src` source code.
|
||||
- Tailwind utility classes + local CSS (`index.css`, `styles.css`)
|
||||
- Sonner (toasts)
|
||||
- Leaflet / react-leaflet (map)
|
||||
- `@michaelhart/meshcore-decoder` installed via npm alias to `meshcore-decoder-multibyte-patch`
|
||||
- `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker)
|
||||
- Multibyte-aware decoder build published as `meshcore-decoder-multibyte-patch`
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer fewer, stronger modules over many thin wrappers.
|
||||
- Split code only when the new hook/component owns a real invariant or workflow.
|
||||
- Keep one reasoning unit readable in one place, even if that file is moderately large.
|
||||
- Avoid dedicated files whose main job is pass-through, prop bundling, or renaming.
|
||||
- For this repo, "locally dense but semantically obvious" is better than indirection-heavy "clean architecture".
|
||||
- When refactoring, preserve behavior first and add tests around the seam being moved.
|
||||
|
||||
## Frontend Map
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── main.tsx # React entry point (StrictMode, root render)
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── App.tsx # Data/orchestration entry that wires hooks into AppShell
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── wsEvents.ts # Typed WS event parsing / discriminated union
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
@@ -32,13 +44,27 @@ frontend/src/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
|
||||
│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions
|
||||
│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state
|
||||
│ ├── useConversationMessages.ts # Conversation timeline loading, cache restore, jump-target loading, pagination, dedup, pending ACK buffering
|
||||
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
|
||||
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
|
||||
│ ├── 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
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
||||
│ ├── visualizer/
|
||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||
│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay
|
||||
│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay
|
||||
│ │ └── shared.ts # Graph node/link types and shared rendering helpers
|
||||
│ └── ...
|
||||
├── utils/
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
@@ -48,6 +74,7 @@ frontend/src/
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
@@ -79,6 +106,7 @@ frontend/src/
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
@@ -110,9 +138,13 @@ frontend/src/
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── contactInfoPane.test.tsx
|
||||
├── integration.test.ts
|
||||
├── mapView.test.tsx
|
||||
├── messageCache.test.ts
|
||||
├── messageList.test.tsx
|
||||
├── messageParser.test.ts
|
||||
├── rawPacketList.test.tsx
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── radioPresets.test.ts
|
||||
@@ -126,37 +158,68 @@ frontend/src/
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── statusBar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
├── channelInfoKeyVisibility.test.tsx
|
||||
├── chatHeaderKeyVisibility.test.tsx
|
||||
├── searchView.test.tsx
|
||||
├── useConversationActions.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
├── useAppShell.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
└── useWebSocket.lifecycle.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### State ownership
|
||||
|
||||
`App.tsx` orchestrates high-level state and delegates to hooks:
|
||||
`App.tsx` is now a thin composition entrypoint over the hook layer. `AppShell.tsx` owns shell layout/composition:
|
||||
- local label banner
|
||||
- status bar
|
||||
- desktop/mobile sidebar container
|
||||
- search/settings surface switching
|
||||
- global cracker mount/focus behavior
|
||||
- new-message modal and info panes
|
||||
|
||||
High-level state is delegated to hooks:
|
||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||
- `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
|
||||
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
|
||||
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
|
||||
- `useConversationMessages`: conversation switch loading, cache restore, jump-target loading, pagination, dedup/update helpers, and pending ACK buffering
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||
|
||||
`App.tsx` intentionally still does the final `AppShell` prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic.
|
||||
|
||||
`ConversationPane.tsx` owns the main active-conversation surface branching:
|
||||
- empty state
|
||||
- map view
|
||||
- visualizer
|
||||
- raw packet feed
|
||||
- repeater dashboard
|
||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||
|
||||
### Initial load + realtime
|
||||
|
||||
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
|
||||
- WebSocket: realtime deltas/events.
|
||||
- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
|
||||
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
|
||||
|
||||
### New Message modal
|
||||
@@ -173,17 +236,26 @@ frontend/src/
|
||||
### Visualizer behavior
|
||||
|
||||
- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs).
|
||||
- `PacketVisualizer3D.tsx` is now a thin composition shell over visualizer-specific hooks/components in `components/visualizer/`.
|
||||
- `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 decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`.
|
||||
- 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).
|
||||
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
@@ -267,7 +339,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
|
||||
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
## Channel Info Pane
|
||||
|
||||
@@ -279,11 +351,11 @@ Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right d
|
||||
- First message date
|
||||
- Top senders in last 24h (name + count)
|
||||
|
||||
State: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot.
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot.
|
||||
|
||||
## Repeater Dashboard
|
||||
|
||||
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
|
||||
@@ -299,9 +371,10 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio
|
||||
|
||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||
|
||||
- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation.
|
||||
- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
|
||||
- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page.
|
||||
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
|
||||
- **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
|
||||
- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
|
||||
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
|
||||
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
|
||||
|
||||
|
||||
21
frontend/lib/meshcore-decoder/LICENSE.md
Normal file
21
frontend/lib/meshcore-decoder/LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Michael Hart <michaelhart@michaelhart.me> (https://github.com/michaelhart)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
453
frontend/lib/meshcore-decoder/README.md
Normal file
453
frontend/lib/meshcore-decoder/README.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# MeshCore Decoder
|
||||
|
||||
A TypeScript library for decoding MeshCore mesh networking packets with full cryptographic support. Uses WebAssembly (WASM) for Ed25519 key derivation through the [orlp/ed25519 library](https://github.com/orlp/ed25519).
|
||||
|
||||
This powers the [MeshCore Packet Analyzer](https://analyzer.letsme.sh/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Packet Decoding**: Decode MeshCore packets
|
||||
- **Built-in Decryption**: Decrypt GroupText, TextMessage, and other encrypted payloads
|
||||
- **Developer Friendly**: TypeScript-first with full type safety and portability of JavaScript
|
||||
|
||||
## Installation
|
||||
|
||||
### Install to a single project
|
||||
|
||||
```bash
|
||||
npm install @michaelhart/meshcore-decoder
|
||||
```
|
||||
|
||||
### Install CLI (install globally)
|
||||
|
||||
```bash
|
||||
npm install -g @michaelhart/meshcore-decoder
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MeshCoreDecoder,
|
||||
PayloadType,
|
||||
Utils,
|
||||
DecodedPacket,
|
||||
AdvertPayload
|
||||
} from '@michaelhart/meshcore-decoder';
|
||||
|
||||
// Decode a MeshCore packet
|
||||
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';
|
||||
|
||||
const packet: DecodedPacket = MeshCoreDecoder.decode(hexData);
|
||||
|
||||
console.log(`Route Type: ${Utils.getRouteTypeName(packet.routeType)}`);
|
||||
console.log(`Payload Type: ${Utils.getPayloadTypeName(packet.payloadType)}`);
|
||||
console.log(`Message Hash: ${packet.messageHash}`);
|
||||
|
||||
if (packet.payloadType === PayloadType.Advert && packet.payload.decoded) {
|
||||
const advert: AdvertPayload = packet.payload.decoded as AdvertPayload;
|
||||
console.log(`Device Name: ${advert.appData.name}`);
|
||||
console.log(`Device Role: ${Utils.getDeviceRoleName(advert.appData.deviceRole)}`);
|
||||
if (advert.appData.location) {
|
||||
console.log(`Location: ${advert.appData.location.latitude}, ${advert.appData.location.longitude}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Full Packet Structure Example
|
||||
|
||||
Here's what a complete decoded packet looks like:
|
||||
|
||||
```typescript
|
||||
import { MeshCoreDecoder, DecodedPacket } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';
|
||||
|
||||
const packet: DecodedPacket = MeshCoreDecoder.decode(hexData);
|
||||
|
||||
console.log(JSON.stringify(packet, null, 2));
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"messageHash": "F9C060FE",
|
||||
"routeType": 1,
|
||||
"payloadType": 4,
|
||||
"payloadVersion": 0,
|
||||
"pathLength": 0,
|
||||
"path": null,
|
||||
"payload": {
|
||||
"raw": "7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172",
|
||||
"decoded": {
|
||||
"type": 4,
|
||||
"version": 0,
|
||||
"isValid": true,
|
||||
"publicKey": "7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400",
|
||||
"timestamp": 1758455660,
|
||||
"signature": "2E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E609",
|
||||
"appData": {
|
||||
"flags": 146,
|
||||
"deviceRole": 2,
|
||||
"hasLocation": true,
|
||||
"hasName": true,
|
||||
"location": {
|
||||
"latitude": 47.543968,
|
||||
"longitude": -122.108616
|
||||
},
|
||||
"name": "WW7STR/PugetMesh Cougar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"totalBytes": 134,
|
||||
"isValid": true
|
||||
}
|
||||
```
|
||||
|
||||
## Packet Support
|
||||
|
||||
| Value | Name | Description | Decoding | Decryption | Segment Analysis |
|
||||
|-------|------|-------------|----------|------------|------------------|
|
||||
| `0x00` | Request | Request (destination/source hashes + MAC) | ✅ | 🚧 | ✅ |
|
||||
| `0x01` | Response | Response to REQ or ANON_REQ | ✅ | 🚧 | ✅ |
|
||||
| `0x02` | Plain text message | Plain text message | ✅ | 🚧 | ✅ |
|
||||
| `0x03` | Acknowledgment | Acknowledgment | ✅ | N/A | ✅ |
|
||||
| `0x04` | Node advertisement | Node advertisement | ✅ | N/A | ✅ |
|
||||
| `0x05` | Group text message | Group text message | ✅ | ✅ | ✅ |
|
||||
| `0x06` | Group datagram | Group datagram | 🚧 | 🚧 | 🚧 |
|
||||
| `0x07` | Anonymous request | Anonymous request | ✅ | 🚧 | ✅ |
|
||||
| `0x08` | Returned path | Returned path | ✅ | N/A | ✅ |
|
||||
| `0x09` | Trace | Trace a path, collecting SNI for each hop | ✅ | N/A | ✅ |
|
||||
| `0x0A` | Multi-part packet | Packet is part of a sequence of packets | 🚧 | 🚧 | 🚧 |
|
||||
| `0x0F` | Custom packet | Custom packet (raw bytes, custom encryption) | 🚧 | 🚧 | 🚧 |
|
||||
|
||||
**Legend:**
|
||||
- ✅ Fully implemented
|
||||
- 🚧 Planned/In development
|
||||
- `-` Not applicable
|
||||
|
||||
For some packet types not yet supported here, they may not exist in MeshCore yet or I have yet to observe these packet types on the mesh.
|
||||
|
||||
## Decryption Support
|
||||
|
||||
Simply provide your channel secret keys and the library handles everything else:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MeshCoreDecoder,
|
||||
PayloadType,
|
||||
CryptoKeyStore,
|
||||
DecodedPacket,
|
||||
GroupTextPayload
|
||||
} from '@michaelhart/meshcore-decoder';
|
||||
|
||||
// Create a key store with channel secret keys
|
||||
const keyStore: CryptoKeyStore = MeshCoreDecoder.createKeyStore({
|
||||
channelSecrets: [
|
||||
'8b3387e9c5cdea6ac9e5edbaa115cd72', // Public channel (channel hash 11)
|
||||
'ff2b7d74e8d20f71505bda9ea8d59a1c', // A different channel's secret
|
||||
]
|
||||
});
|
||||
|
||||
const groupTextHexData: string = '...'; // Your encrypted GroupText packet hex
|
||||
|
||||
// Decode encrypted GroupText message
|
||||
const encryptedPacket: DecodedPacket = MeshCoreDecoder.decode(groupTextHexData, { keyStore });
|
||||
|
||||
if (encryptedPacket.payloadType === PayloadType.GroupText && encryptedPacket.payload.decoded) {
|
||||
const groupText: GroupTextPayload = encryptedPacket.payload.decoded as GroupTextPayload;
|
||||
|
||||
if (groupText.decrypted) {
|
||||
console.log(`Sender: ${groupText.decrypted.sender}`);
|
||||
console.log(`Message: ${groupText.decrypted.message}`);
|
||||
console.log(`Timestamp: ${new Date(groupText.decrypted.timestamp * 1000).toISOString()}`);
|
||||
} else {
|
||||
console.log('Message encrypted (no key available)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The library automatically:
|
||||
- Calculates channel hashes from your secret keys using SHA256
|
||||
- Handles hash collisions (multiple keys with same first byte) by trying all matching keys
|
||||
- Verifies message authenticity using HMAC-SHA256
|
||||
- Decrypts using AES-128 ECB
|
||||
|
||||
## Packet Structure Analysis
|
||||
|
||||
For detailed packet analysis and debugging, use `analyzeStructure()` to get byte-level breakdowns:
|
||||
|
||||
```typescript
|
||||
import { MeshCoreDecoder, PacketStructure } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
console.log('=== Packet Breakdown ===');
|
||||
const hexData: string = '11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172';
|
||||
|
||||
console.log('Packet length:', hexData.length);
|
||||
console.log('Expected bytes:', hexData.length / 2);
|
||||
|
||||
const structure: PacketStructure = MeshCoreDecoder.analyzeStructure(hexData);
|
||||
console.log('\nMain segments:');
|
||||
structure.segments.forEach((seg, i) => {
|
||||
console.log(`${i+1}. ${seg.name} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
});
|
||||
|
||||
console.log('\nPayload segments:');
|
||||
structure.payload.segments.forEach((seg, i) => {
|
||||
console.log(`${i+1}. ${seg.name} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
console.log(` Description: ${seg.description}`);
|
||||
});
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
=== Packet Breakdown ===
|
||||
Packet length: 268
|
||||
Expected bytes: 134
|
||||
|
||||
Main segments:
|
||||
1. Header (bytes 0-0): 0x11
|
||||
2. Path Length (bytes 1-1): 0x00
|
||||
3. Payload (bytes 2-133): 7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
Payload segments:
|
||||
1. Public Key (bytes 0-31): 7E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C9400
|
||||
Description: Ed25519 public key
|
||||
2. Timestamp (bytes 32-35): 6CE7CF68
|
||||
Description: 1758455660 (2025-09-21T11:54:20Z)
|
||||
3. Signature (bytes 36-99): 2E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E609
|
||||
Description: Ed25519 signature
|
||||
4. App Flags (bytes 100-100): 92
|
||||
Description: Binary: 10010010 | Bits 0-3 (Role): Room server | Bit 4 (Location): Yes | Bit 5 (Feature1): No | Bit 6 (Feature2): No | Bit 7 (Name): Yes
|
||||
5. Latitude (bytes 101-104): A076D502
|
||||
Description: 47.543968° (47.543968)
|
||||
6. Longitude (bytes 105-108): 38C5B8F8
|
||||
Description: -122.108616° (-122.108616)
|
||||
7. Node Name (bytes 109-131): 5757375354522F50756765744D65736820436F75676172
|
||||
Description: Node name: "WW7STR/PugetMesh Cougar"
|
||||
```
|
||||
|
||||
The `analyzeStructure()` method provides:
|
||||
- **Header breakdown** with bit-level field analysis
|
||||
- **Byte-accurate segments** with start/end positions
|
||||
- **Payload field parsing** for all supported packet types
|
||||
- **Human-readable descriptions** for each field
|
||||
|
||||
## Ed25519 Key Derivation
|
||||
|
||||
The library includes MeshCore-compatible Ed25519 key derivation using the exact orlp/ed25519 algorithm via WebAssembly:
|
||||
|
||||
```typescript
|
||||
import { Utils } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
// Derive public key from MeshCore private key (64-byte format)
|
||||
const privateKey = '18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5';
|
||||
|
||||
const publicKey = await Utils.derivePublicKey(privateKey);
|
||||
console.log('Derived Public Key:', publicKey);
|
||||
// Output: 4852B69364572B52EFA1B6BB3E6D0ABED4F389A1CBFBB60A9BBA2CCE649CAF0E
|
||||
|
||||
// Validate a key pair
|
||||
const isValid = await Utils.validateKeyPair(privateKey, publicKey);
|
||||
console.log('Key pair valid:', isValid); // true
|
||||
```
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
For quick analysis from the terminal, install globally and use the CLI:
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
npm install -g @michaelhart/meshcore-decoder
|
||||
|
||||
# Analyze a packet
|
||||
meshcore-decoder 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
# With decryption (provide channel secrets)
|
||||
meshcore-decoder 150011C3C1354D619BAE9590E4D177DB7EEAF982F5BDCF78005D75157D9535FA90178F785D --key 8b3387e9c5cdea6ac9e5edbaa115cd72
|
||||
|
||||
# Show detailed structure analysis
|
||||
meshcore-decoder --structure 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
# JSON output
|
||||
meshcore-decoder --json 11007E7662676F7F0850A8A355BAAFBFC1EB7B4174C340442D7D7161C9474A2C94006CE7CF682E58408DD8FCC51906ECA98EBF94A037886BDADE7ECD09FD92B839491DF3809C9454F5286D1D3370AC31A34593D569E9A042A3B41FD331DFFB7E18599CE1E60992A076D50238C5B8F85757375354522F50756765744D65736820436F75676172
|
||||
|
||||
# Derive public key from MeshCore private key
|
||||
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5
|
||||
|
||||
# Validate key pair
|
||||
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5 --validate 4852b69364572b52efa1b6bb3e6d0abed4f389a1cbfbb60a9bba2cce649caf0e
|
||||
|
||||
# Key derivation with JSON output
|
||||
meshcore-decoder derive-key 18469d6140447f77de13cd8d761e605431f52269fbff43b0925752ed9e6745435dc6a86d2568af8b70d3365db3f88234760c8ecc645ce469829bc45b65f1d5d5 --json
|
||||
```
|
||||
|
||||
|
||||
## Using with Angular
|
||||
|
||||
The library works in Angular (and other browser-based) applications but requires additional configuration for WASM support and browser compatibility.
|
||||
|
||||
### 1. Configure Assets in `angular.json`
|
||||
|
||||
Add the WASM files to your Angular assets configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": {
|
||||
"your-app": {
|
||||
"architect": {
|
||||
"build": {
|
||||
"options": {
|
||||
"assets": [
|
||||
// ... your existing assets ...
|
||||
{
|
||||
"glob": "orlp-ed25519.*",
|
||||
"input": "./node_modules/@michaelhart/meshcore-decoder/lib",
|
||||
"output": "assets/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create a WASM Service
|
||||
|
||||
Create `src/app/services/meshcore-wasm.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MeshCoreWasmService {
|
||||
private wasm: any = null;
|
||||
public ready = new BehaviorSubject<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
this.loadWasm();
|
||||
}
|
||||
|
||||
private async loadWasm() {
|
||||
try {
|
||||
const jsResponse = await fetch('/assets/orlp-ed25519.js');
|
||||
const jsText = await jsResponse.text();
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.textContent = jsText;
|
||||
document.head.appendChild(script);
|
||||
|
||||
this.wasm = await (window as any).OrlpEd25519({
|
||||
locateFile: (path: string) => path === 'orlp-ed25519.wasm' ? '/assets/orlp-ed25519.wasm' : path
|
||||
});
|
||||
|
||||
this.ready.next(true);
|
||||
} catch (error) {
|
||||
console.error('WASM load failed:', error);
|
||||
this.ready.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
derivePublicKey(privateKeyHex: string): string | null {
|
||||
if (!this.wasm) return null;
|
||||
|
||||
const privateKeyBytes = this.hexToBytes(privateKeyHex);
|
||||
const privateKeyPtr = 1024;
|
||||
const publicKeyPtr = 1088;
|
||||
|
||||
this.wasm.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
|
||||
const result = this.wasm.ccall('orlp_derive_public_key', 'number', ['number', 'number'], [publicKeyPtr, privateKeyPtr]);
|
||||
|
||||
if (result === 0) {
|
||||
const publicKeyBytes = this.wasm.HEAPU8.subarray(publicKeyPtr, publicKeyPtr + 32);
|
||||
return this.bytesToHex(publicKeyBytes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Basic Usage
|
||||
|
||||
```typescript
|
||||
import { MeshCorePacketDecoder } from '@michaelhart/meshcore-decoder';
|
||||
import { MeshCoreWasmService } from './services/meshcore-wasm';
|
||||
|
||||
// Basic packet decoding (works immediately)
|
||||
const packet = MeshCorePacketDecoder.decode(hexData);
|
||||
|
||||
// Key derivation (wait for WASM)
|
||||
wasmService.ready.subscribe(isReady => {
|
||||
if (isReady) {
|
||||
const publicKey = wasmService.derivePublicKey(privateKeyHex);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Angular/Browser: Important Notes
|
||||
|
||||
- **WASM Loading**: The library uses WebAssembly for Ed25519 key derivation. This requires proper asset configuration and a service to handle async WASM loading.
|
||||
- **Browser Compatibility**: The library automatically detects the environment and uses Web Crypto API in browsers, Node.js crypto in Node.js.
|
||||
- **Async Operations**: Key derivation is async due to WASM loading. Always wait for the `WasmService.ready` observable.
|
||||
- **Error Handling**: WASM operations may fail in some environments. Always wrap in try-catch blocks.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Development with ts-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Michael Hart <michaelhart@michaelhart.me> (https://github.com/michaelhart)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
3
frontend/lib/meshcore-decoder/dist/cli.d.ts
vendored
Normal file
3
frontend/lib/meshcore-decoder/dist/cli.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
//# sourceMappingURL=cli.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/cli.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/cli.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
||||
409
frontend/lib/meshcore-decoder/dist/cli.js
vendored
Normal file
409
frontend/lib/meshcore-decoder/dist/cli.js
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const packet_decoder_1 = require("./decoder/packet-decoder");
|
||||
const enums_1 = require("./types/enums");
|
||||
const enum_names_1 = require("./utils/enum-names");
|
||||
const index_1 = require("./index");
|
||||
const commander_1 = require("commander");
|
||||
const chalk_1 = __importDefault(require("chalk"));
|
||||
const packageJson = __importStar(require("../package.json"));
|
||||
commander_1.program
|
||||
.name('meshcore-decoder')
|
||||
.description('CLI tool for decoding MeshCore packets')
|
||||
.version(packageJson.version);
|
||||
// Default decode command
|
||||
commander_1.program
|
||||
.command('decode', { isDefault: true })
|
||||
.description('Decode a MeshCore packet')
|
||||
.argument('<hex>', 'Hex string of the packet to decode')
|
||||
.option('-k, --key <keys...>', 'Channel secret keys for decryption (hex)')
|
||||
.option('-j, --json', 'Output as JSON instead of formatted text')
|
||||
.option('-s, --structure', 'Show detailed packet structure analysis')
|
||||
.action(async (hex, options) => {
|
||||
try {
|
||||
// Clean up hex input
|
||||
const cleanHex = hex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
// Create key store if keys provided
|
||||
let keyStore;
|
||||
if (options.key && options.key.length > 0) {
|
||||
keyStore = packet_decoder_1.MeshCorePacketDecoder.createKeyStore({
|
||||
channelSecrets: options.key
|
||||
});
|
||||
}
|
||||
// Decode packet with signature verification
|
||||
const packet = await packet_decoder_1.MeshCorePacketDecoder.decodeWithVerification(cleanHex, { keyStore });
|
||||
if (options.json) {
|
||||
// JSON output
|
||||
if (options.structure) {
|
||||
const structure = await packet_decoder_1.MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { keyStore });
|
||||
console.log(JSON.stringify({ packet, structure }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(JSON.stringify(packet, null, 2));
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Formatted output
|
||||
console.log(chalk_1.default.cyan('=== MeshCore Packet Analysis ===\n'));
|
||||
if (!packet.isValid) {
|
||||
console.log(chalk_1.default.red('❌ Invalid Packet'));
|
||||
if (packet.errors) {
|
||||
packet.errors.forEach(error => console.log(chalk_1.default.red(` ${error}`)));
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.green('✅ Valid Packet'));
|
||||
}
|
||||
console.log(`${chalk_1.default.bold('Message Hash:')} ${packet.messageHash}`);
|
||||
console.log(`${chalk_1.default.bold('Route Type:')} ${(0, enum_names_1.getRouteTypeName)(packet.routeType)}`);
|
||||
console.log(`${chalk_1.default.bold('Payload Type:')} ${(0, enum_names_1.getPayloadTypeName)(packet.payloadType)}`);
|
||||
console.log(`${chalk_1.default.bold('Total Bytes:')} ${packet.totalBytes}`);
|
||||
if (packet.path && packet.path.length > 0) {
|
||||
console.log(`${chalk_1.default.bold('Path:')} ${packet.path.join(' → ')}`);
|
||||
}
|
||||
// Show payload details (even for invalid packets)
|
||||
if (packet.payload.decoded) {
|
||||
console.log(chalk_1.default.cyan('\n=== Payload Details ==='));
|
||||
showPayloadDetails(packet.payload.decoded);
|
||||
}
|
||||
// Exit with error code if packet is invalid
|
||||
if (!packet.isValid) {
|
||||
process.exit(1);
|
||||
}
|
||||
// Show structure if requested
|
||||
if (options.structure) {
|
||||
const structure = await packet_decoder_1.MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { keyStore });
|
||||
console.log(chalk_1.default.cyan('\n=== Packet Structure ==='));
|
||||
console.log(chalk_1.default.yellow('\nMain Segments:'));
|
||||
structure.segments.forEach((seg, i) => {
|
||||
console.log(`${i + 1}. ${chalk_1.default.bold(seg.name)} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
if (seg.description) {
|
||||
console.log(` ${chalk_1.default.dim(seg.description)}`);
|
||||
}
|
||||
});
|
||||
if (structure.payload.segments.length > 0) {
|
||||
console.log(chalk_1.default.yellow('\nPayload Segments:'));
|
||||
structure.payload.segments.forEach((seg, i) => {
|
||||
console.log(`${i + 1}. ${chalk_1.default.bold(seg.name)} (bytes ${seg.startByte}-${seg.endByte}): ${seg.value}`);
|
||||
console.log(` ${chalk_1.default.dim(seg.description)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(chalk_1.default.red('Error:'), error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
function showPayloadDetails(payload) {
|
||||
switch (payload.type) {
|
||||
case enums_1.PayloadType.Advert:
|
||||
const advert = payload;
|
||||
console.log(`${chalk_1.default.bold('Device Role:')} ${(0, enum_names_1.getDeviceRoleName)(advert.appData.deviceRole)}`);
|
||||
if (advert.appData.name) {
|
||||
console.log(`${chalk_1.default.bold('Device Name:')} ${advert.appData.name}`);
|
||||
}
|
||||
if (advert.appData.location) {
|
||||
console.log(`${chalk_1.default.bold('Location:')} ${advert.appData.location.latitude}, ${advert.appData.location.longitude}`);
|
||||
}
|
||||
console.log(`${chalk_1.default.bold('Timestamp:')} ${new Date(advert.timestamp * 1000).toISOString()}`);
|
||||
// Show signature verification status
|
||||
if (advert.signatureValid !== undefined) {
|
||||
if (advert.signatureValid) {
|
||||
console.log(`${chalk_1.default.bold('Signature:')} ${chalk_1.default.green('✅ Valid Ed25519 signature')}`);
|
||||
}
|
||||
else {
|
||||
console.log(`${chalk_1.default.bold('Signature:')} ${chalk_1.default.red('❌ Invalid Ed25519 signature')}`);
|
||||
if (advert.signatureError) {
|
||||
console.log(`${chalk_1.default.bold('Error:')} ${chalk_1.default.red(advert.signatureError)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(`${chalk_1.default.bold('Signature:')} ${chalk_1.default.yellow('⚠️ Not verified (use async verification)')}`);
|
||||
}
|
||||
break;
|
||||
case enums_1.PayloadType.GroupText:
|
||||
const groupText = payload;
|
||||
console.log(`${chalk_1.default.bold('Channel Hash:')} ${groupText.channelHash}`);
|
||||
if (groupText.decrypted) {
|
||||
console.log(chalk_1.default.green('🔓 Decrypted Message:'));
|
||||
if (groupText.decrypted.sender) {
|
||||
console.log(`${chalk_1.default.bold('Sender:')} ${groupText.decrypted.sender}`);
|
||||
}
|
||||
console.log(`${chalk_1.default.bold('Message:')} ${groupText.decrypted.message}`);
|
||||
console.log(`${chalk_1.default.bold('Timestamp:')} ${new Date(groupText.decrypted.timestamp * 1000).toISOString()}`);
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.yellow('🔒 Encrypted (no key available)'));
|
||||
console.log(`${chalk_1.default.bold('Ciphertext:')} ${groupText.ciphertext.substring(0, 32)}...`);
|
||||
}
|
||||
break;
|
||||
case enums_1.PayloadType.Trace:
|
||||
const trace = payload;
|
||||
console.log(`${chalk_1.default.bold('Trace Tag:')} ${trace.traceTag}`);
|
||||
console.log(`${chalk_1.default.bold('Auth Code:')} ${trace.authCode}`);
|
||||
if (trace.snrValues && trace.snrValues.length > 0) {
|
||||
console.log(`${chalk_1.default.bold('SNR Values:')} ${trace.snrValues.map(snr => `${snr.toFixed(1)}dB`).join(', ')}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log(`${chalk_1.default.bold('Type:')} ${(0, enum_names_1.getPayloadTypeName)(payload.type)}`);
|
||||
console.log(`${chalk_1.default.bold('Valid:')} ${payload.isValid ? '✅' : '❌'}`);
|
||||
}
|
||||
}
|
||||
// Add key derivation command
|
||||
commander_1.program
|
||||
.command('derive-key')
|
||||
.description('Derive Ed25519 public key from MeshCore private key')
|
||||
.argument('<private-key>', '64-byte private key in hex format')
|
||||
.option('-v, --validate <public-key>', 'Validate against expected public key')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (privateKeyHex, options) => {
|
||||
try {
|
||||
// Clean up hex input
|
||||
const cleanPrivateKey = privateKeyHex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
if (cleanPrivateKey.length !== 128) {
|
||||
console.error(chalk_1.default.red('❌ Error: Private key must be exactly 64 bytes (128 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
if (options.json) {
|
||||
// JSON output
|
||||
const result = {
|
||||
privateKey: cleanPrivateKey,
|
||||
derivedPublicKey: await index_1.Utils.derivePublicKey(cleanPrivateKey)
|
||||
};
|
||||
if (options.validate) {
|
||||
const cleanExpectedKey = options.validate.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
result.expectedPublicKey = cleanExpectedKey;
|
||||
result.isValid = await index_1.Utils.validateKeyPair(cleanPrivateKey, cleanExpectedKey);
|
||||
result.match = result.derivedPublicKey.toLowerCase() === cleanExpectedKey.toLowerCase();
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
else {
|
||||
// Formatted output
|
||||
console.log(chalk_1.default.cyan('=== MeshCore Ed25519 Key Derivation ===\n'));
|
||||
console.log(chalk_1.default.bold('Private Key (64 bytes):'));
|
||||
console.log(chalk_1.default.gray(cleanPrivateKey));
|
||||
console.log();
|
||||
console.log(chalk_1.default.bold('Derived Public Key (32 bytes):'));
|
||||
const derivedKey = await index_1.Utils.derivePublicKey(cleanPrivateKey);
|
||||
console.log(chalk_1.default.green(derivedKey));
|
||||
console.log();
|
||||
if (options.validate) {
|
||||
const cleanExpectedKey = options.validate.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
console.log(chalk_1.default.bold('Expected Public Key:'));
|
||||
console.log(chalk_1.default.gray(cleanExpectedKey));
|
||||
console.log();
|
||||
const match = derivedKey.toLowerCase() === cleanExpectedKey.toLowerCase();
|
||||
console.log(chalk_1.default.bold('Validation:'));
|
||||
console.log(match ? chalk_1.default.green('Keys match') : chalk_1.default.red('Keys do not match'));
|
||||
if (!match) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(chalk_1.default.green('Key derivation completed successfully'));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ error: errorMessage }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
// Add auth-token command
|
||||
commander_1.program
|
||||
.command('auth-token')
|
||||
.description('Generate JWT authentication token signed with Ed25519 private key')
|
||||
.argument('<public-key>', '32-byte public key in hex format')
|
||||
.argument('<private-key>', '64-byte private key in hex format')
|
||||
.option('-e, --exp <seconds>', 'Token expiration in seconds from now (default: 86400 = 24 hours)', '86400')
|
||||
.option('-c, --claims <json>', 'Additional claims as JSON object (e.g., \'{"aud":"mqtt.example.com","sub":"device-123"}\')')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (publicKeyHex, privateKeyHex, options) => {
|
||||
try {
|
||||
const { createAuthToken } = await Promise.resolve().then(() => __importStar(require('./utils/auth-token')));
|
||||
// Clean up hex inputs
|
||||
const cleanPublicKey = publicKeyHex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
const cleanPrivateKey = privateKeyHex.replace(/\s+/g, '').replace(/^0x/i, '');
|
||||
if (cleanPublicKey.length !== 64) {
|
||||
console.error(chalk_1.default.red('❌ Error: Public key must be exactly 32 bytes (64 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
if (cleanPrivateKey.length !== 128) {
|
||||
console.error(chalk_1.default.red('❌ Error: Private key must be exactly 64 bytes (128 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
const expSeconds = parseInt(options.exp);
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + expSeconds;
|
||||
const payload = {
|
||||
publicKey: cleanPublicKey.toUpperCase(),
|
||||
iat,
|
||||
exp
|
||||
};
|
||||
// Parse and merge additional claims if provided
|
||||
if (options.claims) {
|
||||
try {
|
||||
const additionalClaims = JSON.parse(options.claims);
|
||||
Object.assign(payload, additionalClaims);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(chalk_1.default.red('❌ Error: Invalid JSON in --claims option'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const token = await createAuthToken(payload, cleanPrivateKey, cleanPublicKey.toUpperCase());
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
token,
|
||||
payload
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(token);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ error: errorMessage }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
// Add verify-token command
|
||||
commander_1.program
|
||||
.command('verify-token')
|
||||
.description('Verify JWT authentication token')
|
||||
.argument('<token>', 'JWT token to verify')
|
||||
.option('-p, --public-key <key>', 'Expected public key in hex format (optional)')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (token, options) => {
|
||||
try {
|
||||
const { verifyAuthToken } = await Promise.resolve().then(() => __importStar(require('./utils/auth-token')));
|
||||
const cleanToken = token.trim();
|
||||
let expectedPublicKey;
|
||||
if (options.publicKey) {
|
||||
const cleanKey = options.publicKey.replace(/\s+/g, '').replace(/^0x/i, '').toUpperCase();
|
||||
if (cleanKey.length !== 64) {
|
||||
console.error(chalk_1.default.red('❌ Error: Public key must be exactly 32 bytes (64 hex characters)'));
|
||||
process.exit(1);
|
||||
}
|
||||
expectedPublicKey = cleanKey;
|
||||
}
|
||||
const payload = await verifyAuthToken(cleanToken, expectedPublicKey);
|
||||
if (payload) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = payload.exp && now > payload.exp;
|
||||
const timeToExpiry = payload.exp ? payload.exp - now : null;
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
valid: true,
|
||||
expired: isExpired,
|
||||
payload,
|
||||
timeToExpiry
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.green('✅ Token is valid'));
|
||||
console.log(chalk_1.default.cyan('\nPayload:'));
|
||||
console.log(` Public Key: ${payload.publicKey}`);
|
||||
console.log(` Issued At: ${new Date(payload.iat * 1000).toISOString()} (${payload.iat})`);
|
||||
if (payload.exp) {
|
||||
console.log(` Expires At: ${new Date(payload.exp * 1000).toISOString()} (${payload.exp})`);
|
||||
if (isExpired) {
|
||||
console.log(chalk_1.default.red(` Status: EXPIRED`));
|
||||
}
|
||||
else {
|
||||
console.log(chalk_1.default.green(` Status: Valid for ${timeToExpiry} more seconds`));
|
||||
}
|
||||
}
|
||||
// Show any additional claims
|
||||
const standardClaims = ['publicKey', 'iat', 'exp'];
|
||||
const customClaims = Object.keys(payload).filter(k => !standardClaims.includes(k));
|
||||
if (customClaims.length > 0) {
|
||||
console.log(chalk_1.default.cyan('\nCustom Claims:'));
|
||||
customClaims.forEach(key => {
|
||||
console.log(` ${key}: ${JSON.stringify(payload[key])}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
valid: false,
|
||||
error: 'Token verification failed'
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red('❌ Token verification failed'));
|
||||
console.error(chalk_1.default.yellow('Possible reasons:'));
|
||||
console.error(' - Invalid signature');
|
||||
console.error(' - Token format is incorrect');
|
||||
console.error(' - Public key mismatch (if --public-key was provided)');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ valid: false, error: errorMessage }, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error(chalk_1.default.red(`Error: ${errorMessage}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
commander_1.program.parse();
|
||||
//# sourceMappingURL=cli.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/cli.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/cli.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
15
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts
vendored
Normal file
15
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import { DecryptionResult } from '../types/crypto';
|
||||
export declare class ChannelCrypto {
|
||||
/**
|
||||
* Decrypt GroupText message using MeshCore algorithm:
|
||||
* - HMAC-SHA256 verification with 2-byte MAC
|
||||
* - AES-128 ECB decryption
|
||||
*/
|
||||
static decryptGroupTextMessage(ciphertext: string, cipherMac: string, channelKey: string): DecryptionResult;
|
||||
/**
|
||||
* Calculate MeshCore channel hash from secret key
|
||||
* Returns the first byte of SHA256(secret) as hex string
|
||||
*/
|
||||
static calculateChannelHash(secretKeyHex: string): string;
|
||||
}
|
||||
//# sourceMappingURL=channel-crypto.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"channel-crypto.d.ts","sourceRoot":"","sources":["../../src/crypto/channel-crypto.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAGnD,qBAAa,aAAa;IACxB;;;;OAIG;IACH,MAAM,CAAC,uBAAuB,CAC5B,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,gBAAgB;IAuFnB;;;OAGG;IACH,MAAM,CAAC,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;CAK1D"}
|
||||
94
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js
vendored
Normal file
94
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChannelCrypto = void 0;
|
||||
const crypto_js_1 = require("crypto-js");
|
||||
const hex_1 = require("../utils/hex");
|
||||
class ChannelCrypto {
|
||||
/**
|
||||
* Decrypt GroupText message using MeshCore algorithm:
|
||||
* - HMAC-SHA256 verification with 2-byte MAC
|
||||
* - AES-128 ECB decryption
|
||||
*/
|
||||
static decryptGroupTextMessage(ciphertext, cipherMac, channelKey) {
|
||||
try {
|
||||
// convert hex strings to byte arrays
|
||||
const channelKey16 = (0, hex_1.hexToBytes)(channelKey);
|
||||
const macBytes = (0, hex_1.hexToBytes)(cipherMac);
|
||||
// MeshCore uses 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
const channelSecret = new Uint8Array(32);
|
||||
channelSecret.set(channelKey16, 0);
|
||||
// Step 1: Verify HMAC-SHA256 using full 32-byte channel secret
|
||||
const calculatedMac = (0, crypto_js_1.HmacSHA256)(crypto_js_1.enc.Hex.parse(ciphertext), crypto_js_1.enc.Hex.parse((0, hex_1.bytesToHex)(channelSecret)));
|
||||
const calculatedMacBytes = (0, hex_1.hexToBytes)(calculatedMac.toString(crypto_js_1.enc.Hex));
|
||||
const calculatedMacFirst2 = calculatedMacBytes.slice(0, 2);
|
||||
if (calculatedMacFirst2[0] !== macBytes[0] || calculatedMacFirst2[1] !== macBytes[1]) {
|
||||
return { success: false, error: 'MAC verification failed' };
|
||||
}
|
||||
// Step 2: Decrypt using AES-128 ECB with first 16 bytes of channel secret
|
||||
const keyWords = crypto_js_1.enc.Hex.parse(channelKey);
|
||||
const ciphertextWords = crypto_js_1.enc.Hex.parse(ciphertext);
|
||||
const decrypted = crypto_js_1.AES.decrypt(crypto_js_1.lib.CipherParams.create({ ciphertext: ciphertextWords }), keyWords, { mode: crypto_js_1.mode.ECB, padding: crypto_js_1.pad.NoPadding });
|
||||
const decryptedBytes = (0, hex_1.hexToBytes)(decrypted.toString(crypto_js_1.enc.Hex));
|
||||
if (!decryptedBytes || decryptedBytes.length < 5) {
|
||||
return { success: false, error: 'Decrypted content too short' };
|
||||
}
|
||||
// parse MeshCore format: timestamp(4) + flags(1) + message_text
|
||||
const timestamp = decryptedBytes[0] |
|
||||
(decryptedBytes[1] << 8) |
|
||||
(decryptedBytes[2] << 16) |
|
||||
(decryptedBytes[3] << 24);
|
||||
const flagsAndAttempt = decryptedBytes[4];
|
||||
// extract message text with UTF-8 decoding
|
||||
const messageBytes = decryptedBytes.slice(5);
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let messageText = decoder.decode(messageBytes);
|
||||
// remove null terminator if present
|
||||
const nullIndex = messageText.indexOf('\0');
|
||||
if (nullIndex >= 0) {
|
||||
messageText = messageText.substring(0, nullIndex);
|
||||
}
|
||||
// parse sender and message (format: "sender: message")
|
||||
const colonIndex = messageText.indexOf(': ');
|
||||
let sender;
|
||||
let content;
|
||||
if (colonIndex > 0 && colonIndex < 50) {
|
||||
const potentialSender = messageText.substring(0, colonIndex);
|
||||
if (!/[:\[\]]/.test(potentialSender)) {
|
||||
sender = potentialSender;
|
||||
content = messageText.substring(colonIndex + 2);
|
||||
}
|
||||
else {
|
||||
content = messageText;
|
||||
}
|
||||
}
|
||||
else {
|
||||
content = messageText;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
timestamp,
|
||||
flags: flagsAndAttempt,
|
||||
sender,
|
||||
message: content
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Decryption failed' };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Calculate MeshCore channel hash from secret key
|
||||
* Returns the first byte of SHA256(secret) as hex string
|
||||
*/
|
||||
static calculateChannelHash(secretKeyHex) {
|
||||
const hash = (0, crypto_js_1.SHA256)(crypto_js_1.enc.Hex.parse(secretKeyHex));
|
||||
const hashBytes = (0, hex_1.hexToBytes)(hash.toString(crypto_js_1.enc.Hex));
|
||||
return hashBytes[0].toString(16).padStart(2, '0');
|
||||
}
|
||||
}
|
||||
exports.ChannelCrypto = ChannelCrypto;
|
||||
//# sourceMappingURL=channel-crypto.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/channel-crypto.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"channel-crypto.js","sourceRoot":"","sources":["../../src/crypto/channel-crypto.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAEd,yCAAyE;AAEzE,sCAAsD;AAEtD,MAAa,aAAa;IACxB;;;;OAIG;IACH,MAAM,CAAC,uBAAuB,CAC5B,UAAkB,EAClB,SAAiB,EACjB,UAAkB;QAElB,IAAI,CAAC;YACH,qCAAqC;YACrC,MAAM,YAAY,GAAG,IAAA,gBAAU,EAAC,UAAU,CAAC,CAAC;YAC5C,MAAM,QAAQ,GAAG,IAAA,gBAAU,EAAC,SAAS,CAAC,CAAC;YAEvC,oEAAoE;YACpE,MAAM,aAAa,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;YACzC,aAAa,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC;YAEnC,+DAA+D;YAC/D,MAAM,aAAa,GAAG,IAAA,sBAAU,EAAC,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC,CAAC,CAAC;YACtG,MAAM,kBAAkB,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,QAAQ,CAAC,eAAG,CAAC,GAAG,CAAC,CAAC,CAAC;YACvE,MAAM,mBAAmB,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YAE3D,IAAI,mBAAmB,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,mBAAmB,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;gBACrF,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC;YAC9D,CAAC;YAED,0EAA0E;YAC1E,MAAM,QAAQ,GAAG,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YAC3C,MAAM,eAAe,GAAG,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YAElD,MAAM,SAAS,GAAG,eAAG,CAAC,OAAO,CAC3B,eAAG,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,eAAe,EAAE,CAAC,EACxD,QAAQ,EACR,EAAE,IAAI,EAAE,gBAAI,CAAC,GAAG,EAAE,OAAO,EAAE,eAAG,CAAC,SAAS,EAAE,CAC3C,CAAC;YAEF,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,SAAS,CAAC,QAAQ,CAAC,eAAG,CAAC,GAAG,CAAC,CAAC,CAAC;YAE/D,IAAI,CAAC,cAAc,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACjD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,6BAA6B,EAAE,CAAC;YAClE,CAAC;YAED,gEAAgE;YAChE,MAAM,SAAS,GAAG,cAAc,CAAC,CAAC,CAAC;gBAClB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;gBACxB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;gBACzB,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YAE3C,MAAM,eAAe,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC;YAE1C,2CAA2C;YAC3C,MAAM,YAAY,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAC7C,MAAM,OAAO,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,CAAC;YACzC,IAAI,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;YAE/C,oCAAoC;YACpC,MAAM,SAAS,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC5C,IAAI,SAAS,IAAI,CAAC,EAAE,CAAC;gBACnB,WAAW,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;YACpD,CAAC;YAED,uDAAuD;YACvD,MAAM,UAAU,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC7C,IAAI,MAA0B,CAAC;YAC/B,IAAI,OAAe,CAAC;YAEpB,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,EAAE,EAAE,CAAC;gBACtC,MAAM,eAAe,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;gBAC7D,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,CAAC;oBACrC,MAAM,GAAG,eAAe,CAAC;oBACzB,OAAO,GAAG,WAAW,CAAC,SAAS,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC;gBAClD,CAAC;qBAAM,CAAC;oBACN,OAAO,GAAG,WAAW,CAAC;gBACxB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,OAAO,GAAG,WAAW,CAAC;YACxB,CAAC;YAED,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,IAAI,EAAE;oBACJ,SAAS;oBACT,KAAK,EAAE,eAAe;oBACtB,MAAM;oBACN,OAAO,EAAE,OAAO;iBACjB;aACF,CAAC;QACJ,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,mBAAmB,EAAE,CAAC;QACjG,CAAC;IACH,CAAC;IAID;;;OAGG;IACH,MAAM,CAAC,oBAAoB,CAAC,YAAoB;QAC9C,MAAM,IAAI,GAAG,IAAA,kBAAM,EAAC,eAAG,CAAC,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC;QACjD,MAAM,SAAS,GAAG,IAAA,gBAAU,EAAC,IAAI,CAAC,QAAQ,CAAC,eAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QACrD,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACpD,CAAC;CACF;AA1GD,sCA0GC"}
|
||||
48
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts
vendored
Normal file
48
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
export declare class Ed25519SignatureVerifier {
|
||||
/**
|
||||
* Verify an Ed25519 signature for MeshCore advertisement packets
|
||||
*
|
||||
* According to MeshCore protocol, the signed message for advertisements is:
|
||||
* timestamp (4 bytes LE) + flags (1 byte) + location (8 bytes LE, if present) + name (variable, if present)
|
||||
*/
|
||||
static verifyAdvertisementSignature(publicKeyHex: string, signatureHex: string, timestamp: number, appDataHex: string): Promise<boolean>;
|
||||
/**
|
||||
* Construct the signed message for MeshCore advertisements
|
||||
* According to MeshCore source (Mesh.cpp lines 242-248):
|
||||
* Format: public_key (32 bytes) + timestamp (4 bytes LE) + app_data (variable length)
|
||||
*/
|
||||
private static constructAdvertSignedMessage;
|
||||
/**
|
||||
* Get a human-readable description of what was signed
|
||||
*/
|
||||
static getSignedMessageDescription(publicKeyHex: string, timestamp: number, appDataHex: string): string;
|
||||
/**
|
||||
* Get the hex representation of the signed message for debugging
|
||||
*/
|
||||
static getSignedMessageHex(publicKeyHex: string, timestamp: number, appDataHex: string): string;
|
||||
/**
|
||||
* Derive Ed25519 public key from orlp/ed25519 private key format
|
||||
* This implements the same algorithm as orlp/ed25519's ed25519_derive_pub()
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
static derivePublicKey(privateKeyHex: string): Promise<string>;
|
||||
/**
|
||||
* Derive Ed25519 public key from orlp/ed25519 private key format (synchronous version)
|
||||
* This implements the same algorithm as orlp/ed25519's ed25519_derive_pub()
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
static derivePublicKeySync(privateKeyHex: string): string;
|
||||
/**
|
||||
* Validate that a private key correctly derives to the expected public key
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - Expected 32-byte public key in hex format
|
||||
* @returns true if the private key derives to the expected public key
|
||||
*/
|
||||
static validateKeyPair(privateKeyHex: string, expectedPublicKeyHex: string): Promise<boolean>;
|
||||
}
|
||||
//# sourceMappingURL=ed25519-verifier.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ed25519-verifier.d.ts","sourceRoot":"","sources":["../../src/crypto/ed25519-verifier.ts"],"names":[],"mappings":"AAyEA,qBAAa,wBAAwB;IACnC;;;;;OAKG;WACU,4BAA4B,CACvC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC;IAkBnB;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,4BAA4B;IAuB3C;;OAEG;IACH,MAAM,CAAC,2BAA2B,CAChC,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM;IAIT;;OAEG;IACH,MAAM,CAAC,mBAAmB,CACxB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM;IAMT;;;;;;OAMG;WACU,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAepE;;;;;;OAMG;IACH,MAAM,CAAC,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM;IAezD;;;;;;OAMG;WACU,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAQpG"}
|
||||
217
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js
vendored
Normal file
217
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js
vendored
Normal file
@@ -0,0 +1,217 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Ed25519SignatureVerifier = void 0;
|
||||
const ed25519 = __importStar(require("@noble/ed25519"));
|
||||
const hex_1 = require("../utils/hex");
|
||||
const orlp_ed25519_wasm_1 = require("./orlp-ed25519-wasm");
|
||||
// Cross-platform SHA-512 implementation
|
||||
async function sha512Hash(data) {
|
||||
// Browser environment - use Web Crypto API
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
|
||||
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-512', data);
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
// Node.js environment - use crypto module
|
||||
if (typeof require !== 'undefined') {
|
||||
try {
|
||||
const { createHash } = require('crypto');
|
||||
return createHash('sha512').update(data).digest();
|
||||
}
|
||||
catch (error) {
|
||||
// Fallback for environments where require is not available
|
||||
}
|
||||
}
|
||||
throw new Error('No SHA-512 implementation available');
|
||||
}
|
||||
function sha512HashSync(data) {
|
||||
// Node.js environment - use crypto module
|
||||
if (typeof require !== 'undefined') {
|
||||
try {
|
||||
const { createHash } = require('crypto');
|
||||
return createHash('sha512').update(data).digest();
|
||||
}
|
||||
catch (error) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
// Browser environment fallback - use crypto-js for sync operation
|
||||
try {
|
||||
const CryptoJS = require('crypto-js');
|
||||
const wordArray = CryptoJS.lib.WordArray.create(data);
|
||||
const hash = CryptoJS.SHA512(wordArray);
|
||||
const hashBytes = new Uint8Array(64);
|
||||
// Convert CryptoJS hash to Uint8Array
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const word = hash.words[i] || 0;
|
||||
hashBytes[i * 4] = (word >>> 24) & 0xff;
|
||||
hashBytes[i * 4 + 1] = (word >>> 16) & 0xff;
|
||||
hashBytes[i * 4 + 2] = (word >>> 8) & 0xff;
|
||||
hashBytes[i * 4 + 3] = word & 0xff;
|
||||
}
|
||||
return hashBytes;
|
||||
}
|
||||
catch (error) {
|
||||
// Final fallback - this should not happen since crypto-js is a dependency
|
||||
throw new Error('No SHA-512 implementation available for synchronous operation');
|
||||
}
|
||||
}
|
||||
// Set up SHA-512 for @noble/ed25519
|
||||
ed25519.etc.sha512Async = sha512Hash;
|
||||
// Always set up sync version - @noble/ed25519 requires it
|
||||
// It will throw in browser environments, which @noble/ed25519 can handle
|
||||
try {
|
||||
ed25519.etc.sha512Sync = sha512HashSync;
|
||||
}
|
||||
catch (error) {
|
||||
console.debug('Could not set up synchronous SHA-512:', error);
|
||||
}
|
||||
class Ed25519SignatureVerifier {
|
||||
/**
|
||||
* Verify an Ed25519 signature for MeshCore advertisement packets
|
||||
*
|
||||
* According to MeshCore protocol, the signed message for advertisements is:
|
||||
* timestamp (4 bytes LE) + flags (1 byte) + location (8 bytes LE, if present) + name (variable, if present)
|
||||
*/
|
||||
static async verifyAdvertisementSignature(publicKeyHex, signatureHex, timestamp, appDataHex) {
|
||||
try {
|
||||
// Convert hex strings to Uint8Arrays
|
||||
const publicKey = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
const signature = (0, hex_1.hexToBytes)(signatureHex);
|
||||
const appData = (0, hex_1.hexToBytes)(appDataHex);
|
||||
// Construct the signed message according to MeshCore format
|
||||
const message = this.constructAdvertSignedMessage(publicKeyHex, timestamp, appData);
|
||||
// Verify the signature using noble-ed25519
|
||||
return await ed25519.verify(signature, message, publicKey);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Ed25519 signature verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Construct the signed message for MeshCore advertisements
|
||||
* According to MeshCore source (Mesh.cpp lines 242-248):
|
||||
* Format: public_key (32 bytes) + timestamp (4 bytes LE) + app_data (variable length)
|
||||
*/
|
||||
static constructAdvertSignedMessage(publicKeyHex, timestamp, appData) {
|
||||
const publicKey = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
// Timestamp (4 bytes, little-endian)
|
||||
const timestampBytes = new Uint8Array(4);
|
||||
timestampBytes[0] = timestamp & 0xFF;
|
||||
timestampBytes[1] = (timestamp >> 8) & 0xFF;
|
||||
timestampBytes[2] = (timestamp >> 16) & 0xFF;
|
||||
timestampBytes[3] = (timestamp >> 24) & 0xFF;
|
||||
// Concatenate: public_key + timestamp + app_data
|
||||
const message = new Uint8Array(32 + 4 + appData.length);
|
||||
message.set(publicKey, 0);
|
||||
message.set(timestampBytes, 32);
|
||||
message.set(appData, 36);
|
||||
return message;
|
||||
}
|
||||
/**
|
||||
* Get a human-readable description of what was signed
|
||||
*/
|
||||
static getSignedMessageDescription(publicKeyHex, timestamp, appDataHex) {
|
||||
return `Public Key: ${publicKeyHex} + Timestamp: ${timestamp} (${new Date(timestamp * 1000).toISOString()}) + App Data: ${appDataHex}`;
|
||||
}
|
||||
/**
|
||||
* Get the hex representation of the signed message for debugging
|
||||
*/
|
||||
static getSignedMessageHex(publicKeyHex, timestamp, appDataHex) {
|
||||
const appData = (0, hex_1.hexToBytes)(appDataHex);
|
||||
const message = this.constructAdvertSignedMessage(publicKeyHex, timestamp, appData);
|
||||
return (0, hex_1.bytesToHex)(message);
|
||||
}
|
||||
/**
|
||||
* Derive Ed25519 public key from orlp/ed25519 private key format
|
||||
* This implements the same algorithm as orlp/ed25519's ed25519_derive_pub()
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
static async derivePublicKey(privateKeyHex) {
|
||||
try {
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
// Use the orlp/ed25519 WebAssembly implementation
|
||||
return await (0, orlp_ed25519_wasm_1.derivePublicKey)(privateKeyHex);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to derive public key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Derive Ed25519 public key from orlp/ed25519 private key format (synchronous version)
|
||||
* This implements the same algorithm as orlp/ed25519's ed25519_derive_pub()
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
static derivePublicKeySync(privateKeyHex) {
|
||||
try {
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
// Note: WASM operations are async, so this sync version throws an error
|
||||
throw new Error('Synchronous key derivation not supported with WASM. Use derivePublicKey() instead.');
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to derive public key: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Validate that a private key correctly derives to the expected public key
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - Expected 32-byte public key in hex format
|
||||
* @returns true if the private key derives to the expected public key
|
||||
*/
|
||||
static async validateKeyPair(privateKeyHex, expectedPublicKeyHex) {
|
||||
try {
|
||||
return await (0, orlp_ed25519_wasm_1.validateKeyPair)(privateKeyHex, expectedPublicKeyHex);
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Ed25519SignatureVerifier = Ed25519SignatureVerifier;
|
||||
//# sourceMappingURL=ed25519-verifier.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/ed25519-verifier.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
23
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts
vendored
Normal file
23
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CryptoKeyStore } from '../types/crypto';
|
||||
export declare class MeshCoreKeyStore implements CryptoKeyStore {
|
||||
nodeKeys: Map<string, string>;
|
||||
private channelHashToKeys;
|
||||
constructor(initialKeys?: {
|
||||
channelSecrets?: string[];
|
||||
nodeKeys?: Record<string, string>;
|
||||
});
|
||||
addNodeKey(publicKey: string, privateKey: string): void;
|
||||
hasChannelKey(channelHash: string): boolean;
|
||||
hasNodeKey(publicKey: string): boolean;
|
||||
/**
|
||||
* Get all channel keys that match the given channel hash (handles collisions)
|
||||
*/
|
||||
getChannelKeys(channelHash: string): string[];
|
||||
getNodeKey(publicKey: string): string | undefined;
|
||||
/**
|
||||
* Add channel keys by secret keys (new simplified API)
|
||||
* Automatically calculates channel hashes
|
||||
*/
|
||||
addChannelSecrets(secretKeys: string[]): void;
|
||||
}
|
||||
//# sourceMappingURL=key-manager.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"key-manager.d.ts","sourceRoot":"","sources":["../../src/crypto/key-manager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGjD,qBAAa,gBAAiB,YAAW,cAAc;IAC9C,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAa;IAGjD,OAAO,CAAC,iBAAiB,CAA+B;gBAE5C,WAAW,CAAC,EAAE;QACxB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACnC;IAYD,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAKvD,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAK3C,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAKtC;;OAEG;IACH,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE;IAK7C,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAKjD;;;OAGG;IACH,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI;CAW9C"}
|
||||
60
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js
vendored
Normal file
60
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MeshCoreKeyStore = void 0;
|
||||
const channel_crypto_1 = require("./channel-crypto");
|
||||
class MeshCoreKeyStore {
|
||||
constructor(initialKeys) {
|
||||
this.nodeKeys = new Map();
|
||||
// internal map for hash -> multiple keys (collision handling)
|
||||
this.channelHashToKeys = new Map();
|
||||
if (initialKeys?.channelSecrets) {
|
||||
this.addChannelSecrets(initialKeys.channelSecrets);
|
||||
}
|
||||
if (initialKeys?.nodeKeys) {
|
||||
Object.entries(initialKeys.nodeKeys).forEach(([pubKey, privKey]) => {
|
||||
this.addNodeKey(pubKey, privKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
addNodeKey(publicKey, privateKey) {
|
||||
const normalizedPubKey = publicKey.toUpperCase();
|
||||
this.nodeKeys.set(normalizedPubKey, privateKey);
|
||||
}
|
||||
hasChannelKey(channelHash) {
|
||||
const normalizedHash = channelHash.toLowerCase();
|
||||
return this.channelHashToKeys.has(normalizedHash);
|
||||
}
|
||||
hasNodeKey(publicKey) {
|
||||
const normalizedPubKey = publicKey.toUpperCase();
|
||||
return this.nodeKeys.has(normalizedPubKey);
|
||||
}
|
||||
/**
|
||||
* Get all channel keys that match the given channel hash (handles collisions)
|
||||
*/
|
||||
getChannelKeys(channelHash) {
|
||||
const normalizedHash = channelHash.toLowerCase();
|
||||
return this.channelHashToKeys.get(normalizedHash) || [];
|
||||
}
|
||||
getNodeKey(publicKey) {
|
||||
const normalizedPubKey = publicKey.toUpperCase();
|
||||
return this.nodeKeys.get(normalizedPubKey);
|
||||
}
|
||||
/**
|
||||
* Add channel keys by secret keys (new simplified API)
|
||||
* Automatically calculates channel hashes
|
||||
*/
|
||||
addChannelSecrets(secretKeys) {
|
||||
for (const secretKey of secretKeys) {
|
||||
const channelHash = channel_crypto_1.ChannelCrypto.calculateChannelHash(secretKey).toLowerCase();
|
||||
// Handle potential hash collisions
|
||||
if (!this.channelHashToKeys.has(channelHash)) {
|
||||
this.channelHashToKeys.set(channelHash, []);
|
||||
}
|
||||
this.channelHashToKeys.get(channelHash).push(secretKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.MeshCoreKeyStore = MeshCoreKeyStore;
|
||||
//# sourceMappingURL=key-manager.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/key-manager.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"key-manager.js","sourceRoot":"","sources":["../../src/crypto/key-manager.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAGd,qDAAiD;AAEjD,MAAa,gBAAgB;IAM3B,YAAY,WAGX;QARM,aAAQ,GAAwB,IAAI,GAAG,EAAE,CAAC;QAEjD,8DAA8D;QACtD,sBAAiB,GAAG,IAAI,GAAG,EAAoB,CAAC;QAMtD,IAAI,WAAW,EAAE,cAAc,EAAE,CAAC;YAChC,IAAI,CAAC,iBAAiB,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC;QACrD,CAAC;QAED,IAAI,WAAW,EAAE,QAAQ,EAAE,CAAC;YAC1B,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE;gBACjE,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACnC,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,UAAU,CAAC,SAAiB,EAAE,UAAkB;QAC9C,MAAM,gBAAgB,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QACjD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,EAAE,UAAU,CAAC,CAAC;IAClD,CAAC;IAED,aAAa,CAAC,WAAmB;QAC/B,MAAM,cAAc,GAAG,WAAW,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACpD,CAAC;IAED,UAAU,CAAC,SAAiB;QAC1B,MAAM,gBAAgB,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAC7C,CAAC;IAED;;OAEG;IACH,cAAc,CAAC,WAAmB;QAChC,MAAM,cAAc,GAAG,WAAW,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;IAC1D,CAAC;IAED,UAAU,CAAC,SAAiB;QAC1B,MAAM,gBAAgB,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAC7C,CAAC;IAED;;;OAGG;IACH,iBAAiB,CAAC,UAAoB;QACpC,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACnC,MAAM,WAAW,GAAG,8BAAa,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,CAAC;YAEhF,mCAAmC;YACnC,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,CAAC;gBAC7C,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;YAC9C,CAAC;YACD,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,WAAW,CAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC3D,CAAC;IACH,CAAC;CACF;AAhED,4CAgEC"}
|
||||
34
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts
vendored
Normal file
34
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Derive Ed25519 public key from private key using the exact orlp/ed25519 algorithm
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
export declare function derivePublicKey(privateKeyHex: string): Promise<string>;
|
||||
/**
|
||||
* Validate that a private key and public key pair match using orlp/ed25519
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if the keys match, false otherwise
|
||||
*/
|
||||
export declare function validateKeyPair(privateKeyHex: string, expectedPublicKeyHex: string): Promise<boolean>;
|
||||
/**
|
||||
* Sign a message using Ed25519 with orlp/ed25519 implementation
|
||||
*
|
||||
* @param messageHex - Message to sign in hex format
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns 64-byte signature in hex format
|
||||
*/
|
||||
export declare function sign(messageHex: string, privateKeyHex: string, publicKeyHex: string): Promise<string>;
|
||||
/**
|
||||
* Verify an Ed25519 signature using orlp/ed25519 implementation
|
||||
*
|
||||
* @param signatureHex - 64-byte signature in hex format
|
||||
* @param messageHex - Message that was signed in hex format
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
export declare function verify(signatureHex: string, messageHex: string, publicKeyHex: string): Promise<boolean>;
|
||||
//# sourceMappingURL=orlp-ed25519-wasm.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"orlp-ed25519-wasm.d.ts","sourceRoot":"","sources":["../../src/crypto/orlp-ed25519-wasm.ts"],"names":[],"mappings":"AAgBA;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAiC5E;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAoC3G;AAED;;;;;;;GAOG;AACH,wBAAsB,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuC3G;AAED;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAsC7G"}
|
||||
150
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js
vendored
Normal file
150
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
"use strict";
|
||||
// WebAssembly wrapper for orlp/ed25519 key derivation
|
||||
// This provides the exact orlp algorithm for JavaScript
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.derivePublicKey = derivePublicKey;
|
||||
exports.validateKeyPair = validateKeyPair;
|
||||
exports.sign = sign;
|
||||
exports.verify = verify;
|
||||
const hex_1 = require("../utils/hex");
|
||||
// Import the generated WASM module
|
||||
const OrlpEd25519 = require('../../lib/orlp-ed25519.js');
|
||||
/**
|
||||
* Get a fresh WASM instance
|
||||
* Loads a fresh instance each time because the WASM module could behave unpredictably otherwise
|
||||
*/
|
||||
async function getWasmInstance() {
|
||||
return await OrlpEd25519();
|
||||
}
|
||||
/**
|
||||
* Derive Ed25519 public key from private key using the exact orlp/ed25519 algorithm
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @returns 32-byte public key in hex format
|
||||
*/
|
||||
async function derivePublicKey(privateKeyHex) {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
// Allocate memory buffers directly in WASM heap
|
||||
const privateKeyPtr = 1024; // Use fixed memory locations
|
||||
const publicKeyPtr = 1024 + 64;
|
||||
// Copy private key to WASM memory
|
||||
wasmModule.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
// Call the orlp key derivation function
|
||||
const result = wasmModule.ccall('orlp_derive_public_key', 'number', ['number', 'number'], [publicKeyPtr, privateKeyPtr]);
|
||||
if (result !== 0) {
|
||||
throw new Error('orlp key derivation failed: invalid private key');
|
||||
}
|
||||
// Read the public key from WASM memory
|
||||
const publicKeyBytes = new Uint8Array(32);
|
||||
publicKeyBytes.set(wasmModule.HEAPU8.subarray(publicKeyPtr, publicKeyPtr + 32));
|
||||
return (0, hex_1.bytesToHex)(publicKeyBytes);
|
||||
}
|
||||
/**
|
||||
* Validate that a private key and public key pair match using orlp/ed25519
|
||||
*
|
||||
* @param privateKeyHex - 64-byte private key in hex format
|
||||
* @param expectedPublicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if the keys match, false otherwise
|
||||
*/
|
||||
async function validateKeyPair(privateKeyHex, expectedPublicKeyHex) {
|
||||
try {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
const expectedPublicKeyBytes = (0, hex_1.hexToBytes)(expectedPublicKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
return false;
|
||||
}
|
||||
if (expectedPublicKeyBytes.length !== 32) {
|
||||
return false;
|
||||
}
|
||||
// Allocate memory buffers directly in WASM heap
|
||||
const privateKeyPtr = 2048; // Use different fixed memory locations
|
||||
const publicKeyPtr = 2048 + 64;
|
||||
// Copy keys to WASM memory
|
||||
wasmModule.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
wasmModule.HEAPU8.set(expectedPublicKeyBytes, publicKeyPtr);
|
||||
// Call the validation function (note: C function expects public_key first, then private_key)
|
||||
const result = wasmModule.ccall('orlp_validate_keypair', 'number', ['number', 'number'], [publicKeyPtr, privateKeyPtr]);
|
||||
return result === 1;
|
||||
}
|
||||
catch (error) {
|
||||
// Invalid hex strings or other errors should return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sign a message using Ed25519 with orlp/ed25519 implementation
|
||||
*
|
||||
* @param messageHex - Message to sign in hex format
|
||||
* @param privateKeyHex - 64-byte private key in hex format (orlp/ed25519 format)
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns 64-byte signature in hex format
|
||||
*/
|
||||
async function sign(messageHex, privateKeyHex, publicKeyHex) {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const messageBytes = (0, hex_1.hexToBytes)(messageHex);
|
||||
const privateKeyBytes = (0, hex_1.hexToBytes)(privateKeyHex);
|
||||
const publicKeyBytes = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
if (privateKeyBytes.length !== 64) {
|
||||
throw new Error(`Invalid private key length: expected 64 bytes, got ${privateKeyBytes.length}`);
|
||||
}
|
||||
if (publicKeyBytes.length !== 32) {
|
||||
throw new Error(`Invalid public key length: expected 32 bytes, got ${publicKeyBytes.length}`);
|
||||
}
|
||||
// Allocate memory buffers with large gaps to avoid conflicts with scratch space
|
||||
const messagePtr = 100000;
|
||||
const privateKeyPtr = 200000;
|
||||
const publicKeyPtr = 300000;
|
||||
const signaturePtr = 400000;
|
||||
// Copy data to WASM memory
|
||||
wasmModule.HEAPU8.set(messageBytes, messagePtr);
|
||||
wasmModule.HEAPU8.set(privateKeyBytes, privateKeyPtr);
|
||||
wasmModule.HEAPU8.set(publicKeyBytes, publicKeyPtr);
|
||||
// Call orlp_sign
|
||||
wasmModule.ccall('orlp_sign', 'void', ['number', 'number', 'number', 'number', 'number'], [signaturePtr, messagePtr, messageBytes.length, publicKeyPtr, privateKeyPtr]);
|
||||
// Read signature
|
||||
const signatureBytes = new Uint8Array(64);
|
||||
signatureBytes.set(wasmModule.HEAPU8.subarray(signaturePtr, signaturePtr + 64));
|
||||
return (0, hex_1.bytesToHex)(signatureBytes);
|
||||
}
|
||||
/**
|
||||
* Verify an Ed25519 signature using orlp/ed25519 implementation
|
||||
*
|
||||
* @param signatureHex - 64-byte signature in hex format
|
||||
* @param messageHex - Message that was signed in hex format
|
||||
* @param publicKeyHex - 32-byte public key in hex format
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
async function verify(signatureHex, messageHex, publicKeyHex) {
|
||||
try {
|
||||
const wasmModule = await getWasmInstance();
|
||||
const signatureBytes = (0, hex_1.hexToBytes)(signatureHex);
|
||||
const messageBytes = (0, hex_1.hexToBytes)(messageHex);
|
||||
const publicKeyBytes = (0, hex_1.hexToBytes)(publicKeyHex);
|
||||
if (signatureBytes.length !== 64) {
|
||||
return false;
|
||||
}
|
||||
if (publicKeyBytes.length !== 32) {
|
||||
return false;
|
||||
}
|
||||
// Allocate memory buffers with large gaps to avoid conflicts with scratch space
|
||||
const messagePtr = 500000;
|
||||
const signaturePtr = 600000;
|
||||
const publicKeyPtr = 700000;
|
||||
// Copy data to WASM memory
|
||||
wasmModule.HEAPU8.set(signatureBytes, signaturePtr);
|
||||
wasmModule.HEAPU8.set(messageBytes, messagePtr);
|
||||
wasmModule.HEAPU8.set(publicKeyBytes, publicKeyPtr);
|
||||
// Call the orlp verify function
|
||||
const result = wasmModule.ccall('orlp_verify', 'number', ['number', 'number', 'number', 'number'], [signaturePtr, messagePtr, messageBytes.length, publicKeyPtr]);
|
||||
return result === 1;
|
||||
}
|
||||
catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=orlp-ed25519-wasm.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/crypto/orlp-ed25519-wasm.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"orlp-ed25519-wasm.js","sourceRoot":"","sources":["../../src/crypto/orlp-ed25519-wasm.ts"],"names":[],"mappings":";AAAA,sDAAsD;AACtD,wDAAwD;;AAqBxD,0CAiCC;AASD,0CAoCC;AAUD,oBAuCC;AAUD,wBAsCC;AAlMD,sCAAsD;AAEtD,mCAAmC;AACnC,MAAM,WAAW,GAAG,OAAO,CAAC,2BAA2B,CAAC,CAAC;AAEzD;;;GAGG;AACH,KAAK,UAAU,eAAe;IAC5B,OAAO,MAAM,WAAW,EAAE,CAAC;AAC7B,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,eAAe,CAAC,aAAqB;IACzD,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;IAE3C,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC;IAElD,IAAI,eAAe,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,sDAAsD,eAAe,CAAC,MAAM,EAAE,CAAC,CAAC;IAClG,CAAC;IAED,gDAAgD;IAChD,MAAM,aAAa,GAAG,IAAI,CAAC,CAAC,6BAA6B;IACzD,MAAM,YAAY,GAAG,IAAI,GAAG,EAAE,CAAC;IAE/B,kCAAkC;IAClC,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;IAEtD,wCAAwC;IACxC,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAC7B,wBAAwB,EACxB,QAAQ,EACR,CAAC,QAAQ,EAAE,QAAQ,CAAC,EACpB,CAAC,YAAY,EAAE,aAAa,CAAC,CAC9B,CAAC;IAEF,IAAI,MAAM,KAAK,CAAC,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAC;IACrE,CAAC;IAED,uCAAuC;IACvC,MAAM,cAAc,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC1C,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,GAAG,EAAE,CAAC,CAAC,CAAC;IAEhF,OAAO,IAAA,gBAAU,EAAC,cAAc,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,eAAe,CAAC,aAAqB,EAAE,oBAA4B;IACvF,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;QAE3C,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC;QAClD,MAAM,sBAAsB,GAAG,IAAA,gBAAU,EAAC,oBAAoB,CAAC,CAAC;QAEhE,IAAI,eAAe,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YAClC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,IAAI,sBAAsB,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACzC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,gDAAgD;QAChD,MAAM,aAAa,GAAG,IAAI,CAAC,CAAC,uCAAuC;QACnE,MAAM,YAAY,GAAG,IAAI,GAAG,EAAE,CAAC;QAE/B,2BAA2B;QAC3B,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;QACtD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,YAAY,CAAC,CAAC;QAE5D,6FAA6F;QAC7F,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAC7B,uBAAuB,EACvB,QAAQ,EACR,CAAC,QAAQ,EAAE,QAAQ,CAAC,EACpB,CAAC,YAAY,EAAE,aAAa,CAAC,CAC9B,CAAC;QAEF,OAAO,MAAM,KAAK,CAAC,CAAC;IACtB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,0DAA0D;QAC1D,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,IAAI,CAAC,UAAkB,EAAE,aAAqB,EAAE,YAAoB;IACxF,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;IAE3C,MAAM,YAAY,GAAG,IAAA,gBAAU,EAAC,UAAU,CAAC,CAAC;IAC5C,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,aAAa,CAAC,CAAC;IAClD,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;IAEhD,IAAI,eAAe,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,sDAAsD,eAAe,CAAC,MAAM,EAAE,CAAC,CAAC;IAClG,CAAC;IAED,IAAI,cAAc,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,qDAAqD,cAAc,CAAC,MAAM,EAAE,CAAC,CAAC;IAChG,CAAC;IAED,gFAAgF;IAChF,MAAM,UAAU,GAAG,MAAM,CAAC;IAC1B,MAAM,aAAa,GAAG,MAAM,CAAC;IAC7B,MAAM,YAAY,GAAG,MAAM,CAAC;IAC5B,MAAM,YAAY,GAAG,MAAM,CAAC;IAE5B,2BAA2B;IAC3B,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAChD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;IACtD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;IAEpD,iBAAiB;IACjB,UAAU,CAAC,KAAK,CACd,WAAW,EACX,MAAM,EACN,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAClD,CAAC,YAAY,EAAE,UAAU,EAAE,YAAY,CAAC,MAAM,EAAE,YAAY,EAAE,aAAa,CAAC,CAC7E,CAAC;IAEF,iBAAiB;IACjB,MAAM,cAAc,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC1C,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,GAAG,EAAE,CAAC,CAAC,CAAC;IAEhF,OAAO,IAAA,gBAAU,EAAC,cAAc,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,MAAM,CAAC,YAAoB,EAAE,UAAkB,EAAE,YAAoB;IACzF,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,MAAM,eAAe,EAAE,CAAC;QAE3C,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;QAChD,MAAM,YAAY,GAAG,IAAA,gBAAU,EAAC,UAAU,CAAC,CAAC;QAC5C,MAAM,cAAc,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;QAEhD,IAAI,cAAc,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACjC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,IAAI,cAAc,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YACjC,OAAO,KAAK,CAAC;QACf,CAAC;QAED,gFAAgF;QAChF,MAAM,UAAU,GAAG,MAAM,CAAC;QAC1B,MAAM,YAAY,GAAG,MAAM,CAAC;QAC5B,MAAM,YAAY,GAAG,MAAM,CAAC;QAE5B,2BAA2B;QAC3B,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QACpD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;QAChD,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;QAEpD,gCAAgC;QAChC,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAC7B,aAAa,EACb,QAAQ,EACR,CAAC,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,EACxC,CAAC,YAAY,EAAE,UAAU,EAAE,YAAY,CAAC,MAAM,EAAE,YAAY,CAAC,CAC9D,CAAC;QAEF,OAAO,MAAM,KAAK,CAAC,CAAC;IACtB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
|
||||
51
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts
vendored
Normal file
51
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DecodedPacket, PacketStructure } from '../types/packet';
|
||||
import { DecryptionOptions, ValidationResult, CryptoKeyStore } from '../types/crypto';
|
||||
export declare class MeshCorePacketDecoder {
|
||||
/**
|
||||
* Decode a raw packet from hex string
|
||||
*/
|
||||
static decode(hexData: string, options?: DecryptionOptions): DecodedPacket;
|
||||
/**
|
||||
* Decode a raw packet from hex string with signature verification for advertisements
|
||||
*/
|
||||
static decodeWithVerification(hexData: string, options?: DecryptionOptions): Promise<DecodedPacket>;
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown
|
||||
*/
|
||||
static analyzeStructure(hexData: string, options?: DecryptionOptions): PacketStructure;
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown with signature verification for advertisements
|
||||
*/
|
||||
static analyzeStructureWithVerification(hexData: string, options?: DecryptionOptions): Promise<PacketStructure>;
|
||||
/**
|
||||
* Internal unified parsing method
|
||||
*/
|
||||
private static parseInternal;
|
||||
/**
|
||||
* Internal unified parsing method with signature verification for advertisements
|
||||
*/
|
||||
private static parseInternalAsync;
|
||||
/**
|
||||
* Validate packet format without full decoding
|
||||
*/
|
||||
static validate(hexData: string): ValidationResult;
|
||||
/**
|
||||
* Calculate message hash for a packet
|
||||
*/
|
||||
static calculateMessageHash(bytes: Uint8Array, routeType: number, payloadType: number, payloadVersion: number): string;
|
||||
/**
|
||||
* Create a key store for decryption
|
||||
*/
|
||||
static createKeyStore(initialKeys?: {
|
||||
channelSecrets?: string[];
|
||||
nodeKeys?: Record<string, string>;
|
||||
}): CryptoKeyStore;
|
||||
/**
|
||||
* Decode a path_len byte into hash size, hop count, and total byte length.
|
||||
* Firmware reference: Packet.h lines 79-83
|
||||
* Bits 7:6 = hash size selector: (path_len >> 6) + 1 = 1, 2, or 3 bytes per hop
|
||||
* Bits 5:0 = hop count (0-63)
|
||||
*/
|
||||
private static decodePathLenByte;
|
||||
}
|
||||
//# sourceMappingURL=packet-decoder.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"packet-decoder.d.ts","sourceRoot":"","sources":["../../src/decoder/packet-decoder.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,eAAe,EAAkD,MAAM,iBAAiB,CAAC;AAIjH,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAatF,qBAAa,qBAAqB;IAChC;;OAEG;IACH,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa;IAK1E;;OAEG;WACU,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC;IAKzG;;OAEG;IACH,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,eAAe;IAKtF;;OAEG;WACU,gCAAgC,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC;IAKrH;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,aAAa;IA6Y5B;;OAEG;mBACkB,kBAAkB;IA2CvC;;OAEG;IACH,MAAM,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB;IAmDlD;;OAEG;IACH,MAAM,CAAC,oBAAoB,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM;IAkDtH;;OAEG;IACH,MAAM,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE;QAClC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;QAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACnC,GAAG,cAAc;IAIlB;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,iBAAiB;CAMjC"}
|
||||
576
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js
vendored
Normal file
576
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js
vendored
Normal file
@@ -0,0 +1,576 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MeshCorePacketDecoder = void 0;
|
||||
const enums_1 = require("../types/enums");
|
||||
const hex_1 = require("../utils/hex");
|
||||
const enum_names_1 = require("../utils/enum-names");
|
||||
const key_manager_1 = require("../crypto/key-manager");
|
||||
const advert_1 = require("./payload-decoders/advert");
|
||||
const trace_1 = require("./payload-decoders/trace");
|
||||
const group_text_1 = require("./payload-decoders/group-text");
|
||||
const request_1 = require("./payload-decoders/request");
|
||||
const response_1 = require("./payload-decoders/response");
|
||||
const anon_request_1 = require("./payload-decoders/anon-request");
|
||||
const ack_1 = require("./payload-decoders/ack");
|
||||
const path_1 = require("./payload-decoders/path");
|
||||
const text_message_1 = require("./payload-decoders/text-message");
|
||||
const control_1 = require("./payload-decoders/control");
|
||||
class MeshCorePacketDecoder {
|
||||
/**
|
||||
* Decode a raw packet from hex string
|
||||
*/
|
||||
static decode(hexData, options) {
|
||||
const result = this.parseInternal(hexData, false, options);
|
||||
return result.packet;
|
||||
}
|
||||
/**
|
||||
* Decode a raw packet from hex string with signature verification for advertisements
|
||||
*/
|
||||
static async decodeWithVerification(hexData, options) {
|
||||
const result = await this.parseInternalAsync(hexData, false, options);
|
||||
return result.packet;
|
||||
}
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown
|
||||
*/
|
||||
static analyzeStructure(hexData, options) {
|
||||
const result = this.parseInternal(hexData, true, options);
|
||||
return result.structure;
|
||||
}
|
||||
/**
|
||||
* Analyze packet structure for detailed breakdown with signature verification for advertisements
|
||||
*/
|
||||
static async analyzeStructureWithVerification(hexData, options) {
|
||||
const result = await this.parseInternalAsync(hexData, true, options);
|
||||
return result.structure;
|
||||
}
|
||||
/**
|
||||
* Internal unified parsing method
|
||||
*/
|
||||
static parseInternal(hexData, includeStructure, options) {
|
||||
const bytes = (0, hex_1.hexToBytes)(hexData);
|
||||
const segments = [];
|
||||
if (bytes.length < 2) {
|
||||
const errorPacket = {
|
||||
messageHash: '',
|
||||
routeType: enums_1.RouteType.Flood,
|
||||
payloadType: enums_1.PayloadType.RawCustom,
|
||||
payloadVersion: enums_1.PayloadVersion.Version1,
|
||||
pathLength: 0,
|
||||
path: null,
|
||||
payload: { raw: '', decoded: null },
|
||||
totalBytes: bytes.length,
|
||||
isValid: false,
|
||||
errors: ['Packet too short (minimum 2 bytes required)']
|
||||
};
|
||||
const errorStructure = {
|
||||
segments: [],
|
||||
totalBytes: bytes.length,
|
||||
rawHex: hexData.toUpperCase(),
|
||||
messageHash: '',
|
||||
payload: {
|
||||
segments: [],
|
||||
hex: '',
|
||||
startByte: 0,
|
||||
type: 'Unknown'
|
||||
}
|
||||
};
|
||||
return { packet: errorPacket, structure: errorStructure };
|
||||
}
|
||||
try {
|
||||
let offset = 0;
|
||||
// parse header
|
||||
const header = bytes[0];
|
||||
const routeType = header & 0x03;
|
||||
const payloadType = (header >> 2) & 0x0F;
|
||||
const payloadVersion = (header >> 6) & 0x03;
|
||||
if (includeStructure) {
|
||||
segments.push({
|
||||
name: 'Header',
|
||||
description: 'Header byte breakdown',
|
||||
startByte: 0,
|
||||
endByte: 0,
|
||||
value: `0x${header.toString(16).padStart(2, '0')}`,
|
||||
headerBreakdown: {
|
||||
fullBinary: header.toString(2).padStart(8, '0'),
|
||||
fields: [
|
||||
{
|
||||
bits: '0-1',
|
||||
field: 'Route Type',
|
||||
value: (0, enum_names_1.getRouteTypeName)(routeType),
|
||||
binary: (header & 0x03).toString(2).padStart(2, '0')
|
||||
},
|
||||
{
|
||||
bits: '2-5',
|
||||
field: 'Payload Type',
|
||||
value: (0, enum_names_1.getPayloadTypeName)(payloadType),
|
||||
binary: ((header >> 2) & 0x0F).toString(2).padStart(4, '0')
|
||||
},
|
||||
{
|
||||
bits: '6-7',
|
||||
field: 'Version',
|
||||
value: payloadVersion.toString(),
|
||||
binary: ((header >> 6) & 0x03).toString(2).padStart(2, '0')
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
offset = 1;
|
||||
// handle transport codes
|
||||
let transportCodes;
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
if (bytes.length < offset + 4) {
|
||||
throw new Error('Packet too short for transport codes');
|
||||
}
|
||||
const code1 = bytes[offset] | (bytes[offset + 1] << 8);
|
||||
const code2 = bytes[offset + 2] | (bytes[offset + 3] << 8);
|
||||
transportCodes = [code1, code2];
|
||||
if (includeStructure) {
|
||||
const transportCode = (bytes[offset]) | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24);
|
||||
segments.push({
|
||||
name: 'Transport Code',
|
||||
description: 'Used for Direct/Response routing',
|
||||
startByte: offset,
|
||||
endByte: offset + 3,
|
||||
value: `0x${transportCode.toString(16).padStart(8, '0')}`
|
||||
});
|
||||
}
|
||||
offset += 4;
|
||||
}
|
||||
// parse path length byte (encodes hash size and hop count)
|
||||
// Bits 7:6 = hash size selector: (path_len >> 6) + 1 = 1, 2, or 3 bytes per hop
|
||||
// Bits 5:0 = hop count (0-63)
|
||||
if (bytes.length < offset + 1) {
|
||||
throw new Error('Packet too short for path length');
|
||||
}
|
||||
const pathLenByte = bytes[offset];
|
||||
const { hashSize: pathHashSize, hopCount: pathHopCount, byteLength: pathByteLength } = this.decodePathLenByte(pathLenByte);
|
||||
if (pathHashSize === 4) {
|
||||
throw new Error('Invalid path length byte: reserved hash size (bits 7:6 = 11)');
|
||||
}
|
||||
if (includeStructure) {
|
||||
const hashDesc = pathHashSize > 1 ? ` × ${pathHashSize}-byte hashes (${pathByteLength} bytes)` : '';
|
||||
let pathLengthDescription;
|
||||
if (pathHopCount === 0) {
|
||||
pathLengthDescription = pathHashSize > 1 ? `No path data (${pathHashSize}-byte hash mode)` : 'No path data';
|
||||
}
|
||||
else if (routeType === enums_1.RouteType.Direct || routeType === enums_1.RouteType.TransportDirect) {
|
||||
pathLengthDescription = `${pathHopCount} hops${hashDesc} of routing instructions (decreases as packet travels)`;
|
||||
}
|
||||
else if (routeType === enums_1.RouteType.Flood || routeType === enums_1.RouteType.TransportFlood) {
|
||||
pathLengthDescription = `${pathHopCount} hops${hashDesc} showing route taken (increases as packet floods)`;
|
||||
}
|
||||
else {
|
||||
pathLengthDescription = `Path contains ${pathHopCount} hops${hashDesc}`;
|
||||
}
|
||||
segments.push({
|
||||
name: 'Path Length',
|
||||
description: pathLengthDescription,
|
||||
startByte: offset,
|
||||
endByte: offset,
|
||||
value: `0x${pathLenByte.toString(16).padStart(2, '0')}`,
|
||||
headerBreakdown: {
|
||||
fullBinary: pathLenByte.toString(2).padStart(8, '0'),
|
||||
fields: [
|
||||
{
|
||||
bits: '6-7',
|
||||
field: 'Hash Size',
|
||||
value: `${pathHashSize} byte${pathHashSize > 1 ? 's' : ''} per hop`,
|
||||
binary: ((pathLenByte >> 6) & 0x03).toString(2).padStart(2, '0')
|
||||
},
|
||||
{
|
||||
bits: '0-5',
|
||||
field: 'Hop Count',
|
||||
value: `${pathHopCount} hop${pathHopCount !== 1 ? 's' : ''}`,
|
||||
binary: (pathLenByte & 63).toString(2).padStart(6, '0')
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
if (bytes.length < offset + pathByteLength) {
|
||||
throw new Error('Packet too short for path data');
|
||||
}
|
||||
// convert path data to grouped hex strings (one entry per hop)
|
||||
const pathBytes = bytes.subarray(offset, offset + pathByteLength);
|
||||
let path = null;
|
||||
if (pathHopCount > 0) {
|
||||
path = [];
|
||||
for (let i = 0; i < pathHopCount; i++) {
|
||||
const hopBytes = pathBytes.subarray(i * pathHashSize, (i + 1) * pathHashSize);
|
||||
path.push((0, hex_1.bytesToHex)(hopBytes));
|
||||
}
|
||||
}
|
||||
if (includeStructure && pathHopCount > 0) {
|
||||
if (payloadType === enums_1.PayloadType.Trace) {
|
||||
// TRACE packets have SNR values in path (always single-byte entries)
|
||||
const snrValues = [];
|
||||
for (let i = 0; i < pathByteLength; i++) {
|
||||
const snrRaw = bytes[offset + i];
|
||||
const snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
||||
const snrDb = snrSigned / 4.0;
|
||||
snrValues.push(`${snrDb.toFixed(2)}dB (0x${snrRaw.toString(16).padStart(2, '0')})`);
|
||||
}
|
||||
segments.push({
|
||||
name: 'Path SNR Data',
|
||||
description: `SNR values collected during trace: ${snrValues.join(', ')}`,
|
||||
startByte: offset,
|
||||
endByte: offset + pathByteLength - 1,
|
||||
value: (0, hex_1.bytesToHex)(bytes.slice(offset, offset + pathByteLength))
|
||||
});
|
||||
}
|
||||
else {
|
||||
let pathDescription = 'Routing path information';
|
||||
if (routeType === enums_1.RouteType.Direct || routeType === enums_1.RouteType.TransportDirect) {
|
||||
pathDescription = `Routing instructions (${pathHashSize}-byte hashes stripped at each hop as packet travels to destination)`;
|
||||
}
|
||||
else if (routeType === enums_1.RouteType.Flood || routeType === enums_1.RouteType.TransportFlood) {
|
||||
pathDescription = `Historical route taken (${pathHashSize}-byte hashes added as packet floods through network)`;
|
||||
}
|
||||
segments.push({
|
||||
name: 'Path Data',
|
||||
description: pathDescription,
|
||||
startByte: offset,
|
||||
endByte: offset + pathByteLength - 1,
|
||||
value: (0, hex_1.bytesToHex)(bytes.slice(offset, offset + pathByteLength))
|
||||
});
|
||||
}
|
||||
}
|
||||
offset += pathByteLength;
|
||||
// extract payload
|
||||
const payloadBytes = bytes.subarray(offset);
|
||||
const payloadHex = (0, hex_1.bytesToHex)(payloadBytes);
|
||||
if (includeStructure && bytes.length > offset) {
|
||||
segments.push({
|
||||
name: 'Payload',
|
||||
description: `${(0, enum_names_1.getPayloadTypeName)(payloadType)} payload data`,
|
||||
startByte: offset,
|
||||
endByte: bytes.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(bytes.slice(offset))
|
||||
});
|
||||
}
|
||||
// decode payload based on type and optionally get segments in one pass
|
||||
let decodedPayload = null;
|
||||
const payloadSegments = [];
|
||||
if (payloadType === enums_1.PayloadType.Advert) {
|
||||
const result = advert_1.AdvertPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Trace) {
|
||||
const result = trace_1.TracePayloadDecoder.decode(payloadBytes, path, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0 // Payload segments are relative to payload start
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments; // Remove from decoded payload to keep it clean
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.GroupText) {
|
||||
const result = group_text_1.GroupTextPayloadDecoder.decode(payloadBytes, {
|
||||
...options,
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Request) {
|
||||
const result = request_1.RequestPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0 // Payload segments are relative to payload start
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Response) {
|
||||
const result = response_1.ResponsePayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0 // Payload segments are relative to payload start
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.AnonRequest) {
|
||||
const result = anon_request_1.AnonRequestPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Ack) {
|
||||
const result = ack_1.AckPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Path) {
|
||||
decodedPayload = path_1.PathPayloadDecoder.decode(payloadBytes);
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.TextMessage) {
|
||||
const result = text_message_1.TextMessagePayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
else if (payloadType === enums_1.PayloadType.Control) {
|
||||
const result = control_1.ControlPayloadDecoder.decode(payloadBytes, {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
decodedPayload = result;
|
||||
if (result?.segments) {
|
||||
payloadSegments.push(...result.segments);
|
||||
delete result.segments;
|
||||
}
|
||||
}
|
||||
// if no segments were generated and we need structure, show basic payload info
|
||||
if (includeStructure && payloadSegments.length === 0 && bytes.length > offset) {
|
||||
payloadSegments.push({
|
||||
name: `${(0, enum_names_1.getPayloadTypeName)(payloadType)} Payload`,
|
||||
description: `Raw ${(0, enum_names_1.getPayloadTypeName)(payloadType)} payload data (${payloadBytes.length} bytes)`,
|
||||
startByte: 0,
|
||||
endByte: payloadBytes.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payloadBytes)
|
||||
});
|
||||
}
|
||||
// calculate message hash
|
||||
const messageHash = this.calculateMessageHash(bytes, routeType, payloadType, payloadVersion);
|
||||
const packet = {
|
||||
messageHash,
|
||||
routeType,
|
||||
payloadType,
|
||||
payloadVersion,
|
||||
transportCodes,
|
||||
pathLength: pathHopCount,
|
||||
...(pathHashSize > 1 ? { pathHashSize } : {}),
|
||||
path,
|
||||
payload: {
|
||||
raw: payloadHex,
|
||||
decoded: decodedPayload
|
||||
},
|
||||
totalBytes: bytes.length,
|
||||
isValid: true
|
||||
};
|
||||
const structure = {
|
||||
segments,
|
||||
totalBytes: bytes.length,
|
||||
rawHex: hexData.toUpperCase(),
|
||||
messageHash,
|
||||
payload: {
|
||||
segments: payloadSegments,
|
||||
hex: payloadHex,
|
||||
startByte: offset,
|
||||
type: (0, enum_names_1.getPayloadTypeName)(payloadType)
|
||||
}
|
||||
};
|
||||
return { packet, structure };
|
||||
}
|
||||
catch (error) {
|
||||
const errorPacket = {
|
||||
messageHash: '',
|
||||
routeType: enums_1.RouteType.Flood,
|
||||
payloadType: enums_1.PayloadType.RawCustom,
|
||||
payloadVersion: enums_1.PayloadVersion.Version1,
|
||||
pathLength: 0,
|
||||
path: null,
|
||||
payload: { raw: '', decoded: null },
|
||||
totalBytes: bytes.length,
|
||||
isValid: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown decoding error']
|
||||
};
|
||||
const errorStructure = {
|
||||
segments: [],
|
||||
totalBytes: bytes.length,
|
||||
rawHex: hexData.toUpperCase(),
|
||||
messageHash: '',
|
||||
payload: {
|
||||
segments: [],
|
||||
hex: '',
|
||||
startByte: 0,
|
||||
type: 'Unknown'
|
||||
}
|
||||
};
|
||||
return { packet: errorPacket, structure: errorStructure };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Internal unified parsing method with signature verification for advertisements
|
||||
*/
|
||||
static async parseInternalAsync(hexData, includeStructure, options) {
|
||||
// First do the regular parsing
|
||||
const result = this.parseInternal(hexData, includeStructure, options);
|
||||
// If it's an advertisement, verify the signature
|
||||
if (result.packet.payloadType === enums_1.PayloadType.Advert && result.packet.payload.decoded) {
|
||||
try {
|
||||
const advertPayload = result.packet.payload.decoded;
|
||||
const verifiedAdvert = await advert_1.AdvertPayloadDecoder.decodeWithVerification((0, hex_1.hexToBytes)(result.packet.payload.raw), {
|
||||
includeSegments: includeStructure,
|
||||
segmentOffset: 0
|
||||
});
|
||||
if (verifiedAdvert) {
|
||||
// Update the payload with signature verification results
|
||||
result.packet.payload.decoded = verifiedAdvert;
|
||||
// If the advertisement signature is invalid, mark the whole packet as invalid
|
||||
if (!verifiedAdvert.isValid) {
|
||||
result.packet.isValid = false;
|
||||
result.packet.errors = verifiedAdvert.errors || ['Invalid advertisement signature'];
|
||||
}
|
||||
// Update structure segments if needed
|
||||
if (includeStructure && verifiedAdvert.segments) {
|
||||
result.structure.payload.segments = verifiedAdvert.segments;
|
||||
delete verifiedAdvert.segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Signature verification failed:', error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Validate packet format without full decoding
|
||||
*/
|
||||
static validate(hexData) {
|
||||
const bytes = (0, hex_1.hexToBytes)(hexData);
|
||||
const errors = [];
|
||||
if (bytes.length < 2) {
|
||||
errors.push('Packet too short (minimum 2 bytes required)');
|
||||
return { isValid: false, errors };
|
||||
}
|
||||
try {
|
||||
let offset = 1; // Skip header
|
||||
// check transport codes
|
||||
const header = bytes[0];
|
||||
const routeType = header & 0x03;
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
if (bytes.length < offset + 4) {
|
||||
errors.push('Packet too short for transport codes');
|
||||
}
|
||||
offset += 4;
|
||||
}
|
||||
// check path length
|
||||
if (bytes.length < offset + 1) {
|
||||
errors.push('Packet too short for path length');
|
||||
}
|
||||
else {
|
||||
const pathLenByte = bytes[offset];
|
||||
const { hashSize, byteLength } = this.decodePathLenByte(pathLenByte);
|
||||
offset += 1;
|
||||
if (hashSize === 4) {
|
||||
errors.push('Invalid path length byte: reserved hash size (bits 7:6 = 11)');
|
||||
}
|
||||
if (bytes.length < offset + byteLength) {
|
||||
errors.push('Packet too short for path data');
|
||||
}
|
||||
offset += byteLength;
|
||||
}
|
||||
// check if we have payload data
|
||||
if (offset >= bytes.length) {
|
||||
errors.push('No payload data found');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : 'Validation error');
|
||||
}
|
||||
return { isValid: errors.length === 0, errors: errors.length > 0 ? errors : undefined };
|
||||
}
|
||||
/**
|
||||
* Calculate message hash for a packet
|
||||
*/
|
||||
static calculateMessageHash(bytes, routeType, payloadType, payloadVersion) {
|
||||
// for TRACE packets, use the trace tag as hash
|
||||
if (payloadType === enums_1.PayloadType.Trace && bytes.length >= 13) {
|
||||
let offset = 1;
|
||||
// skip transport codes if present
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
offset += 4;
|
||||
}
|
||||
// skip path data (decode path_len byte for multi-byte hops)
|
||||
if (bytes.length > offset) {
|
||||
const { byteLength } = this.decodePathLenByte(bytes[offset]);
|
||||
offset += 1 + byteLength;
|
||||
}
|
||||
// extract trace tag
|
||||
if (bytes.length >= offset + 4) {
|
||||
const traceTag = (bytes[offset]) | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24);
|
||||
return (0, hex_1.numberToHex)(traceTag, 8);
|
||||
}
|
||||
}
|
||||
// for other packets, create hash from constant parts
|
||||
const constantHeader = (payloadType << 2) | (payloadVersion << 6);
|
||||
let offset = 1;
|
||||
// skip transport codes if present
|
||||
if (routeType === enums_1.RouteType.TransportFlood || routeType === enums_1.RouteType.TransportDirect) {
|
||||
offset += 4;
|
||||
}
|
||||
// skip path data (decode path_len byte for multi-byte hops)
|
||||
if (bytes.length > offset) {
|
||||
const { byteLength } = this.decodePathLenByte(bytes[offset]);
|
||||
offset += 1 + byteLength;
|
||||
}
|
||||
const payloadData = bytes.slice(offset);
|
||||
const hashInput = [constantHeader, ...Array.from(payloadData)];
|
||||
// generate hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < hashInput.length; i++) {
|
||||
hash = ((hash << 5) - hash + hashInput[i]) & 0xffffffff;
|
||||
}
|
||||
return (0, hex_1.numberToHex)(hash, 8);
|
||||
}
|
||||
/**
|
||||
* Create a key store for decryption
|
||||
*/
|
||||
static createKeyStore(initialKeys) {
|
||||
return new key_manager_1.MeshCoreKeyStore(initialKeys);
|
||||
}
|
||||
/**
|
||||
* Decode a path_len byte into hash size, hop count, and total byte length.
|
||||
* Firmware reference: Packet.h lines 79-83
|
||||
* Bits 7:6 = hash size selector: (path_len >> 6) + 1 = 1, 2, or 3 bytes per hop
|
||||
* Bits 5:0 = hop count (0-63)
|
||||
*/
|
||||
static decodePathLenByte(pathLenByte) {
|
||||
const hashSize = (pathLenByte >> 6) + 1;
|
||||
const hopCount = pathLenByte & 63;
|
||||
return { hashSize, hopCount, byteLength: hopCount * hashSize };
|
||||
}
|
||||
}
|
||||
exports.MeshCorePacketDecoder = MeshCorePacketDecoder;
|
||||
//# sourceMappingURL=packet-decoder.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/packet-decoder.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
11
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts
vendored
Normal file
11
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AckPayload } from '../../types/payloads';
|
||||
import { PayloadSegment } from '../../types/packet';
|
||||
export declare class AckPayloadDecoder {
|
||||
static decode(payload: Uint8Array, options?: {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): AckPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
} | null;
|
||||
}
|
||||
//# sourceMappingURL=ack.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ack.d.ts","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/ack.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAIpD,qBAAa,iBAAiB;IAC5B,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,GAAG,IAAI;CA2EzJ"}
|
||||
78
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.js
vendored
Normal file
78
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.js
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AckPayloadDecoder = void 0;
|
||||
const enums_1 = require("../../types/enums");
|
||||
const hex_1 = require("../../utils/hex");
|
||||
class AckPayloadDecoder {
|
||||
static decode(payload, options) {
|
||||
try {
|
||||
// Based on MeshCore payloads.md - Ack payload structure:
|
||||
// - checksum (4 bytes) - CRC checksum of message timestamp, text, and sender pubkey
|
||||
if (payload.length < 4) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Ack,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: ['Ack payload too short (minimum 4 bytes for checksum)'],
|
||||
checksum: ''
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid Ack Data',
|
||||
description: 'Ack payload too short (minimum 4 bytes required for checksum)',
|
||||
startByte: options.segmentOffset || 0,
|
||||
endByte: (options.segmentOffset || 0) + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload)
|
||||
}];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const segments = [];
|
||||
const segmentOffset = options?.segmentOffset || 0;
|
||||
// parse checksum (4 bytes as hex)
|
||||
const checksum = (0, hex_1.bytesToHex)(payload.subarray(0, 4));
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Checksum',
|
||||
description: `CRC checksum of message timestamp, text, and sender pubkey: 0x${checksum}`,
|
||||
startByte: segmentOffset,
|
||||
endByte: segmentOffset + 3,
|
||||
value: checksum
|
||||
});
|
||||
}
|
||||
// any additional data (if present)
|
||||
if (options?.includeSegments && payload.length > 4) {
|
||||
segments.push({
|
||||
name: 'Additional Data',
|
||||
description: 'Extra data in Ack payload',
|
||||
startByte: segmentOffset + 4,
|
||||
endByte: segmentOffset + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload.subarray(4))
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Ack,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: true,
|
||||
checksum
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = segments;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
type: enums_1.PayloadType.Ack,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: [error instanceof Error ? error.message : 'Failed to decode Ack payload'],
|
||||
checksum: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AckPayloadDecoder = AckPayloadDecoder;
|
||||
//# sourceMappingURL=ack.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/ack.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ack.js","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/ack.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAId,6CAAgE;AAChE,yCAA6C;AAE7C,MAAa,iBAAiB;IAC5B,MAAM,CAAC,MAAM,CAAC,OAAmB,EAAE,OAA+D;QAChG,IAAI,CAAC;YACH,yDAAyD;YACzD,oFAAoF;YAEpF,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACvB,MAAM,MAAM,GAAiD;oBAC3D,IAAI,EAAE,mBAAW,CAAC,GAAG;oBACrB,OAAO,EAAE,sBAAc,CAAC,QAAQ;oBAChC,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,CAAC,sDAAsD,CAAC;oBAChE,QAAQ,EAAE,EAAE;iBACb,CAAC;gBAEF,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;oBAC7B,MAAM,CAAC,QAAQ,GAAG,CAAC;4BACjB,IAAI,EAAE,kBAAkB;4BACxB,WAAW,EAAE,+DAA+D;4BAC5E,SAAS,EAAE,OAAO,CAAC,aAAa,IAAI,CAAC;4BACrC,OAAO,EAAE,CAAC,OAAO,CAAC,aAAa,IAAI,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC;4BAC1D,KAAK,EAAE,IAAA,gBAAU,EAAC,OAAO,CAAC;yBAC3B,CAAC,CAAC;gBACL,CAAC;gBAED,OAAO,MAAM,CAAC;YAChB,CAAC;YAED,MAAM,QAAQ,GAAqB,EAAE,CAAC;YACtC,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,CAAC,CAAC;YAElD,kCAAkC;YAClC,MAAM,QAAQ,GAAG,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;YACpD,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,UAAU;oBAChB,WAAW,EAAE,iEAAiE,QAAQ,EAAE;oBACxF,SAAS,EAAE,aAAa;oBACxB,OAAO,EAAE,aAAa,GAAG,CAAC;oBAC1B,KAAK,EAAE,QAAQ;iBAChB,CAAC,CAAC;YACL,CAAC;YAED,mCAAmC;YACnC,IAAI,OAAO,EAAE,eAAe,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACnD,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,iBAAiB;oBACvB,WAAW,EAAE,2BAA2B;oBACxC,SAAS,EAAE,aAAa,GAAG,CAAC;oBAC5B,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC;oBAC3C,KAAK,EAAE,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;iBACvC,CAAC,CAAC;YACL,CAAC;YAED,MAAM,MAAM,GAAiD;gBAC3D,IAAI,EAAE,mBAAW,CAAC,GAAG;gBACrB,OAAO,EAAE,sBAAc,CAAC,QAAQ;gBAChC,OAAO,EAAE,IAAI;gBACb,QAAQ;aACT,CAAC;YAEF,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,MAAM,CAAC,QAAQ,GAAG,QAAQ,CAAC;YAC7B,CAAC;YAED,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO;gBACL,IAAI,EAAE,mBAAW,CAAC,GAAG;gBACrB,OAAO,EAAE,sBAAc,CAAC,QAAQ;gBAChC,OAAO,EAAE,KAAK;gBACd,MAAM,EAAE,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,8BAA8B,CAAC;gBACjF,QAAQ,EAAE,EAAE;aACb,CAAC;QACJ,CAAC;IACH,CAAC;CACF;AA5ED,8CA4EC"}
|
||||
24
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.d.ts
vendored
Normal file
24
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AdvertPayload } from '../../types/payloads';
|
||||
import { PayloadSegment } from '../../types/packet';
|
||||
export declare class AdvertPayloadDecoder {
|
||||
static decode(payload: Uint8Array, options?: {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): AdvertPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
} | null;
|
||||
/**
|
||||
* Decode advertisement payload with signature verification
|
||||
*/
|
||||
static decodeWithVerification(payload: Uint8Array, options?: {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): Promise<AdvertPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
} | null>;
|
||||
private static parseDeviceRole;
|
||||
private static readUint32LE;
|
||||
private static readInt32LE;
|
||||
private static sanitizeControlCharacters;
|
||||
}
|
||||
//# sourceMappingURL=advert.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"advert.d.ts","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/advert.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAMpD,qBAAa,oBAAoB;IAC/B,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,aAAa,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,GAAG,IAAI;IAuL3J;;OAEG;WACU,sBAAsB,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,aAAa,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,GAAG,IAAI,CAAC;IA4C1L,OAAO,CAAC,MAAM,CAAC,eAAe;IAW9B,OAAO,CAAC,MAAM,CAAC,YAAY;IAO3B,OAAO,CAAC,MAAM,CAAC,WAAW;IAM1B,OAAO,CAAC,MAAM,CAAC,yBAAyB;CAKzC"}
|
||||
244
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.js
vendored
Normal file
244
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.js
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AdvertPayloadDecoder = void 0;
|
||||
const enums_1 = require("../../types/enums");
|
||||
const hex_1 = require("../../utils/hex");
|
||||
const enum_names_1 = require("../../utils/enum-names");
|
||||
const ed25519_verifier_1 = require("../../crypto/ed25519-verifier");
|
||||
class AdvertPayloadDecoder {
|
||||
static decode(payload, options) {
|
||||
try {
|
||||
// start of appdata section: public_key(32) + timestamp(4) + signature(64) + flags(1) = 101 bytes
|
||||
if (payload.length < 101) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Advert,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: ['Advertisement payload too short'],
|
||||
publicKey: '',
|
||||
timestamp: 0,
|
||||
signature: '',
|
||||
appData: {
|
||||
flags: 0,
|
||||
deviceRole: enums_1.DeviceRole.ChatNode,
|
||||
hasLocation: false,
|
||||
hasName: false
|
||||
}
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid Advert Data',
|
||||
description: 'Advert payload too short (minimum 101 bytes required)',
|
||||
startByte: options.segmentOffset || 0,
|
||||
endByte: (options.segmentOffset || 0) + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload)
|
||||
}];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const segments = [];
|
||||
const segmentOffset = options?.segmentOffset || 0;
|
||||
let currentOffset = 0;
|
||||
// parse advertisement structure from payloads.md
|
||||
const publicKey = (0, hex_1.bytesToHex)(payload.subarray(currentOffset, currentOffset + 32));
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Public Key',
|
||||
description: 'Ed25519 public key',
|
||||
startByte: segmentOffset + currentOffset,
|
||||
endByte: segmentOffset + currentOffset + 31,
|
||||
value: publicKey
|
||||
});
|
||||
}
|
||||
currentOffset += 32;
|
||||
const timestamp = this.readUint32LE(payload, currentOffset);
|
||||
if (options?.includeSegments) {
|
||||
const timestampDate = new Date(timestamp * 1000);
|
||||
segments.push({
|
||||
name: 'Timestamp',
|
||||
description: `${timestamp} (${timestampDate.toISOString().slice(0, 19)}Z)`,
|
||||
startByte: segmentOffset + currentOffset,
|
||||
endByte: segmentOffset + currentOffset + 3,
|
||||
value: (0, hex_1.bytesToHex)(payload.subarray(currentOffset, currentOffset + 4))
|
||||
});
|
||||
}
|
||||
currentOffset += 4;
|
||||
const signature = (0, hex_1.bytesToHex)(payload.subarray(currentOffset, currentOffset + 64));
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Signature',
|
||||
description: 'Ed25519 signature',
|
||||
startByte: segmentOffset + currentOffset,
|
||||
endByte: segmentOffset + currentOffset + 63,
|
||||
value: signature
|
||||
});
|
||||
}
|
||||
currentOffset += 64;
|
||||
const flags = payload[currentOffset];
|
||||
if (options?.includeSegments) {
|
||||
const binaryStr = flags.toString(2).padStart(8, '0');
|
||||
const deviceRole = this.parseDeviceRole(flags);
|
||||
const roleName = (0, enum_names_1.getDeviceRoleName)(deviceRole);
|
||||
const flagDesc = ` | Bits 0-3 (Role): ${roleName} | Bit 4 (Location): ${!!(flags & enums_1.AdvertFlags.HasLocation) ? 'Yes' : 'No'} | Bit 7 (Name): ${!!(flags & enums_1.AdvertFlags.HasName) ? 'Yes' : 'No'}`;
|
||||
segments.push({
|
||||
name: 'App Flags',
|
||||
description: `Binary: ${binaryStr}${flagDesc}`,
|
||||
startByte: segmentOffset + currentOffset,
|
||||
endByte: segmentOffset + currentOffset,
|
||||
value: flags.toString(16).padStart(2, '0').toUpperCase()
|
||||
});
|
||||
}
|
||||
currentOffset += 1;
|
||||
const advert = {
|
||||
type: enums_1.PayloadType.Advert,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: true,
|
||||
publicKey,
|
||||
timestamp,
|
||||
signature,
|
||||
appData: {
|
||||
flags,
|
||||
deviceRole: this.parseDeviceRole(flags),
|
||||
hasLocation: !!(flags & enums_1.AdvertFlags.HasLocation),
|
||||
hasName: !!(flags & enums_1.AdvertFlags.HasName)
|
||||
}
|
||||
};
|
||||
let offset = currentOffset;
|
||||
// location data (if HasLocation flag is set)
|
||||
if (flags & enums_1.AdvertFlags.HasLocation && payload.length >= offset + 8) {
|
||||
const lat = this.readInt32LE(payload, offset) / 1000000;
|
||||
const lon = this.readInt32LE(payload, offset + 4) / 1000000;
|
||||
advert.appData.location = {
|
||||
latitude: Math.round(lat * 1000000) / 1000000, // Keep precision
|
||||
longitude: Math.round(lon * 1000000) / 1000000
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Latitude',
|
||||
description: `${lat}° (${lat})`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 3,
|
||||
value: (0, hex_1.bytesToHex)(payload.subarray(offset, offset + 4))
|
||||
});
|
||||
segments.push({
|
||||
name: 'Longitude',
|
||||
description: `${lon}° (${lon})`,
|
||||
startByte: segmentOffset + offset + 4,
|
||||
endByte: segmentOffset + offset + 7,
|
||||
value: (0, hex_1.bytesToHex)(payload.subarray(offset + 4, offset + 8))
|
||||
});
|
||||
}
|
||||
offset += 8;
|
||||
}
|
||||
// skip feature fields for now (HasFeature1, HasFeature2)
|
||||
if (flags & enums_1.AdvertFlags.HasFeature1)
|
||||
offset += 2;
|
||||
if (flags & enums_1.AdvertFlags.HasFeature2)
|
||||
offset += 2;
|
||||
// name data (if HasName flag is set)
|
||||
if (flags & enums_1.AdvertFlags.HasName && payload.length > offset) {
|
||||
const nameBytes = payload.subarray(offset);
|
||||
const rawName = new TextDecoder('utf-8').decode(nameBytes).replace(/\0.*$/, '');
|
||||
advert.appData.name = this.sanitizeControlCharacters(rawName) || rawName;
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Node Name',
|
||||
description: `Node name: "${advert.appData.name}"`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(nameBytes)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options?.includeSegments) {
|
||||
advert.segments = segments;
|
||||
}
|
||||
return advert;
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
type: enums_1.PayloadType.Advert,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: [error instanceof Error ? error.message : 'Failed to decode advertisement payload'],
|
||||
publicKey: '',
|
||||
timestamp: 0,
|
||||
signature: '',
|
||||
appData: {
|
||||
flags: 0,
|
||||
deviceRole: enums_1.DeviceRole.ChatNode,
|
||||
hasLocation: false,
|
||||
hasName: false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Decode advertisement payload with signature verification
|
||||
*/
|
||||
static async decodeWithVerification(payload, options) {
|
||||
// First decode normally
|
||||
const advert = this.decode(payload, options);
|
||||
if (!advert || !advert.isValid) {
|
||||
return advert;
|
||||
}
|
||||
// Perform signature verification
|
||||
try {
|
||||
// Extract app_data from the payload (everything after public_key + timestamp + signature)
|
||||
const appDataStart = 32 + 4 + 64; // public_key + timestamp + signature
|
||||
const appDataBytes = payload.subarray(appDataStart);
|
||||
const appDataHex = (0, hex_1.bytesToHex)(appDataBytes);
|
||||
const signatureValid = await ed25519_verifier_1.Ed25519SignatureVerifier.verifyAdvertisementSignature(advert.publicKey, advert.signature, advert.timestamp, appDataHex);
|
||||
advert.signatureValid = signatureValid;
|
||||
if (!signatureValid) {
|
||||
advert.signatureError = 'Ed25519 signature verification failed';
|
||||
advert.isValid = false;
|
||||
if (!advert.errors) {
|
||||
advert.errors = [];
|
||||
}
|
||||
advert.errors.push('Invalid Ed25519 signature');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
advert.signatureValid = false;
|
||||
advert.signatureError = error instanceof Error ? error.message : 'Signature verification error';
|
||||
advert.isValid = false;
|
||||
if (!advert.errors) {
|
||||
advert.errors = [];
|
||||
}
|
||||
advert.errors.push('Signature verification failed: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
||||
}
|
||||
return advert;
|
||||
}
|
||||
static parseDeviceRole(flags) {
|
||||
const roleValue = flags & 0x0F;
|
||||
switch (roleValue) {
|
||||
case 0x01: return enums_1.DeviceRole.ChatNode;
|
||||
case 0x02: return enums_1.DeviceRole.Repeater;
|
||||
case 0x03: return enums_1.DeviceRole.RoomServer;
|
||||
case 0x04: return enums_1.DeviceRole.Sensor;
|
||||
default: return enums_1.DeviceRole.ChatNode;
|
||||
}
|
||||
}
|
||||
static readUint32LE(buffer, offset) {
|
||||
return buffer[offset] |
|
||||
(buffer[offset + 1] << 8) |
|
||||
(buffer[offset + 2] << 16) |
|
||||
(buffer[offset + 3] << 24);
|
||||
}
|
||||
static readInt32LE(buffer, offset) {
|
||||
const value = this.readUint32LE(buffer, offset);
|
||||
// convert unsigned to signed
|
||||
return value > 0x7FFFFFFF ? value - 0x100000000 : value;
|
||||
}
|
||||
static sanitizeControlCharacters(value) {
|
||||
if (!value)
|
||||
return null;
|
||||
const sanitized = value.trim().replace(/[\x00-\x1F\x7F]/g, '');
|
||||
return sanitized || null;
|
||||
}
|
||||
}
|
||||
exports.AdvertPayloadDecoder = AdvertPayloadDecoder;
|
||||
//# sourceMappingURL=advert.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/advert.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
11
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.d.ts
vendored
Normal file
11
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AnonRequestPayload } from '../../types/payloads';
|
||||
import { PayloadSegment } from '../../types/packet';
|
||||
export declare class AnonRequestPayloadDecoder {
|
||||
static decode(payload: Uint8Array, options?: {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): AnonRequestPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
} | null;
|
||||
}
|
||||
//# sourceMappingURL=anon-request.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"anon-request.d.ts","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/anon-request.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAIpD,qBAAa,yBAAyB;IACpC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,kBAAkB,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,GAAG,IAAI;CA8HjK"}
|
||||
123
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.js
vendored
Normal file
123
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.js
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AnonRequestPayloadDecoder = void 0;
|
||||
const enums_1 = require("../../types/enums");
|
||||
const hex_1 = require("../../utils/hex");
|
||||
class AnonRequestPayloadDecoder {
|
||||
static decode(payload, options) {
|
||||
try {
|
||||
// Based on MeshCore payloads.md - AnonRequest payload structure:
|
||||
// - destination_hash (1 byte)
|
||||
// - sender_public_key (32 bytes)
|
||||
// - cipher_mac (2 bytes)
|
||||
// - ciphertext (rest of payload)
|
||||
if (payload.length < 35) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.AnonRequest,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: ['AnonRequest payload too short (minimum 35 bytes: dest + public key + MAC)'],
|
||||
destinationHash: '',
|
||||
senderPublicKey: '',
|
||||
cipherMac: '',
|
||||
ciphertext: '',
|
||||
ciphertextLength: 0
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid AnonRequest Data',
|
||||
description: 'AnonRequest payload too short (minimum 35 bytes required: 1 for dest hash + 32 for public key + 2 for MAC)',
|
||||
startByte: options.segmentOffset || 0,
|
||||
endByte: (options.segmentOffset || 0) + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload)
|
||||
}];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const segments = [];
|
||||
const segmentOffset = options?.segmentOffset || 0;
|
||||
let offset = 0;
|
||||
// Parse destination hash (1 byte)
|
||||
const destinationHash = (0, hex_1.byteToHex)(payload[0]);
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Destination Hash',
|
||||
description: `First byte of destination node public key: 0x${destinationHash}`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset,
|
||||
value: destinationHash
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
// Parse sender public key (32 bytes)
|
||||
const senderPublicKey = (0, hex_1.bytesToHex)(payload.subarray(1, 33));
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Sender Public Key',
|
||||
description: `Ed25519 public key of the sender (32 bytes)`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 31,
|
||||
value: senderPublicKey
|
||||
});
|
||||
}
|
||||
offset += 32;
|
||||
// Parse cipher MAC (2 bytes)
|
||||
const cipherMac = (0, hex_1.bytesToHex)(payload.subarray(33, 35));
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Cipher MAC',
|
||||
description: `MAC for encrypted data verification (2 bytes)`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 1,
|
||||
value: cipherMac
|
||||
});
|
||||
}
|
||||
offset += 2;
|
||||
// Parse ciphertext (remaining bytes)
|
||||
const ciphertext = (0, hex_1.bytesToHex)(payload.subarray(35));
|
||||
if (options?.includeSegments && payload.length > 35) {
|
||||
segments.push({
|
||||
name: 'Ciphertext',
|
||||
description: `Encrypted message data (${payload.length - 35} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
• Sync Timestamp (4 bytes) - room server only, sender's "sync messages SINCE x" timestamp
|
||||
• Password (remaining bytes) - password for repeater/room`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + payload.length - 1,
|
||||
value: ciphertext
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
type: enums_1.PayloadType.AnonRequest,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: true,
|
||||
destinationHash,
|
||||
senderPublicKey,
|
||||
cipherMac,
|
||||
ciphertext,
|
||||
ciphertextLength: payload.length - 35
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = segments;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
type: enums_1.PayloadType.AnonRequest,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: [error instanceof Error ? error.message : 'Failed to decode AnonRequest payload'],
|
||||
destinationHash: '',
|
||||
senderPublicKey: '',
|
||||
cipherMac: '',
|
||||
ciphertext: '',
|
||||
ciphertextLength: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.AnonRequestPayloadDecoder = AnonRequestPayloadDecoder;
|
||||
//# sourceMappingURL=anon-request.js.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.js.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/anon-request.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"anon-request.js","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/anon-request.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAId,6CAAgE;AAChE,yCAAwD;AAExD,MAAa,yBAAyB;IACpC,MAAM,CAAC,MAAM,CAAC,OAAmB,EAAE,OAA+D;QAChG,IAAI,CAAC;YACH,iEAAiE;YACjE,8BAA8B;YAC9B,iCAAiC;YACjC,yBAAyB;YACzB,iCAAiC;YAEjC,IAAI,OAAO,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;gBACxB,MAAM,MAAM,GAAyD;oBACnE,IAAI,EAAE,mBAAW,CAAC,WAAW;oBAC7B,OAAO,EAAE,sBAAc,CAAC,QAAQ;oBAChC,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,CAAC,2EAA2E,CAAC;oBACrF,eAAe,EAAE,EAAE;oBACnB,eAAe,EAAE,EAAE;oBACnB,SAAS,EAAE,EAAE;oBACb,UAAU,EAAE,EAAE;oBACd,gBAAgB,EAAE,CAAC;iBACpB,CAAC;gBAEF,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;oBAC7B,MAAM,CAAC,QAAQ,GAAG,CAAC;4BACjB,IAAI,EAAE,0BAA0B;4BAChC,WAAW,EAAE,4GAA4G;4BACzH,SAAS,EAAE,OAAO,CAAC,aAAa,IAAI,CAAC;4BACrC,OAAO,EAAE,CAAC,OAAO,CAAC,aAAa,IAAI,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC;4BAC1D,KAAK,EAAE,IAAA,gBAAU,EAAC,OAAO,CAAC;yBAC3B,CAAC,CAAC;gBACL,CAAC;gBAED,OAAO,MAAM,CAAC;YAChB,CAAC;YAED,MAAM,QAAQ,GAAqB,EAAE,CAAC;YACtC,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,CAAC,CAAC;YAClD,IAAI,MAAM,GAAG,CAAC,CAAC;YAEf,kCAAkC;YAClC,MAAM,eAAe,GAAG,IAAA,eAAS,EAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;YAE9C,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,kBAAkB;oBACxB,WAAW,EAAE,gDAAgD,eAAe,EAAE;oBAC9E,SAAS,EAAE,aAAa,GAAG,MAAM;oBACjC,OAAO,EAAE,aAAa,GAAG,MAAM;oBAC/B,KAAK,EAAE,eAAe;iBACvB,CAAC,CAAC;YACL,CAAC;YACD,MAAM,IAAI,CAAC,CAAC;YAEZ,qCAAqC;YACrC,MAAM,eAAe,GAAG,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;YAE5D,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,mBAAmB;oBACzB,WAAW,EAAE,6CAA6C;oBAC1D,SAAS,EAAE,aAAa,GAAG,MAAM;oBACjC,OAAO,EAAE,aAAa,GAAG,MAAM,GAAG,EAAE;oBACpC,KAAK,EAAE,eAAe;iBACvB,CAAC,CAAC;YACL,CAAC;YACD,MAAM,IAAI,EAAE,CAAC;YAEb,6BAA6B;YAC7B,MAAM,SAAS,GAAG,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;YAEvD,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,YAAY;oBAClB,WAAW,EAAE,+CAA+C;oBAC5D,SAAS,EAAE,aAAa,GAAG,MAAM;oBACjC,OAAO,EAAE,aAAa,GAAG,MAAM,GAAG,CAAC;oBACnC,KAAK,EAAE,SAAS;iBACjB,CAAC,CAAC;YACL,CAAC;YACD,MAAM,IAAI,CAAC,CAAC;YAEZ,qCAAqC;YACrC,MAAM,UAAU,GAAG,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC;YAEpD,IAAI,OAAO,EAAE,eAAe,IAAI,OAAO,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;gBACpD,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,YAAY;oBAClB,WAAW,EAAE,2BAA2B,OAAO,CAAC,MAAM,GAAG,EAAE;;;0DAGX;oBAChD,SAAS,EAAE,aAAa,GAAG,MAAM;oBACjC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC;oBAC3C,KAAK,EAAE,UAAU;iBAClB,CAAC,CAAC;YACL,CAAC;YAED,MAAM,MAAM,GAAyD;gBACnE,IAAI,EAAE,mBAAW,CAAC,WAAW;gBAC7B,OAAO,EAAE,sBAAc,CAAC,QAAQ;gBAChC,OAAO,EAAE,IAAI;gBACb,eAAe;gBACf,eAAe;gBACf,SAAS;gBACT,UAAU;gBACV,gBAAgB,EAAE,OAAO,CAAC,MAAM,GAAG,EAAE;aACtC,CAAC;YAEF,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,MAAM,CAAC,QAAQ,GAAG,QAAQ,CAAC;YAC7B,CAAC;YAED,OAAO,MAAM,CAAC;QAChB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO;gBACL,IAAI,EAAE,mBAAW,CAAC,WAAW;gBAC7B,OAAO,EAAE,sBAAc,CAAC,QAAQ;gBAChC,OAAO,EAAE,KAAK;gBACd,MAAM,EAAE,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,sCAAsC,CAAC;gBACzF,eAAe,EAAE,EAAE;gBACnB,eAAe,EAAE,EAAE;gBACnB,SAAS,EAAE,EAAE;gBACb,UAAU,EAAE,EAAE;gBACd,gBAAgB,EAAE,CAAC;aACpB,CAAC;QACJ,CAAC;IACH,CAAC;CACF;AA/HD,8DA+HC"}
|
||||
16
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/control.d.ts
vendored
Normal file
16
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/control.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ControlPayload } from '../../types/payloads';
|
||||
import { PayloadSegment } from '../../types/packet';
|
||||
export declare class ControlPayloadDecoder {
|
||||
static decode(payload: Uint8Array, options?: {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): (ControlPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
}) | null;
|
||||
private static decodeDiscoverReq;
|
||||
private static decodeDiscoverResp;
|
||||
private static parseTypeFilter;
|
||||
private static createErrorPayload;
|
||||
private static readUint32LE;
|
||||
}
|
||||
//# sourceMappingURL=control.d.ts.map
|
||||
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/control.d.ts.map
vendored
Normal file
1
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/control.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/control.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAyD,MAAM,sBAAsB,CAAC;AAC7G,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAKpD,qBAAa,qBAAqB;IAChC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,CAAC,cAAc,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC,GAAG,IAAI;IAsB9J,OAAO,CAAC,MAAM,CAAC,iBAAiB;IAoHhC,OAAO,CAAC,MAAM,CAAC,kBAAkB;IAwHjC,OAAO,CAAC,MAAM,CAAC,eAAe;IAS9B,OAAO,CAAC,MAAM,CAAC,kBAAkB;IAgCjC,OAAO,CAAC,MAAM,CAAC,YAAY;CAM5B"}
|
||||
279
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/control.js
vendored
Normal file
279
frontend/lib/meshcore-decoder/dist/decoder/payload-decoders/control.js
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ControlPayloadDecoder = void 0;
|
||||
const enums_1 = require("../../types/enums");
|
||||
const hex_1 = require("../../utils/hex");
|
||||
const enum_names_1 = require("../../utils/enum-names");
|
||||
class ControlPayloadDecoder {
|
||||
static decode(payload, options) {
|
||||
try {
|
||||
if (payload.length < 1) {
|
||||
return this.createErrorPayload('Control payload too short (minimum 1 byte required)', payload, options);
|
||||
}
|
||||
const rawFlags = payload[0];
|
||||
const subType = rawFlags & 0xF0; // upper 4 bits
|
||||
switch (subType) {
|
||||
case enums_1.ControlSubType.NodeDiscoverReq:
|
||||
return this.decodeDiscoverReq(payload, options);
|
||||
case enums_1.ControlSubType.NodeDiscoverResp:
|
||||
return this.decodeDiscoverResp(payload, options);
|
||||
default:
|
||||
return this.createErrorPayload(`Unknown control sub-type: 0x${subType.toString(16).padStart(2, '0')}`, payload, options);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return this.createErrorPayload(error instanceof Error ? error.message : 'Failed to decode control payload', payload, options);
|
||||
}
|
||||
}
|
||||
static decodeDiscoverReq(payload, options) {
|
||||
const segments = [];
|
||||
const segmentOffset = options?.segmentOffset ?? 0;
|
||||
// Minimum size: flags(1) + type_filter(1) + tag(4) = 6 bytes
|
||||
if (payload.length < 6) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Control,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: ['DISCOVER_REQ payload too short (minimum 6 bytes required)'],
|
||||
subType: enums_1.ControlSubType.NodeDiscoverReq,
|
||||
rawFlags: payload[0],
|
||||
prefixOnly: false,
|
||||
typeFilter: 0,
|
||||
typeFilterNames: [],
|
||||
tag: 0,
|
||||
since: 0
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid DISCOVER_REQ Data',
|
||||
description: 'DISCOVER_REQ payload too short (minimum 6 bytes required)',
|
||||
startByte: segmentOffset,
|
||||
endByte: segmentOffset + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload)
|
||||
}];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let offset = 0;
|
||||
// Byte 0: flags - upper 4 bits is sub_type (0x8), lowest bit is prefix_only
|
||||
const rawFlags = payload[offset];
|
||||
const prefixOnly = (rawFlags & 0x01) !== 0;
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Flags',
|
||||
description: `Sub-type: DISCOVER_REQ (0x8) | Prefix Only: ${prefixOnly}`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset,
|
||||
value: rawFlags.toString(16).padStart(2, '0').toUpperCase()
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
// Byte 1: type_filter - bit for each ADV_TYPE_*
|
||||
const typeFilter = payload[offset];
|
||||
const typeFilterNames = this.parseTypeFilter(typeFilter);
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Type Filter',
|
||||
description: `Filter mask: 0b${typeFilter.toString(2).padStart(8, '0')} | Types: ${typeFilterNames.length > 0 ? typeFilterNames.join(', ') : 'None'}`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset,
|
||||
value: typeFilter.toString(16).padStart(2, '0').toUpperCase()
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
// Bytes 2-5: tag (uint32, little endian)
|
||||
const tag = this.readUint32LE(payload, offset);
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Tag',
|
||||
description: `Random tag for response matching: 0x${tag.toString(16).padStart(8, '0')}`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 3,
|
||||
value: (0, hex_1.bytesToHex)(payload.slice(offset, offset + 4))
|
||||
});
|
||||
}
|
||||
offset += 4;
|
||||
// Optional: Bytes 6-9: since (uint32, little endian) - epoch timestamp
|
||||
let since = 0;
|
||||
if (payload.length >= offset + 4) {
|
||||
since = this.readUint32LE(payload, offset);
|
||||
if (options?.includeSegments) {
|
||||
const sinceDate = since > 0 ? new Date(since * 1000).toISOString().slice(0, 19) + 'Z' : 'N/A';
|
||||
segments.push({
|
||||
name: 'Since',
|
||||
description: `Filter timestamp: ${since} (${sinceDate})`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 3,
|
||||
value: (0, hex_1.bytesToHex)(payload.slice(offset, offset + 4))
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Control,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: true,
|
||||
subType: enums_1.ControlSubType.NodeDiscoverReq,
|
||||
rawFlags,
|
||||
prefixOnly,
|
||||
typeFilter,
|
||||
typeFilterNames,
|
||||
tag,
|
||||
since
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = segments;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static decodeDiscoverResp(payload, options) {
|
||||
const segments = [];
|
||||
const segmentOffset = options?.segmentOffset ?? 0;
|
||||
// Minimum size: flags(1) + snr(1) + tag(4) + pubkey(8 for prefix) = 14 bytes
|
||||
if (payload.length < 14) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Control,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: ['DISCOVER_RESP payload too short (minimum 14 bytes required)'],
|
||||
subType: enums_1.ControlSubType.NodeDiscoverResp,
|
||||
rawFlags: payload.length > 0 ? payload[0] : 0,
|
||||
nodeType: enums_1.DeviceRole.Unknown,
|
||||
nodeTypeName: 'Unknown',
|
||||
snr: 0,
|
||||
tag: 0,
|
||||
publicKey: '',
|
||||
publicKeyLength: 0
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid DISCOVER_RESP Data',
|
||||
description: 'DISCOVER_RESP payload too short (minimum 14 bytes required)',
|
||||
startByte: segmentOffset,
|
||||
endByte: segmentOffset + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload)
|
||||
}];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let offset = 0;
|
||||
// Byte 0: flags - upper 4 bits is sub_type (0x9), lower 4 bits is node_type
|
||||
const rawFlags = payload[offset];
|
||||
const nodeType = (rawFlags & 0x0F);
|
||||
const nodeTypeName = (0, enum_names_1.getDeviceRoleName)(nodeType);
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Flags',
|
||||
description: `Sub-type: DISCOVER_RESP (0x9) | Node Type: ${nodeTypeName}`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset,
|
||||
value: rawFlags.toString(16).padStart(2, '0').toUpperCase()
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
// Byte 1: snr (signed int8, represents SNR * 4)
|
||||
const snrRaw = payload[offset];
|
||||
const snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
|
||||
const snr = snrSigned / 4.0;
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'SNR',
|
||||
description: `Inbound SNR: ${snr.toFixed(2)} dB (raw: ${snrRaw}, signed: ${snrSigned})`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset,
|
||||
value: snrRaw.toString(16).padStart(2, '0').toUpperCase()
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
// Bytes 2-5: tag (uint32, little endian) - reflected from request
|
||||
const tag = this.readUint32LE(payload, offset);
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Tag',
|
||||
description: `Reflected tag from request: 0x${tag.toString(16).padStart(8, '0')}`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 3,
|
||||
value: (0, hex_1.bytesToHex)(payload.slice(offset, offset + 4))
|
||||
});
|
||||
}
|
||||
offset += 4;
|
||||
// Remaining bytes: public key (8 bytes for prefix, 32 bytes for full)
|
||||
const remainingBytes = payload.length - offset;
|
||||
const publicKeyLength = remainingBytes;
|
||||
const publicKeyBytes = payload.slice(offset, offset + publicKeyLength);
|
||||
const publicKey = (0, hex_1.bytesToHex)(publicKeyBytes);
|
||||
if (options?.includeSegments) {
|
||||
const keyType = publicKeyLength === 32 ? 'Full Public Key' : 'Public Key Prefix';
|
||||
segments.push({
|
||||
name: keyType,
|
||||
description: `${keyType} (${publicKeyLength} bytes)`,
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + publicKeyLength - 1,
|
||||
value: publicKey
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Control,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: true,
|
||||
subType: enums_1.ControlSubType.NodeDiscoverResp,
|
||||
rawFlags,
|
||||
nodeType,
|
||||
nodeTypeName,
|
||||
snr,
|
||||
tag,
|
||||
publicKey,
|
||||
publicKeyLength
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = segments;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static parseTypeFilter(filter) {
|
||||
const types = [];
|
||||
if (filter & (1 << enums_1.DeviceRole.ChatNode))
|
||||
types.push('Chat');
|
||||
if (filter & (1 << enums_1.DeviceRole.Repeater))
|
||||
types.push('Repeater');
|
||||
if (filter & (1 << enums_1.DeviceRole.RoomServer))
|
||||
types.push('Room');
|
||||
if (filter & (1 << enums_1.DeviceRole.Sensor))
|
||||
types.push('Sensor');
|
||||
return types;
|
||||
}
|
||||
static createErrorPayload(error, payload, options) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.Control,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: [error],
|
||||
subType: enums_1.ControlSubType.NodeDiscoverReq,
|
||||
rawFlags: payload.length > 0 ? payload[0] : 0,
|
||||
prefixOnly: false,
|
||||
typeFilter: 0,
|
||||
typeFilterNames: [],
|
||||
tag: 0,
|
||||
since: 0
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid Control Data',
|
||||
description: error,
|
||||
startByte: options.segmentOffset ?? 0,
|
||||
endByte: (options.segmentOffset ?? 0) + payload.length - 1,
|
||||
value: (0, hex_1.bytesToHex)(payload)
|
||||
}];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static readUint32LE(buffer, offset) {
|
||||
return (buffer[offset] |
|
||||
(buffer[offset + 1] << 8) |
|
||||
(buffer[offset + 2] << 16) |
|
||||
(buffer[offset + 3] << 24)) >>> 0; // >>> 0 to ensure unsigned
|
||||
}
|
||||
}
|
||||
exports.ControlPayloadDecoder = ControlPayloadDecoder;
|
||||
//# sourceMappingURL=control.js.map
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user