mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d00bc68a83 |
@@ -29,7 +29,6 @@ frontend/src/test/
|
||||
# Docs
|
||||
*.md
|
||||
!README.md
|
||||
!LICENSES.md
|
||||
|
||||
# Other
|
||||
references/
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,11 +8,11 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
frontend/node_modules/
|
||||
frontend/package-lock.json
|
||||
frontend/test-results/
|
||||
|
||||
# Frontend build output (built from source by end users)
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
frontend/.eslintcache
|
||||
|
||||
# reference libraries
|
||||
|
||||
94
AGENTS.md
94
AGENTS.md
@@ -10,19 +10,16 @@ If instructed to "run all tests" or "get ready for a commit" or other summative,
|
||||
./scripts/all_quality.sh
|
||||
```
|
||||
|
||||
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend sequentially. All checks must pass green.
|
||||
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green.
|
||||
|
||||
## Overview
|
||||
|
||||
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over Serial, TCP, or BLE and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
|
||||
|
||||
**For detailed component documentation, see these primary AGENTS.md files:**
|
||||
**For detailed component documentation, see:**
|
||||
- `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
|
||||
- `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)
|
||||
- `frontend/src/components/AGENTS.md` - Frontend visualizer feature (a particularly complex and long force-directed graph visualizer component; can skip this file unless you're working on that feature)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -75,7 +72,7 @@ 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`
|
||||
- Bot system — automated message responses
|
||||
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
|
||||
|
||||
## Error Handling Philosophy
|
||||
@@ -97,7 +94,7 @@ The following are **deliberate design choices**, not bugs. They are documented i
|
||||
|
||||
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
|
||||
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/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.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this.
|
||||
|
||||
## Intentional Packet Handling Decision
|
||||
|
||||
@@ -112,19 +109,9 @@ Frontend packet-feed consumers should treat `observation_id` as the dedup/render
|
||||
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 + hop count, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Only the N most recent unique paths are retained per contact (currently 10).
|
||||
- See `frontend/src/components/AGENTS_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.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -157,14 +144,15 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
.
|
||||
├── app/ # FastAPI backend
|
||||
│ ├── AGENTS.md # Backend documentation
|
||||
│ ├── bot.py # Bot execution and outbound bot sends
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings)
|
||||
│ ├── 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)
|
||||
│ └── mqtt.py # Optional MQTT publisher
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
@@ -177,11 +165,9 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── scripts/
|
||||
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (sequential)
|
||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ ├── e2e.sh # End-to-end test runner
|
||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
||||
├── remoteterm.service # Systemd unit file for production deployment
|
||||
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized)
|
||||
│ ├── publish.sh # Version bump, changelog, docker build & push
|
||||
│ └── deploy.sh # Deploy to production server
|
||||
├── tests/ # Backend tests (pytest)
|
||||
├── data/ # SQLite database (runtime)
|
||||
└── pyproject.toml # Python dependencies
|
||||
@@ -244,14 +230,9 @@ Key test files:
|
||||
- `tests/test_api.py` - API endpoints, read state tracking
|
||||
- `tests/test_migrations.py` - Database migration system
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
||||
- `tests/test_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_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
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -262,7 +243,7 @@ npm run test:run
|
||||
|
||||
### Before Completing Changes
|
||||
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** This runs all linting, formatting, type checking, tests, and builds sequentially, catching type mismatches, breaking changes, and compilation errors. This is not necessary for docs-only changes.
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors.
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -270,9 +251,9 @@ 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, including `path_hash_mode` and `path_hash_mode_supported` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported |
|
||||
| GET | `/api/health` | Connection status |
|
||||
| GET | `/api/radio/config` | Radio configuration |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
@@ -301,17 +282,15 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| GET | `/api/channels/{key}` | Get channel by key |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/sync` | Pull from radio |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
| GET | `/api/messages` | List with filters |
|
||||
| POST | `/api/messages/direct` | Send direct message |
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
@@ -320,13 +299,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/settings` | Get app settings |
|
||||
| PATCH | `/api/settings` | Update app settings |
|
||||
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
|
||||
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
|
||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
@@ -344,7 +317,6 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- `1` - Client (regular node)
|
||||
- `2` - Repeater
|
||||
- `3` - Room
|
||||
- `4` - Sensor
|
||||
|
||||
### Channel Keys
|
||||
|
||||
@@ -372,13 +344,22 @@ 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)
|
||||
### MQTT Publishing
|
||||
|
||||
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`.
|
||||
Optional MQTT integration forwards mesh events to an external broker for home automation, logging, or alerting. All MQTT config is stored in the database (`app_settings`), not env vars — configured from the Settings pane, no server restart needed.
|
||||
|
||||
`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.
|
||||
**Two independent toggles**: publish decrypted messages, publish raw packets.
|
||||
|
||||
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.
|
||||
**Topic structure** (default prefix `meshcore`):
|
||||
- `meshcore/dm:<contact_public_key>` — decrypted DM
|
||||
- `meshcore/gm:<channel_key>` — decrypted channel message
|
||||
- `meshcore/raw/dm:<contact_key>` — raw packet attributed to a DM contact
|
||||
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
|
||||
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
|
||||
|
||||
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status`.
|
||||
|
||||
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
|
||||
|
||||
### Server-Side Decryption
|
||||
|
||||
@@ -420,18 +401,9 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
|
||||
|
||||
**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`, `bots`, and all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used.
|
||||
|
||||
## 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.
|
||||
|
||||
107
CHANGELOG.md
107
CHANGELOG.md
@@ -1,77 +1,3 @@
|
||||
## [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)
|
||||
Feature: Add community MQTT stats reporting, and improve over a few commits
|
||||
Feature: Color schemes and misc. settings reorg
|
||||
Feature: Add why-active to filtered nodes
|
||||
Feature: Add channel and contact info box
|
||||
Feature: Add contact blocking
|
||||
Feature: Add potential repeater path map display
|
||||
Feature: Add flood scoping/regions
|
||||
Feature: Global message search
|
||||
Feature: Fully safe bot disable
|
||||
Feature: Add default #remoteterm channel (lol sorry I had to)
|
||||
Feature: Custom recency pruning in visualizer
|
||||
Bugfix: Be more cautious around null byte stripping
|
||||
Bugfix: Clear channel-add interface on not-add-another
|
||||
Bugfix: Add status/name/MQTT LWT
|
||||
Bugfix: Channel deletion propagates over WS
|
||||
Bugfix: Show map location for all nodes on link, not 7-day-limited
|
||||
Bugfix: Hide private key channel keys by default
|
||||
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
||||
Misc: Doc, changelog, and test improvements
|
||||
Misc: Add, and remove, package lock (sorry Windows users)
|
||||
Misc: Don't show mark all as read if not necessary
|
||||
Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
||||
Misc: Add Windows startup notes
|
||||
Misc: E2E expansion + improvement
|
||||
Misc: Move around visualizer settings
|
||||
|
||||
## [2.4.0] - 2026-03-02
|
||||
|
||||
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
||||
Misc: Build scripts and library attribution
|
||||
Misc: Add sign of life to E2E tests
|
||||
|
||||
## [2.3.0] - 2026-03-01
|
||||
|
||||
Feature: Click path description to reset to flood
|
||||
@@ -91,14 +17,14 @@ Feature: Contact info pane
|
||||
Feature: Overhaul repeater interface
|
||||
Bugfix: Misc. frontend rendering + perf improvements
|
||||
Bugfix: Better behavior around radio locking and autofetch/polling
|
||||
Bugfix: Clear channel name field on new-channel modal tab change
|
||||
Bugifx: Clear channel name field on new-channel modal tab change
|
||||
Bugfix: Repeater inforbox can scroll
|
||||
Bugfix: Better handling of historical DM encrypts
|
||||
Bugfix: Handle errors if returned in prefetch phase
|
||||
Misc: Radio event response failure is logged/surfaced better
|
||||
Misc: Improve test coverage and remove dead code
|
||||
Misc: Documentation and errata improvements
|
||||
Misc: Database storage optimization
|
||||
Misc: Documentatin and errata improvements
|
||||
Misc: Databse storage optimization
|
||||
|
||||
## [2.1.0] - 2026-02-23
|
||||
|
||||
@@ -138,11 +64,11 @@ Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||
|
||||
Feature: Frontend UX + log overhaul
|
||||
Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||
Bugfix: Fix out of order path WS messages overwriting each other
|
||||
Bugfix: Fix our of order path WS messages voerwriting each other
|
||||
Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||
Bugfix: Fix repeater command timestamp selection logic
|
||||
Bugfix: Fir repeater command timestamp selection logic
|
||||
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||
Bugfix: Add missing radio operation locks in a few spots
|
||||
Bugfix: Add missing radio operation locks in a few sports
|
||||
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||
Misc: Visualizer layout refinement & option labels
|
||||
@@ -168,7 +94,7 @@ Feature: Upgrade the room finder to support two-word rooms
|
||||
## [1.9.2] - 2026-02-12
|
||||
|
||||
Feature: Options dialog sucks less
|
||||
Bugfix: Move tests to isolated memory DB
|
||||
Bugix: Move tests to isolated memory DB
|
||||
Bugfix: Mention case sensitivity
|
||||
Bugfix: Stale header retention on settings page view
|
||||
Bugfix: Non-isolated path writing
|
||||
@@ -221,13 +147,30 @@ Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
Feature: PWA viewport fixes(thanks @rgregg)
|
||||
Feature (?): No frontend distribution; build it yourself ;P
|
||||
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||
Bugfix: Better guarding around reconnection
|
||||
Bugfix: Duplicate websocket connection fixes
|
||||
Bugfix: Settings tab error cleanliness on tab swap
|
||||
Bugfix: Fix path traversal vuln
|
||||
UI: Swap visualizer legend ordering (yay prettier)
|
||||
Misc: Perf and locking improvements
|
||||
Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.7.1] - 2026-02-03
|
||||
|
||||
Feature: Clickable hyperlinks
|
||||
Bugfix: More consistent public key normalization
|
||||
Bugfix: Use more reliable cursor paging
|
||||
Bugfix: Fix null timestamp dedupe failure
|
||||
Bugfix: More consistent prefix-based message claiming on key receipt
|
||||
Bugfix: More concistent prefix-based message claiming on key reciept
|
||||
Misc: Bot can respond to its own messages
|
||||
Misc: Additional tests
|
||||
Misc: Remove unneeded message dedupe logic
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package.json frontend/.npmrc ./
|
||||
RUN npm install
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
@@ -29,9 +29,6 @@ RUN uv sync --frozen --no-dev
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
|
||||
# Copy license attributions
|
||||
COPY LICENSES.md ./
|
||||
|
||||
# Copy built frontend from first stage
|
||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||
|
||||
|
||||
1532
LICENSES.md
1532
LICENSES.md
File diff suppressed because it is too large
Load Diff
50
README.md
50
README.md
@@ -8,13 +8,12 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward 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
|
||||
* Forward decrypted packets to MQTT brokers
|
||||
* 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 has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||

|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -43,12 +42,6 @@ ls /dev/ttyUSB* /dev/ttyACM*
|
||||
#######
|
||||
ls /dev/cu.usbserial-* /dev/cu.usbmodem*
|
||||
|
||||
###########
|
||||
# Windows
|
||||
###########
|
||||
# In PowerShell:
|
||||
Get-CimInstance Win32_SerialPort | Select-Object DeviceID, Caption
|
||||
|
||||
######
|
||||
# WSL2
|
||||
######
|
||||
@@ -57,11 +50,8 @@ winget install usbipd
|
||||
# restart console
|
||||
# then find device ID
|
||||
usbipd list
|
||||
# make device shareable
|
||||
# attach device to WSL
|
||||
usbipd bind --busid 3-8 # (or whatever the right ID is)
|
||||
# attach device to WSL (run this each time you plug in the device)
|
||||
usbipd attach --wsl --busid 3-8
|
||||
# device will appear in WSL as /dev/ttyUSB0 or /dev/ttyACM0
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -95,12 +85,6 @@ MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:a
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
On Windows (PowerShell), set environment variables as a separate statement:
|
||||
```powershell
|
||||
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access at http://localhost:8000
|
||||
|
||||
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
|
||||
@@ -162,15 +146,6 @@ uv run uvicorn app.main:app --reload # autodetects serial port
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
```powershell
|
||||
uv sync
|
||||
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
> **Windows note:** I've seen an intermittent startup issue like `"Received empty packet: index out of range"` with failed contact sync. I can't figure out why this happens. The issue typically resolves on restart. If you can figure out why this happens, I will buy you a virtual or iRL six pack if you're in the PNW. As a former always-windows-girlie before embracing WSL2, I despise second-classing M$FT users, but I'm just stuck with this one.
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
@@ -186,7 +161,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
|
||||
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
|
||||
|
||||
Run everything at once:
|
||||
Run everything at once (parallelized):
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
@@ -223,7 +198,6 @@ npm run build # build the frontend
|
||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||
| `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) |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
@@ -314,7 +288,7 @@ npm run test:run
|
||||
|
||||
**E2E:**
|
||||
|
||||
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly and an available autodetect-able radio, as well as a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work.
|
||||
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly. E2E tests are generally not necessary to run for normal development work.
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
@@ -326,15 +300,3 @@ npx playwright test --headed # show the browser window
|
||||
## API Documentation
|
||||
|
||||
With the backend running: http://localhost:8000/docs
|
||||
|
||||
## Debugging & Bug Reports
|
||||
|
||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||
|
||||
To start the server with debug logging:
|
||||
|
||||
```bash
|
||||
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Please include the relevant debug log output when filing an issue on GitHub.
|
||||
|
||||
@@ -20,16 +20,16 @@ app/
|
||||
├── 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, fanout)
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings)
|
||||
├── radio.py # RadioManager + auto-reconnect monitor
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
├── decoder.py # Packet parsing/decryption
|
||||
├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
├── mqtt.py # Optional MQTT publisher (fire-and-forget forwarding)
|
||||
├── bot.py # Bot execution and outbound bot sends
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
@@ -41,7 +41,6 @@ app/
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── settings.py
|
||||
├── fanout.py
|
||||
├── repeaters.py
|
||||
├── statistics.py
|
||||
└── ws.py
|
||||
@@ -62,7 +61,7 @@ app/
|
||||
2. Message is persisted as outgoing.
|
||||
3. Endpoint broadcasts WS `message` event so all live clients update.
|
||||
4. ACK/repeat updates arrive later as `message_acked` events.
|
||||
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend. 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.
|
||||
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend — a safe assumption since name changes require a radio config update and are not something that happens mid-conversation.
|
||||
|
||||
### Connection lifecycle
|
||||
|
||||
@@ -73,13 +72,6 @@ app/
|
||||
|
||||
## 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.
|
||||
- `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`).
|
||||
@@ -109,14 +101,18 @@ app/
|
||||
- `0` means disabled.
|
||||
- Last send time tracked in `app_settings.last_advert_time`.
|
||||
|
||||
### Fanout bus
|
||||
### MQTT publishing
|
||||
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise) 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.
|
||||
- Optional forwarding of mesh events to an external MQTT broker.
|
||||
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
|
||||
- Disabled when `mqtt_broker_host` is empty.
|
||||
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
|
||||
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
|
||||
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
|
||||
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
|
||||
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`).
|
||||
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
|
||||
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
|
||||
@@ -124,8 +120,8 @@ app/
|
||||
- `GET /health`
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config` — includes `path_hash_mode` and `path_hash_mode_supported`
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||
- `GET /radio/config`
|
||||
- `PATCH /radio/config`
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/reboot`
|
||||
@@ -157,7 +153,6 @@ app/
|
||||
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `GET /channels/{key}`
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
@@ -165,8 +160,7 @@ app/
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
- `GET /messages` — list with filters; supports `q` (full-text search), `after`/`after_id` (forward cursor)
|
||||
- `GET /messages/around/{message_id}` — context messages around a target (for jump-to-message navigation)
|
||||
- `GET /messages`
|
||||
- `POST /messages/direct`
|
||||
- `POST /messages/channel`
|
||||
- `POST /messages/channel/{message_id}/resend`
|
||||
@@ -184,16 +178,8 @@ app/
|
||||
- `GET /settings`
|
||||
- `PATCH /settings`
|
||||
- `POST /settings/favorites/toggle`
|
||||
- `POST /settings/blocked-keys/toggle`
|
||||
- `POST /settings/blocked-names/toggle`
|
||||
- `POST /settings/migrate`
|
||||
|
||||
### Fanout
|
||||
- `GET /fanout` — list all fanout configs
|
||||
- `POST /fanout` — create new fanout config
|
||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
|
||||
@@ -207,8 +193,6 @@ app/
|
||||
- `message` — new message (channel or DM, from packet processor or send endpoints)
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
|
||||
- `channel_deleted` — channel removed from database (payload: `{ key }`)
|
||||
- `error` — toast notification (reconnect failure, missing private key, etc.)
|
||||
- `success` — toast notification (historical decrypt complete, etc.)
|
||||
|
||||
@@ -218,11 +202,11 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
## Data Model Notes
|
||||
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
||||
- `contacts` (includes `first_seen` for contact age tracking)
|
||||
- `channels`
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
@@ -235,10 +219,9 @@ Main tables:
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
|
||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
@@ -268,10 +251,7 @@ tests/
|
||||
├── test_config.py # Configuration validation
|
||||
├── 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_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
|
||||
@@ -280,27 +260,19 @@ tests/
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
├── test_radio_sync.py # Polling, sync, advertisement
|
||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||
├── test_repository.py # Data access layer
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_channel_sender_backfill.py # Sender key backfill for channel messages
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── 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
|
||||
```
|
||||
|
||||
@@ -257,3 +257,94 @@ async def _send_single_bot_message(
|
||||
|
||||
# Update last send time after successful send
|
||||
_last_bot_send_time = time.monotonic()
|
||||
|
||||
|
||||
async def run_bot_for_message(
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None = None,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
is_outgoing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Run all enabled bots for a message (incoming or outgoing).
|
||||
|
||||
This is the main entry point called by message handlers after
|
||||
a message is successfully decrypted and stored. Bots run serially,
|
||||
and errors in one bot don't prevent others from running.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of the sender
|
||||
sender_key: 64-char hex public key of sender (DMs only, None for channels)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel messages
|
||||
channel_key: Channel key for channel messages
|
||||
channel_name: Channel name (e.g. "#general"), None for DMs
|
||||
sender_timestamp: Sender's timestamp from the message
|
||||
path: Hex-encoded routing path
|
||||
is_outgoing: Whether this is our own outgoing message
|
||||
"""
|
||||
# Early check if any bots are enabled (will re-check after sleep)
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
|
||||
if not enabled_bots:
|
||||
return
|
||||
|
||||
async with _bot_semaphore:
|
||||
logger.debug(
|
||||
"Running %d bot(s) for message from %s (is_dm=%s)",
|
||||
len(enabled_bots),
|
||||
sender_name or (sender_key[:12] if sender_key else "unknown"),
|
||||
is_dm,
|
||||
)
|
||||
|
||||
# Wait for the initiating message's retransmissions to propagate through the mesh
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Re-check settings after sleep (user may have changed bot config)
|
||||
settings = await AppSettingsRepository.get()
|
||||
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
|
||||
if not enabled_bots:
|
||||
logger.debug("All bots disabled during wait, skipping")
|
||||
return
|
||||
|
||||
# Run each enabled bot serially
|
||||
loop = asyncio.get_event_loop()
|
||||
for bot in enabled_bots:
|
||||
logger.debug("Executing bot '%s'", bot.name)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
bot.code,
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Bot '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
|
||||
)
|
||||
continue # Continue to next bot
|
||||
except Exception as e:
|
||||
logger.warning("Bot '%s' execution error: %s", bot.name, e)
|
||||
continue # Continue to next bot
|
||||
|
||||
# Send response if any
|
||||
if response:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
@@ -16,7 +16,6 @@ class Settings(BaseSettings):
|
||||
ble_pin: str = ""
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -36,6 +35,11 @@ class Settings(BaseSettings):
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
return self
|
||||
|
||||
@property
|
||||
def loopback_eligible(self) -> bool:
|
||||
"""True when no explicit transport env var is set."""
|
||||
return not self.serial_port and not self.tcp_host and not self.ble_address
|
||||
|
||||
@property
|
||||
def connection_type(self) -> Literal["serial", "tcp", "ble"]:
|
||||
if self.tcp_host:
|
||||
@@ -48,40 +52,6 @@ class Settings(BaseSettings):
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class _RepeatSquelch(logging.Filter):
|
||||
"""Suppress rapid-fire identical messages and emit a summary instead.
|
||||
|
||||
Attached to the ``meshcore`` library logger to catch its repeated
|
||||
"Serial Connection started" lines that flood the log when another
|
||||
process holds the serial port.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = 3) -> None:
|
||||
super().__init__()
|
||||
self._last_msg: str | None = None
|
||||
self._repeat_count: int = 0
|
||||
self._threshold = threshold
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
if msg == self._last_msg:
|
||||
self._repeat_count += 1
|
||||
if self._repeat_count == self._threshold:
|
||||
record.msg = (
|
||||
"%s (repeated %d times — possible serial port contention from another process)"
|
||||
)
|
||||
record.args = (msg, self._repeat_count)
|
||||
record.levelno = logging.WARNING
|
||||
record.levelname = "WARNING"
|
||||
return True
|
||||
# Suppress further repeats beyond the threshold
|
||||
return self._repeat_count < self._threshold
|
||||
else:
|
||||
self._last_msg = msg
|
||||
self._repeat_count = 1
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
logging.basicConfig(
|
||||
@@ -89,6 +59,3 @@ def setup_logging() -> None:
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
|
||||
|
||||
@@ -15,7 +15,6 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
@@ -71,7 +70,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, path_len),
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
);
|
||||
|
||||
|
||||
@@ -79,10 +79,9 @@ class PacketInfo:
|
||||
route_type: RouteType
|
||||
payload_type: PayloadType
|
||||
payload_version: int
|
||||
path_length: int # Decoded hop count (not the raw wire byte)
|
||||
path: bytes # The routing path bytes (empty if path_length is 0)
|
||||
path_length: int
|
||||
path: bytes # The routing path (empty if path_length is 0)
|
||||
payload: bytes
|
||||
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
@@ -101,36 +100,86 @@ 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: path byte (packed as [hash_mode:2][hop_count:6])
|
||||
- Next hop_count * hash_size bytes: path data
|
||||
- Next byte: path_length
|
||||
- Next path_length bytes: path data
|
||||
- Remaining: payload
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
"""
|
||||
from app.path_utils import parse_packet_envelope
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.payload if envelope is not None else None
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload
|
||||
return raw_packet[offset:]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
|
||||
"""Parse a raw packet and extract basic info."""
|
||||
from app.path_utils import parse_packet_envelope
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
if envelope is None:
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = RouteType(header & 0x03)
|
||||
payload_type = PayloadType((header >> 2) & 0x0F)
|
||||
payload_version = (header >> 6) & 0x03
|
||||
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present
|
||||
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Extract path data
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
path = raw_packet[offset : offset + path_length]
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload
|
||||
payload = raw_packet[offset:]
|
||||
|
||||
return PacketInfo(
|
||||
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,
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
payload_version=payload_version,
|
||||
path_length=path_length,
|
||||
path=path,
|
||||
payload=payload,
|
||||
)
|
||||
except ValueError:
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -480,10 +529,8 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
message_bytes = decrypted[5:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Truncate at first null terminator (consistent with channel message handling)
|
||||
null_idx = message_text.find("\x00")
|
||||
if null_idx >= 0:
|
||||
message_text = message_text[:null_idx]
|
||||
# Remove null terminator and any padding
|
||||
message_text = message_text.rstrip("\x00")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -103,23 +104,15 @@ async def on_contact_message(event: "Event") -> None:
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
# If the packet processor already stored this message, this returns None
|
||||
ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
sender_name = contact.name if contact else None
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=payload.get("text", ""),
|
||||
conversation_key=sender_pubkey,
|
||||
sender_timestamp=sender_timestamp,
|
||||
sender_timestamp=payload.get("sender_timestamp") or received_at,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
path=payload.get("path"),
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
@@ -132,11 +125,8 @@ async def on_contact_message(event: "Event") -> None:
|
||||
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=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
path = payload.get("path")
|
||||
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
|
||||
|
||||
# Broadcast the new message
|
||||
broadcast_event(
|
||||
@@ -146,13 +136,11 @@ async def on_contact_message(event: "Event") -> None:
|
||||
type="PRIV",
|
||||
conversation_key=sender_pubkey,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=sender_timestamp,
|
||||
sender_timestamp=payload.get("sender_timestamp") or received_at,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
sender_name=sender_name,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@@ -160,6 +148,23 @@ async def on_contact_message(event: "Event") -> None:
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
|
||||
|
||||
# Run bot if enabled
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=contact.name if contact else None,
|
||||
sender_key=sender_pubkey,
|
||||
message_text=payload.get("text", ""),
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=payload.get("sender_timestamp"),
|
||||
path=payload.get("path"),
|
||||
is_outgoing=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def on_rx_log_data(event: "Event") -> None:
|
||||
"""Store raw RF packet data and process via centralized packet processor.
|
||||
@@ -211,7 +216,6 @@ 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]
|
||||
@@ -226,28 +230,7 @@ async def on_path_update(event: "Event") -> None:
|
||||
)
|
||||
return
|
||||
|
||||
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,
|
||||
)
|
||||
await ContactRepository.update_path(contact.public_key, str(path), normalized_path_len)
|
||||
|
||||
|
||||
async def on_new_contact(event: "Event") -> None:
|
||||
@@ -277,13 +260,6 @@ async def on_new_contact(event: "Event") -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, etc.) matching the REST Contact shape exactly.
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
# Fanout Bus Architecture
|
||||
|
||||
The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### FanoutModule (base.py)
|
||||
Base class that all integration modules extend:
|
||||
- `__init__(config_id, config, *, name="")` — constructor; receives the config UUID, the type-specific config dict, and the user-assigned name
|
||||
- `start()` / `stop()` — async lifecycle (e.g. open/close connections)
|
||||
- `on_message(data)` — receive decoded messages (DM/channel)
|
||||
- `on_raw(data)` — receive raw RF packets
|
||||
- `status` property (**must override**) — return `"connected"`, `"disconnected"`, or `"error"`
|
||||
|
||||
### FanoutManager (manager.py)
|
||||
Singleton that owns all active modules and dispatches events:
|
||||
- `load_from_db()` — startup: load enabled configs, instantiate modules
|
||||
- `reload_config(id)` — CRUD: stop old, start new
|
||||
- `remove_config(id)` — delete: stop and remove
|
||||
- `broadcast_message(data)` — scope-check + dispatch `on_message`
|
||||
- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
|
||||
- `stop_all()` — shutdown
|
||||
- `get_statuses()` — health endpoint data
|
||||
|
||||
All modules are constructed uniformly: `cls(config_id, config_blob, name=cfg.get("name", ""))`.
|
||||
|
||||
### Scope Matching
|
||||
Each config has a `scope` JSON blob controlling what events reach it:
|
||||
```json
|
||||
{"messages": "all", "raw_packets": "all"}
|
||||
{"messages": "none", "raw_packets": "all"}
|
||||
{"messages": {"channels": ["key1"], "contacts": "all"}, "raw_packets": "none"}
|
||||
```
|
||||
Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Radio Event -> packet_processor / event_handler
|
||||
-> broadcast_event("message"|"raw_packet", data, realtime=True)
|
||||
-> WebSocket broadcast (always)
|
||||
-> FanoutManager.broadcast_message/raw (only if realtime=True)
|
||||
-> scope check per module
|
||||
-> module.on_message / on_raw
|
||||
```
|
||||
|
||||
Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
|
||||
|
||||
## Current Module Types
|
||||
|
||||
### mqtt_private (mqtt_private.py)
|
||||
Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
|
||||
- `broker_host`, `broker_port`, `username`, `password`
|
||||
- `use_tls`, `tls_insecure`, `topic_prefix`
|
||||
|
||||
### mqtt_community (mqtt_community.py)
|
||||
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:
|
||||
- `code` — Python bot function source code
|
||||
- Executes in a thread pool with timeout and semaphore concurrency control
|
||||
- Rate-limits outgoing messages for repeater compatibility
|
||||
|
||||
### webhook (webhook.py)
|
||||
HTTP webhook delivery. Config blob:
|
||||
- `url`, `method` (POST/PUT/PATCH)
|
||||
- `hmac_secret` (optional) — when set, each request includes an HMAC-SHA256 signature of the JSON body
|
||||
- `hmac_header` (optional, default `X-Webhook-Signature`) — header name for the signature (value format: `sha256=<hex>`)
|
||||
- `headers` — arbitrary extra headers (JSON object)
|
||||
|
||||
### apprise (apprise_mod.py)
|
||||
Push notifications via Apprise library. Config blob:
|
||||
- `urls` — newline-separated Apprise notification service URLs
|
||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||
- `include_path` — include routing path in notification body
|
||||
|
||||
## Adding a New Integration Type
|
||||
|
||||
### Step-by-step checklist
|
||||
|
||||
#### 1. Backend module (`app/fanout/my_type.py`)
|
||||
|
||||
Create a class extending `FanoutModule`:
|
||||
|
||||
```python
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
class MyTypeModule(FanoutModule):
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
# Initialize module-specific state
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Open connections, create clients, etc."""
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Close connections, clean up resources."""
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Handle decoded messages. Omit if not needed."""
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
"""Handle raw packets. Omit if not needed."""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Required. Return 'connected', 'disconnected', or 'error'."""
|
||||
...
|
||||
```
|
||||
|
||||
Constructor requirements:
|
||||
- Must accept `config_id: str, config: dict, *, name: str = ""`
|
||||
- Must forward `name` to super: `super().__init__(config_id, config, name=name)`
|
||||
|
||||
#### 2. Register in manager (`app/fanout/manager.py`)
|
||||
|
||||
Add import and mapping in `_register_module_types()`:
|
||||
|
||||
```python
|
||||
from app.fanout.my_type import MyTypeModule
|
||||
_MODULE_TYPES["my_type"] = MyTypeModule
|
||||
```
|
||||
|
||||
#### 3. Router changes (`app/routers/fanout.py`)
|
||||
|
||||
Three changes needed:
|
||||
|
||||
**a)** Add to `_VALID_TYPES` set:
|
||||
```python
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "my_type"}
|
||||
```
|
||||
|
||||
**b)** Add a validation function:
|
||||
```python
|
||||
def _validate_my_type_config(config: dict) -> None:
|
||||
"""Validate my_type config blob."""
|
||||
if not config.get("some_required_field"):
|
||||
raise HTTPException(status_code=400, detail="some_required_field is required")
|
||||
```
|
||||
|
||||
**c)** Wire validation into both `create_fanout_config` and `update_fanout_config` — add an `elif` to the validation block in each:
|
||||
```python
|
||||
elif body.type == "my_type":
|
||||
_validate_my_type_config(body.config)
|
||||
```
|
||||
Note: validation only runs when the config will be enabled (disabled configs are treated as drafts).
|
||||
|
||||
**d)** Add scope enforcement in `_enforce_scope()` if the type has fixed scope constraints (e.g. raw_packets always none). Otherwise it falls through to the `mqtt_private` default which allows both messages and raw_packets to be configurable.
|
||||
|
||||
#### 4. Frontend editor component (`SettingsFanoutSection.tsx`)
|
||||
|
||||
Four changes needed in this single file:
|
||||
|
||||
**a)** Add to `TYPE_LABELS` and `TYPE_OPTIONS` at the top:
|
||||
```tsx
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
// ... existing entries ...
|
||||
my_type: 'My Type',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
// ... existing entries ...
|
||||
{ value: 'my_type', label: 'My Type' },
|
||||
];
|
||||
```
|
||||
|
||||
**b)** Create an editor component (follows the same pattern as existing editors):
|
||||
```tsx
|
||||
function MyTypeConfigEditor({
|
||||
config,
|
||||
scope,
|
||||
onChange,
|
||||
onScopeChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Type-specific config fields */}
|
||||
<Separator />
|
||||
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`.
|
||||
|
||||
The `ScopeSelector` component is defined within the same file. It accepts an optional `showRawPackets` prop:
|
||||
- **Without `showRawPackets`** (webhook, apprise): shows message scope only (all/only/except — no "none" option since that would make the integration a no-op). A warning appears when the effective selection matches nothing.
|
||||
- **With `showRawPackets`** (private MQTT): adds a "Forward raw packets" toggle and includes the "No messages" option (valid when raw packets are enabled). The warning appears only when both raw packets and messages are effectively disabled.
|
||||
|
||||
**c)** Add default config and scope in `handleAddCreate`:
|
||||
```tsx
|
||||
const defaults: Record<string, Record<string, unknown>> = {
|
||||
// ... existing entries ...
|
||||
my_type: { some_field: '', other_field: true },
|
||||
};
|
||||
const defaultScopes: Record<string, Record<string, unknown>> = {
|
||||
// ... existing entries ...
|
||||
my_type: { messages: 'all', raw_packets: 'none' },
|
||||
};
|
||||
```
|
||||
|
||||
**d)** Wire the editor into the detail view's conditional render block:
|
||||
```tsx
|
||||
{editingConfig.type === 'my_type' && (
|
||||
<MyTypeConfigEditor
|
||||
config={editConfig}
|
||||
scope={editScope}
|
||||
onChange={setEditConfig}
|
||||
onScopeChange={setEditScope}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 5. Tests
|
||||
|
||||
**Backend integration tests** (`tests/test_fanout_integration.py`):
|
||||
- Test that a configured + enabled module receives messages via `FanoutManager.broadcast_message`
|
||||
- Test scope filtering (all, none, selective)
|
||||
- Test that a disabled module does not receive messages
|
||||
|
||||
**Backend unit tests** (`tests/test_fanout_hitlist.py` or a dedicated file):
|
||||
- Test config validation (required fields, bad values)
|
||||
- Test module-specific logic in isolation
|
||||
|
||||
**Frontend tests** (`frontend/src/test/fanoutSection.test.tsx`):
|
||||
- The existing suite covers the list/edit/create flow generically. If your editor has special behavior, add specific test cases.
|
||||
|
||||
#### Summary of files to touch
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `app/fanout/my_type.py` | New module class |
|
||||
| `app/fanout/manager.py` | Import + register in `_register_module_types()` |
|
||||
| `app/routers/fanout.py` | `_VALID_TYPES` + validator function + scope enforcement |
|
||||
| `frontend/.../SettingsFanoutSection.tsx` | `TYPE_LABELS` + `TYPE_OPTIONS` + editor component + defaults + detail view wiring |
|
||||
| `tests/test_fanout_integration.py` | Integration tests |
|
||||
|
||||
## REST API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new config |
|
||||
| PATCH | `/api/fanout/{id}` | Update config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete config (stops module) |
|
||||
|
||||
## Database
|
||||
|
||||
`fanout_configs` table:
|
||||
- `id` TEXT PRIMARY KEY
|
||||
- `type`, `name`, `enabled`, `config` (JSON), `scope` (JSON)
|
||||
- `sort_order`, `created_at`
|
||||
|
||||
Migrations:
|
||||
- **36**: Creates `fanout_configs` table, migrates existing MQTT settings from `app_settings`
|
||||
- **37**: Migrates bot configs from `app_settings.bots` JSON column into fanout rows
|
||||
- **38**: Drops legacy `mqtt_*`, `community_mqtt_*`, and `bots` columns from `app_settings`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `app/fanout/base.py` — FanoutModule base class
|
||||
- `app/fanout/manager.py` — FanoutManager singleton
|
||||
- `app/fanout/mqtt_base.py` — BaseMqttPublisher ABC (shared MQTT connection loop)
|
||||
- `app/fanout/mqtt.py` — MqttPublisher (private MQTT publishing)
|
||||
- `app/fanout/community_mqtt.py` — CommunityMqttPublisher (community MQTT with JWT auth)
|
||||
- `app/fanout/mqtt_private.py` — Private MQTT fanout module
|
||||
- `app/fanout/mqtt_community.py` — Community MQTT fanout module
|
||||
- `app/fanout/bot.py` — Bot fanout module
|
||||
- `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/repository/fanout.py` — Database CRUD
|
||||
- `app/routers/fanout.py` — REST API
|
||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||
- `frontend/src/components/settings/SettingsFanoutSection.tsx` — UI
|
||||
@@ -1,8 +0,0 @@
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.manager import FanoutManager, fanout_manager
|
||||
|
||||
__all__ = [
|
||||
"FanoutManager",
|
||||
"FanoutModule",
|
||||
"fanout_manager",
|
||||
]
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Fanout module for Apprise push notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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__)
|
||||
|
||||
|
||||
def _parse_urls(raw: str) -> list[str]:
|
||||
"""Split multi-line URL string into individual URLs."""
|
||||
return [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _normalize_discord_url(url: str) -> str:
|
||||
"""Add avatar=no to Discord URLs to suppress identity override."""
|
||||
parts = urlsplit(url)
|
||||
scheme = parts.scheme.lower()
|
||||
host = parts.netloc.lower()
|
||||
|
||||
is_discord = scheme in ("discord", "discords") or (
|
||||
scheme in ("http", "https")
|
||||
and host in ("discord.com", "discordapp.com")
|
||||
and parts.path.lower().startswith("/api/webhooks/")
|
||||
)
|
||||
if not is_discord:
|
||||
return url
|
||||
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["avatar"] = "no"
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
"""Build a human-readable notification body from message data."""
|
||||
msg_type = data.get("type", "")
|
||||
text = data.get("text", "")
|
||||
sender_name = data.get("sender_name") or "Unknown"
|
||||
|
||||
via = ""
|
||||
if include_path:
|
||||
paths = data.get("paths")
|
||||
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`]"
|
||||
elif path_str is not None:
|
||||
path_str = path_str.strip().lower()
|
||||
if path_str == "":
|
||||
via = " **via:** [`direct`]"
|
||||
else:
|
||||
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}]"
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"**DM:** {sender_name}: {text}{via}"
|
||||
|
||||
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
|
||||
return f"**{channel_name}:** {sender_name}: {text}{via}"
|
||||
|
||||
|
||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||
"""Send notification synchronously via Apprise. Returns True on success."""
|
||||
import apprise as apprise_lib
|
||||
|
||||
urls = _parse_urls(urls_raw)
|
||||
if not urls:
|
||||
return False
|
||||
|
||||
notifier = apprise_lib.Apprise()
|
||||
for url in urls:
|
||||
if preserve_identity:
|
||||
url = _normalize_discord_url(url)
|
||||
notifier.add(url)
|
||||
|
||||
return bool(notifier.notify(title="", body=body))
|
||||
|
||||
|
||||
class AppriseModule(FanoutModule):
|
||||
"""Sends push notifications via Apprise for incoming messages."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
# Skip outgoing messages — only notify on incoming
|
||||
if data.get("outgoing"):
|
||||
return
|
||||
|
||||
urls = self.config.get("urls", "")
|
||||
if not urls or not urls.strip():
|
||||
return
|
||||
|
||||
preserve_identity = self.config.get("preserve_identity", True)
|
||||
include_path = self.config.get("include_path", True)
|
||||
body = _format_body(data, include_path=include_path)
|
||||
|
||||
try:
|
||||
success = await asyncio.to_thread(
|
||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
||||
)
|
||||
self._last_error = None if success else "Apprise notify returned failure"
|
||||
if not success:
|
||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.exception("Apprise send error for module %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("urls", "").strip():
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Base class for fanout integration modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FanoutModule:
|
||||
"""Base class for all fanout integrations.
|
||||
|
||||
Each module wraps a specific integration (MQTT, webhook, etc.) and
|
||||
receives dispatched messages/packets from the FanoutManager.
|
||||
|
||||
Subclasses must override the ``status`` property.
|
||||
"""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
self.config_id = config_id
|
||||
self.config = config
|
||||
self.name = name
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the module (e.g. disconnect from broker)."""
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Called for decoded messages (DM/channel). Override if needed."""
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
"""Called for raw RF packets. Override if needed."""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Return 'connected', 'disconnected', or 'error'."""
|
||||
raise NotImplementedError
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Fanout module wrapping bot execution logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotModule(FanoutModule):
|
||||
"""Wraps a single bot's code execution and response routing.
|
||||
|
||||
Each BotModule represents one bot configuration. It receives decoded
|
||||
messages via ``on_message``, executes the bot's Python code in a
|
||||
background task (after a 2-second settle delay), and sends any response
|
||||
back through the radio.
|
||||
"""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._active = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._active = False
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
# Wait briefly for tasks to acknowledge cancellation
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
self._tasks.clear()
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
"""Kick off bot execution in a background task so we don't block dispatch."""
|
||||
task = asyncio.create_task(self._run_for_message(data))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _run_for_message(self, data: dict) -> None:
|
||||
from app.fanout.bot_exec import (
|
||||
BOT_EXECUTION_TIMEOUT,
|
||||
execute_bot_code,
|
||||
process_bot_response,
|
||||
)
|
||||
|
||||
code = self.config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
return
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
is_dm = msg_type == "PRIV"
|
||||
|
||||
# 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", "")
|
||||
channel_key = None
|
||||
channel_name = None
|
||||
|
||||
# Outgoing DMs: sender is us, not the contact
|
||||
if is_outgoing:
|
||||
sender_name = None
|
||||
else:
|
||||
sender_name = data.get("sender_name")
|
||||
if sender_name is None:
|
||||
from app.repository import ContactRepository
|
||||
|
||||
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")
|
||||
channel_key = conversation_key
|
||||
|
||||
channel_name = data.get("channel_name")
|
||||
if channel_name is None:
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
channel = await ChannelRepository.get_by_key(conversation_key)
|
||||
channel_name = channel.name if channel else None
|
||||
|
||||
# Strip "sender: " prefix from channel message text
|
||||
text = data.get("text", "")
|
||||
if sender_name and text.startswith(f"{sender_name}: "):
|
||||
message_text = text[len(f"{sender_name}: ") :]
|
||||
else:
|
||||
message_text = text
|
||||
|
||||
sender_timestamp = data.get("sender_timestamp")
|
||||
path_value = data.get("path")
|
||||
# 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
|
||||
|
||||
# Wait for message to settle (allows retransmissions to be deduped)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Execute bot code in thread pool with timeout
|
||||
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
|
||||
|
||||
async with _bot_semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
_bot_executor,
|
||||
execute_bot_code,
|
||||
code,
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path_value,
|
||||
is_outgoing,
|
||||
),
|
||||
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)
|
||||
return
|
||||
|
||||
if response and self._active:
|
||||
await process_bot_response(response, is_dm, sender_key or "", channel_key)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "connected"
|
||||
@@ -1,568 +0,0 @@
|
||||
"""Community MQTT publisher for sharing raw packets with the MeshCore community.
|
||||
|
||||
Publishes raw packet data to mqtt-us-v1.letsmesh.net using the protocol
|
||||
defined by meshcore-packet-capture (https://github.com/agessaman/meshcore-packet-capture).
|
||||
|
||||
Authentication uses Ed25519 JWT tokens signed with the radio's private key.
|
||||
This module is independent from the private MqttPublisher in app/mqtt.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
import aiomqtt
|
||||
import nacl.bindings
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
|
||||
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
|
||||
_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
|
||||
|
||||
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
|
||||
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
|
||||
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
||||
|
||||
# Periodic status republish interval (matches meshcore-packet-capture reference)
|
||||
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
||||
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
||||
|
||||
# Ed25519 group order
|
||||
_L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
# Route type mapping: bottom 2 bits of first byte
|
||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||
|
||||
|
||||
class CommunityMqttSettings(Protocol):
|
||||
"""Attributes expected on the settings object for the community MQTT publisher."""
|
||||
|
||||
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:
|
||||
"""Base64url encode without padding."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _ed25519_sign_expanded(
|
||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
||||
) -> bytes:
|
||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||
|
||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
||||
This performs the signing manually using the already-expanded key material.
|
||||
|
||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||
"""
|
||||
# r = SHA-512(prefix || message) mod L
|
||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||
# R = r * B (base point multiplication)
|
||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||
# k = SHA-512(R || public_key || message) mod L
|
||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||
# s = (r + k * scalar) mod L
|
||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||
return R + s.to_bytes(32, "little")
|
||||
|
||||
|
||||
def _generate_jwt_token(
|
||||
private_key: bytes,
|
||||
public_key: bytes,
|
||||
*,
|
||||
audience: str = _DEFAULT_BROKER,
|
||||
email: str = "",
|
||||
) -> str:
|
||||
"""Generate a JWT token for community MQTT authentication.
|
||||
|
||||
Creates a token with Ed25519 signature using MeshCore's expanded key format.
|
||||
Token format: header_b64.payload_b64.signature_hex
|
||||
|
||||
Optional ``email`` embeds a node-claiming identity so the community
|
||||
aggregator can associate this radio with an owner.
|
||||
"""
|
||||
header = {"alg": "Ed25519", "typ": "JWT"}
|
||||
now = int(time.time())
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
payload: dict[str, object] = {
|
||||
"publicKey": pubkey_hex,
|
||||
"iat": now,
|
||||
"exp": now + _TOKEN_LIFETIME,
|
||||
"aud": audience,
|
||||
"owner": pubkey_hex,
|
||||
"client": _CLIENT_ID,
|
||||
}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
|
||||
header_b64 = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
|
||||
scalar = private_key[:32]
|
||||
prefix = private_key[32:]
|
||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||
|
||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||
|
||||
|
||||
def _calculate_packet_hash(raw_bytes: bytes) -> str:
|
||||
"""Calculate packet hash matching MeshCore's Packet::calculatePacketHash().
|
||||
|
||||
Parses the packet structure to extract payload type and payload data,
|
||||
then hashes: payload_type(1 byte) [+ path_len(2 bytes LE) for TRACE] + payload_data.
|
||||
Returns first 16 hex characters (uppercase).
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return "0" * 16
|
||||
|
||||
try:
|
||||
envelope = parse_packet_envelope(raw_bytes)
|
||||
if envelope is None:
|
||||
return "0" * 16
|
||||
|
||||
# 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([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:
|
||||
return "0" * 16
|
||||
|
||||
|
||||
def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], int | None]:
|
||||
"""Decode packet fields used by the community uploader payload format.
|
||||
|
||||
Returns:
|
||||
(route_letter, packet_type_str, payload_len_str, path_values, payload_type_int)
|
||||
"""
|
||||
# Reference defaults when decode fails
|
||||
route = "U"
|
||||
packet_type = "0"
|
||||
payload_len = "0"
|
||||
path_values: list[str] = []
|
||||
payload_type: int | None = None
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
payload_type = envelope.payload_type
|
||||
route = _ROUTE_MAP.get(envelope.route_type, "U")
|
||||
packet_type = str(payload_type)
|
||||
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:
|
||||
return route, packet_type, payload_len, path_values, payload_type
|
||||
|
||||
|
||||
def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict:
|
||||
"""Convert a RawPacketBroadcast dict to meshcore-packet-capture format."""
|
||||
raw_hex = data.get("data", "")
|
||||
raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
|
||||
|
||||
route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes)
|
||||
|
||||
# Reference format uses local "now" timestamp and derived time/date fields.
|
||||
current_time = datetime.now()
|
||||
ts_str = current_time.isoformat()
|
||||
|
||||
# SNR/RSSI are always strings in reference output.
|
||||
snr_val = data.get("snr")
|
||||
rssi_val = data.get("rssi")
|
||||
snr = str(snr_val) if snr_val is not None else "Unknown"
|
||||
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
|
||||
|
||||
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||
|
||||
packet = {
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": public_key_hex.upper(),
|
||||
"timestamp": ts_str,
|
||||
"type": "PACKET",
|
||||
"direction": "rx",
|
||||
"time": current_time.strftime("%H:%M:%S"),
|
||||
"date": current_time.strftime("%d/%m/%Y"),
|
||||
"len": str(len(raw_bytes)),
|
||||
"packet_type": packet_type,
|
||||
"route": route,
|
||||
"payload_len": payload_len,
|
||||
"raw": raw_hex.upper(),
|
||||
"SNR": snr,
|
||||
"RSSI": rssi,
|
||||
"hash": packet_hash,
|
||||
}
|
||||
|
||||
if route == "D":
|
||||
packet["path"] = ",".join(path_values)
|
||||
|
||||
return packet
|
||||
|
||||
|
||||
def _build_status_topic(settings: CommunityMqttSettings, pubkey_hex: str) -> str:
|
||||
"""Build the ``meshcore/{IATA}/{PUBKEY}/status`` topic string."""
|
||||
iata = settings.community_mqtt_iata.upper().strip()
|
||||
return f"meshcore/{iata}/{pubkey_hex}/status"
|
||||
|
||||
|
||||
def _build_radio_info() -> str:
|
||||
"""Format the radio parameters string from self_info.
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
info = radio_manager.meshcore.self_info
|
||||
freq = info.get("radio_freq", 0)
|
||||
bw = info.get("radio_bw", 0)
|
||||
sf = info.get("radio_sf", 0)
|
||||
cr = info.get("radio_cr", 0)
|
||||
return f"{freq},{bw},{sf},{cr}"
|
||||
except Exception:
|
||||
pass
|
||||
return "0,0,0,0"
|
||||
|
||||
|
||||
def _get_client_version() -> str:
|
||||
"""Return a client version string like ``'RemoteTerm 2.4.0'``."""
|
||||
try:
|
||||
version = importlib.metadata.version("remoteterm-meshcore")
|
||||
return f"RemoteTerm {version}"
|
||||
except Exception:
|
||||
return "RemoteTerm unknown"
|
||||
|
||||
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
"""Manages the community MQTT connection and publishes raw packets."""
|
||||
|
||||
_backoff_max = 60
|
||||
_log_prefix = "Community MQTT"
|
||||
_not_configured_timeout: float | None = 30
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._key_unavailable_warned: bool = False
|
||||
self._cached_device_info: dict[str, str] | None = None
|
||||
self._cached_stats: dict[str, Any] | None = None
|
||||
self._stats_supported: bool | None = None
|
||||
self._last_stats_fetch: float = 0.0
|
||||
self._last_status_publish: float = 0.0
|
||||
|
||||
async def start(self, settings: object) -> None:
|
||||
self._key_unavailable_warned = False
|
||||
self._cached_device_info = None
|
||||
self._cached_stats = None
|
||||
self._stats_supported = None
|
||||
self._last_stats_fetch = 0.0
|
||||
self._last_status_publish = 0.0
|
||||
await super().start(settings)
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
from app.keystore import get_public_key, has_private_key
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
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.",
|
||||
)
|
||||
self._key_unavailable_warned = True
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if community MQTT is enabled and keys are available."""
|
||||
from app.keystore import get_public_key, has_private_key
|
||||
|
||||
s: CommunityMqttSettings | None = self._settings
|
||||
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
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
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
|
||||
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.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:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
status_topic = _build_status_topic(s, pubkey_hex)
|
||||
offline_payload = json.dumps(
|
||||
{
|
||||
"status": "offline",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": pubkey_hex,
|
||||
}
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"hostname": broker_host,
|
||||
"port": broker_port,
|
||||
"transport": transport,
|
||||
"tls_context": tls_context,
|
||||
"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]
|
||||
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
|
||||
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
|
||||
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
|
||||
|
||||
async def _fetch_device_info(self) -> dict[str, str]:
|
||||
"""Fetch firmware model/version from the radio (cached for the connection)."""
|
||||
if self._cached_device_info is not None:
|
||||
return self._cached_device_info
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
|
||||
fallback = {"model": "unknown", "firmware_version": "unknown"}
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"community_stats_device_info", blocking=False
|
||||
) as mc:
|
||||
event = await mc.commands.send_device_query()
|
||||
from meshcore.events import EventType
|
||||
|
||||
if event.type == EventType.DEVICE_INFO:
|
||||
fw_ver = event.payload.get("fw ver", 0)
|
||||
if fw_ver >= 3:
|
||||
model = event.payload.get("model", "unknown") or "unknown"
|
||||
ver = event.payload.get("ver", "unknown") or "unknown"
|
||||
fw_build = event.payload.get("fw_build", "") or ""
|
||||
fw_str = f"v{ver} (Build: {fw_build})" if fw_build else f"v{ver}"
|
||||
self._cached_device_info = {
|
||||
"model": model,
|
||||
"firmware_version": fw_str,
|
||||
}
|
||||
else:
|
||||
# Old firmware — cache what we can
|
||||
self._cached_device_info = {
|
||||
"model": "unknown",
|
||||
"firmware_version": f"v{fw_ver}" if fw_ver else "unknown",
|
||||
}
|
||||
return self._cached_device_info
|
||||
except (RadioOperationBusyError, RadioDisconnectedError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Community MQTT: device info fetch failed: %s", e)
|
||||
|
||||
# Don't cache transient failures — allow retry on next status publish
|
||||
return fallback
|
||||
|
||||
async def _fetch_stats(self) -> dict[str, Any] | None:
|
||||
"""Fetch core + radio stats from the radio (best-effort, cached)."""
|
||||
if self._stats_supported is False:
|
||||
return self._cached_stats
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - self._last_stats_fetch
|
||||
) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None:
|
||||
return self._cached_stats
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc:
|
||||
from meshcore.events import EventType
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
core_event = await mc.commands.get_stats_core()
|
||||
if core_event.type == EventType.ERROR:
|
||||
logger.info("Community MQTT: firmware does not support stats commands")
|
||||
self._stats_supported = False
|
||||
return self._cached_stats
|
||||
if core_event.type == EventType.STATS_CORE:
|
||||
result.update(core_event.payload)
|
||||
|
||||
radio_event = await mc.commands.get_stats_radio()
|
||||
if radio_event.type == EventType.ERROR:
|
||||
logger.info("Community MQTT: firmware does not support stats commands")
|
||||
self._stats_supported = False
|
||||
return self._cached_stats
|
||||
if radio_event.type == EventType.STATS_RADIO:
|
||||
result.update(radio_event.payload)
|
||||
|
||||
if result:
|
||||
self._cached_stats = result
|
||||
self._last_stats_fetch = now
|
||||
return self._cached_stats
|
||||
|
||||
except (RadioOperationBusyError, RadioDisconnectedError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Community MQTT: stats fetch failed: %s", e)
|
||||
|
||||
return self._cached_stats
|
||||
|
||||
async def _publish_status(
|
||||
self, settings: CommunityMqttSettings, *, refresh_stats: bool = True
|
||||
) -> None:
|
||||
"""Build and publish the enriched retained status message."""
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
return
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
device_info = await self._fetch_device_info()
|
||||
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
||||
|
||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||
payload: dict[str, Any] = {
|
||||
"status": "online",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"origin": device_name or "MeshCore Device",
|
||||
"origin_id": pubkey_hex,
|
||||
"model": device_info.get("model", "unknown"),
|
||||
"firmware_version": device_info.get("firmware_version", "unknown"),
|
||||
"radio": _build_radio_info(),
|
||||
"client_version": _get_client_version(),
|
||||
}
|
||||
if stats:
|
||||
payload["stats"] = stats
|
||||
|
||||
await self.publish(status_topic, payload, retain=True)
|
||||
self._last_status_publish = time.monotonic()
|
||||
|
||||
async def _on_connected_async(self, settings: object) -> None:
|
||||
"""Publish a retained online status message after connecting."""
|
||||
await self._publish_status(settings) # type: ignore[arg-type]
|
||||
|
||||
async def _on_periodic_wake(self, elapsed: float) -> None:
|
||||
if not self._settings:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if (now - self._last_status_publish) >= _STATS_REFRESH_INTERVAL:
|
||||
await self._publish_status(self._settings, refresh_stats=True)
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return (
|
||||
"Community MQTT connection failure",
|
||||
"Check your internet connection or try again later.",
|
||||
)
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
if not self.connected:
|
||||
logger.info("Community MQTT publish failure detected, reconnecting")
|
||||
return True
|
||||
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
|
||||
|
||||
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 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()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
@@ -1,243 +0,0 @@
|
||||
"""FanoutManager: owns all active fanout modules and dispatches events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DISPATCH_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# Type string -> module class mapping
|
||||
_MODULE_TYPES: dict[str, type] = {}
|
||||
|
||||
|
||||
def _register_module_types() -> None:
|
||||
"""Lazily populate the type registry to avoid circular imports."""
|
||||
if _MODULE_TYPES:
|
||||
return
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.webhook import WebhookModule
|
||||
|
||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
|
||||
_MODULE_TYPES["bot"] = BotModule
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
|
||||
|
||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||
"""Check a single filter value (channels or contacts) against a key.
|
||||
|
||||
Supported shapes:
|
||||
"all" -> True
|
||||
"none" -> False
|
||||
["key1", "key2"] -> key in list (only listed)
|
||||
{"except": ["key1", "key2"]} -> key not in list (all except listed)
|
||||
"""
|
||||
if filter_value == "all":
|
||||
return True
|
||||
if filter_value == "none":
|
||||
return False
|
||||
if isinstance(filter_value, list):
|
||||
return key in filter_value
|
||||
if isinstance(filter_value, dict) and "except" in filter_value:
|
||||
return key not in filter_value["except"]
|
||||
return False
|
||||
|
||||
|
||||
def _scope_matches_message(scope: dict, data: dict) -> bool:
|
||||
"""Check whether a message event matches the given scope."""
|
||||
messages = scope.get("messages", "none")
|
||||
if messages == "all":
|
||||
return True
|
||||
if messages == "none":
|
||||
return False
|
||||
if isinstance(messages, dict):
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
if msg_type == "CHAN":
|
||||
return _matches_filter(messages.get("channels", "none"), conversation_key)
|
||||
elif msg_type == "PRIV":
|
||||
return _matches_filter(messages.get("contacts", "none"), conversation_key)
|
||||
return False
|
||||
|
||||
|
||||
def _scope_matches_raw(scope: dict, _data: dict) -> bool:
|
||||
"""Check whether a raw packet event matches the given scope."""
|
||||
return scope.get("raw_packets", "none") == "all"
|
||||
|
||||
|
||||
class FanoutManager:
|
||||
"""Owns all active fanout modules and dispatches events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
async def load_from_db(self) -> None:
|
||||
"""Read enabled fanout_configs and instantiate modules."""
|
||||
_register_module_types()
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
configs = await FanoutConfigRepository.get_enabled()
|
||||
for cfg in configs:
|
||||
await self._start_module(cfg)
|
||||
|
||||
async def _start_module(self, cfg: dict[str, Any]) -> None:
|
||||
"""Instantiate and start a single module from a config dict."""
|
||||
config_id = cfg["id"]
|
||||
config_type = cfg["type"]
|
||||
config_blob = cfg["config"]
|
||||
scope = cfg["scope"]
|
||||
|
||||
# Skip bot modules when bots are disabled server-wide
|
||||
if config_type == "bot":
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
||||
return
|
||||
|
||||
cls = _MODULE_TYPES.get(config_type)
|
||||
if cls is None:
|
||||
logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
|
||||
return
|
||||
|
||||
try:
|
||||
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
||||
await module.start()
|
||||
self._modules[config_id] = (module, scope)
|
||||
logger.info(
|
||||
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to start fanout module %s", config_id)
|
||||
|
||||
async def reload_config(self, config_id: str) -> None:
|
||||
"""Stop old module (if any) and start updated config."""
|
||||
lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
|
||||
async with lock:
|
||||
await self.remove_config(config_id)
|
||||
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
cfg = await FanoutConfigRepository.get(config_id)
|
||||
if cfg is None or not cfg["enabled"]:
|
||||
return
|
||||
await self._start_module(cfg)
|
||||
|
||||
async def remove_config(self, config_id: str) -> None:
|
||||
"""Stop and remove a module."""
|
||||
entry = self._modules.pop(config_id, None)
|
||||
if entry is not None:
|
||||
module, _ = entry
|
||||
try:
|
||||
await module.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping fanout module %s", config_id)
|
||||
|
||||
async def _dispatch_matching(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
matcher: Any,
|
||||
handler_name: str,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
"""Dispatch to all matching modules concurrently."""
|
||||
tasks = []
|
||||
for config_id, (module, scope) in list(self._modules.items()):
|
||||
if matcher(scope, data):
|
||||
tasks.append(self._run_handler(config_id, module, handler_name, data, log_label))
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _run_handler(
|
||||
self,
|
||||
config_id: str,
|
||||
module: FanoutModule,
|
||||
handler_name: str,
|
||||
data: dict,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
"""Run one module handler with per-module exception isolation."""
|
||||
try:
|
||||
handler = getattr(module, handler_name)
|
||||
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Fanout %s %s timed out after %.1fs; restarting module",
|
||||
config_id,
|
||||
log_label,
|
||||
_DISPATCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
await self._restart_module(config_id, module)
|
||||
except Exception:
|
||||
logger.exception("Fanout %s %s error", config_id, log_label)
|
||||
|
||||
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
||||
"""Restart a timed-out module if it is still the active instance."""
|
||||
lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
|
||||
async with lock:
|
||||
entry = self._modules.get(config_id)
|
||||
if entry is None or entry[0] is not module:
|
||||
return
|
||||
try:
|
||||
await module.stop()
|
||||
await module.start()
|
||||
except Exception:
|
||||
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
||||
self._modules.pop(config_id, None)
|
||||
|
||||
async def broadcast_message(self, data: dict) -> None:
|
||||
"""Dispatch a decoded message to modules whose scope matches."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_scope_matches_message,
|
||||
handler_name="on_message",
|
||||
log_label="on_message",
|
||||
)
|
||||
|
||||
async def broadcast_raw(self, data: dict) -> None:
|
||||
"""Dispatch a raw packet to modules whose scope matches."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_scope_matches_raw,
|
||||
handler_name="on_raw",
|
||||
log_label="on_raw",
|
||||
)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
"""Shutdown all modules."""
|
||||
for config_id, (module, _) in list(self._modules.items()):
|
||||
try:
|
||||
await module.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping fanout module %s", config_id)
|
||||
self._modules.clear()
|
||||
self._restart_locks.clear()
|
||||
|
||||
def get_statuses(self) -> dict[str, dict[str, str]]:
|
||||
"""Return status info for each active module."""
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for config_id, (module, _) in self._modules.items():
|
||||
info = _configs_cache.get(config_id, {})
|
||||
result[config_id] = {
|
||||
"name": info.get("name", config_id),
|
||||
"type": info.get("type", "unknown"),
|
||||
"status": module.status,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
fanout_manager = FanoutManager()
|
||||
@@ -1,91 +0,0 @@
|
||||
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrivateMqttSettings(Protocol):
|
||||
"""Attributes expected on the settings object for the private MQTT publisher."""
|
||||
|
||||
mqtt_broker_host: str
|
||||
mqtt_broker_port: int
|
||||
mqtt_username: str
|
||||
mqtt_password: str
|
||||
mqtt_use_tls: bool
|
||||
mqtt_tls_insecure: bool
|
||||
mqtt_publish_messages: bool
|
||||
mqtt_publish_raw_packets: bool
|
||||
|
||||
|
||||
class MqttPublisher(BaseMqttPublisher):
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
_backoff_max = 30
|
||||
_log_prefix = "MQTT"
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
"""Check if MQTT is configured and has something to publish."""
|
||||
s: PrivateMqttSettings | None = self._settings
|
||||
return bool(
|
||||
s and s.mqtt_broker_host and (s.mqtt_publish_messages or s.mqtt_publish_raw_packets)
|
||||
)
|
||||
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
s: PrivateMqttSettings = settings # type: ignore[assignment]
|
||||
return {
|
||||
"hostname": s.mqtt_broker_host,
|
||||
"port": s.mqtt_broker_port,
|
||||
"username": s.mqtt_username or None,
|
||||
"password": s.mqtt_password or None,
|
||||
"tls_context": self._build_tls_context(s),
|
||||
}
|
||||
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
s: PrivateMqttSettings = settings # type: ignore[assignment]
|
||||
return ("MQTT connected", f"{s.mqtt_broker_host}:{s.mqtt_broker_port}")
|
||||
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
return ("MQTT connection failure", "Please correct the settings or disable.")
|
||||
|
||||
@staticmethod
|
||||
def _build_tls_context(settings: PrivateMqttSettings) -> ssl.SSLContext | None:
|
||||
"""Build TLS context from settings, or None if TLS is disabled."""
|
||||
if not settings.mqtt_use_tls:
|
||||
return None
|
||||
ctx = ssl.create_default_context()
|
||||
if settings.mqtt_tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a decrypted message."""
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "unknown")
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"{prefix}/dm:{conversation_key}"
|
||||
elif msg_type == "CHAN":
|
||||
return f"{prefix}/gm:{conversation_key}"
|
||||
return f"{prefix}/message:{conversation_key}"
|
||||
|
||||
|
||||
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a raw packet."""
|
||||
info = data.get("decrypted_info")
|
||||
if info and isinstance(info, dict):
|
||||
contact_key = info.get("contact_key")
|
||||
channel_key = info.get("channel_key")
|
||||
if contact_key:
|
||||
return f"{prefix}/raw/dm:{contact_key}"
|
||||
if channel_key:
|
||||
return f"{prefix}/raw/gm:{channel_key}"
|
||||
return f"{prefix}/raw/unrouted"
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Shared base class for MQTT publisher lifecycle management.
|
||||
|
||||
Both ``MqttPublisher`` (private broker) and ``CommunityMqttPublisher``
|
||||
(community aggregator) inherit from ``BaseMqttPublisher``, which owns
|
||||
the connection-loop skeleton, reconnect/backoff logic, and publish method.
|
||||
Subclasses override a small set of hooks to control configuration checks,
|
||||
client construction, toast messages, and optional wait-loop behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
class BaseMqttPublisher(ABC):
|
||||
"""Base class for MQTT publishers with shared lifecycle management.
|
||||
|
||||
Subclasses implement the abstract hooks to control configuration checks,
|
||||
client construction, toast messages, and optional wait-loop behavior.
|
||||
|
||||
The settings type is duck-typed — each subclass defines a Protocol
|
||||
describing the attributes it expects (e.g. ``PrivateMqttSettings``,
|
||||
``CommunityMqttSettings``). Callers pass ``SimpleNamespace`` instances
|
||||
that satisfy the protocol.
|
||||
"""
|
||||
|
||||
_backoff_max: int = 30
|
||||
_log_prefix: str = "MQTT"
|
||||
_not_configured_timeout: float | None = None # None = block forever
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: Any = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
async def start(self, settings: object) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background task and disconnect."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
|
||||
async def restart(self, settings: object) -> None:
|
||||
"""Called when settings change — stop + start."""
|
||||
await self.stop()
|
||||
await self.start(settings)
|
||||
|
||||
async def publish(self, topic: str, payload: dict[str, Any], *, retain: bool = False) -> None:
|
||||
"""Publish a JSON payload. Drops silently if not connected."""
|
||||
if self._client is None or not self.connected:
|
||||
return
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload), retain=retain)
|
||||
except Exception as e:
|
||||
logger.warning("%s publish failed on %s: %s", self._log_prefix, topic, e)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
|
||||
# ── Abstract hooks ─────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
def _is_configured(self) -> bool:
|
||||
"""Return True when this publisher should attempt to connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_connected(self, settings: object) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the success toast on connect."""
|
||||
|
||||
@abstractmethod
|
||||
def _on_error(self) -> tuple[str, str]:
|
||||
"""Return ``(title, detail)`` for the error toast on connect failure."""
|
||||
|
||||
# ── Optional hooks ─────────────────────────────────────────────────
|
||||
|
||||
def _should_break_wait(self, elapsed: float) -> bool:
|
||||
"""Return True to break the inner wait (e.g. token expiry)."""
|
||||
return False
|
||||
|
||||
async def _pre_connect(self, settings: object) -> bool:
|
||||
"""Called before connecting. Return True to proceed, False to retry."""
|
||||
return True
|
||||
|
||||
def _on_not_configured(self) -> None:
|
||||
"""Called each time the loop finds the publisher not configured."""
|
||||
return # no-op by default; subclasses may override
|
||||
|
||||
async def _on_connected_async(self, settings: object) -> None:
|
||||
"""Async hook called after connection succeeds (before health broadcast).
|
||||
|
||||
Subclasses can override to publish messages immediately after connecting.
|
||||
"""
|
||||
return # no-op by default
|
||||
|
||||
async def _on_periodic_wake(self, elapsed: float) -> None:
|
||||
"""Called every ~60s while connected. Subclasses may override."""
|
||||
return
|
||||
|
||||
# ── Connection loop ────────────────────────────────────────────────
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Background loop: connect, wait for version change, reconnect on failure."""
|
||||
from app.websocket import broadcast_error, broadcast_success
|
||||
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
while True:
|
||||
if not self._is_configured():
|
||||
self._on_not_configured()
|
||||
self.connected = False
|
||||
self._client = None
|
||||
self._version_event.clear()
|
||||
try:
|
||||
if self._not_configured_timeout is None:
|
||||
await self._version_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self._version_event.wait(),
|
||||
timeout=self._not_configured_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
continue
|
||||
|
||||
settings = self._settings
|
||||
assert settings is not None # guaranteed by _is_configured()
|
||||
version_at_connect = self._settings_version
|
||||
|
||||
try:
|
||||
if not await self._pre_connect(settings):
|
||||
continue
|
||||
|
||||
client_kwargs = self._build_client_kwargs(settings)
|
||||
connect_time = time.monotonic()
|
||||
|
||||
async with aiomqtt.Client(**client_kwargs) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
title, detail = self._on_connected(settings)
|
||||
broadcast_success(title, detail)
|
||||
await self._on_connected_async(settings)
|
||||
_broadcast_health()
|
||||
|
||||
# Wait until cancelled or settings version changes.
|
||||
# The 60s timeout is a housekeeping wake-up; actual connection
|
||||
# liveness is handled by paho-mqtt's keepalive mechanism.
|
||||
while self._settings_version == version_at_connect:
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.monotonic() - connect_time
|
||||
await self._on_periodic_wake(elapsed)
|
||||
if self._should_break_wait(elapsed):
|
||||
break
|
||||
continue
|
||||
|
||||
# async with exited — client is now closed
|
||||
self._client = None
|
||||
self.connected = False
|
||||
_broadcast_health()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
logger.warning(
|
||||
"%s connection error: %s (reconnecting in %ds)",
|
||||
self._log_prefix,
|
||||
e,
|
||||
backoff,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
backoff = min(backoff * 2, self._backoff_max)
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Fanout module wrapping the community MQTT publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
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:
|
||||
"""Map a fanout config blob to a settings namespace for the CommunityMqttPublisher."""
|
||||
return 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."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._publisher = CommunityMqttPublisher()
|
||||
|
||||
async def start(self) -> None:
|
||||
settings = _config_to_settings(self.config)
|
||||
await self._publisher.start(settings)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._publisher.stop()
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
# Community MQTT only publishes raw packets, not decoded messages.
|
||||
pass
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if not self._publisher.connected or self._publisher._settings is None:
|
||||
return
|
||||
await _publish_community_packet(self._publisher, self.config, data)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._publisher._is_configured():
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
return "disconnected"
|
||||
|
||||
|
||||
async def _publish_community_packet(
|
||||
publisher: CommunityMqttPublisher,
|
||||
config: dict,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Format and publish a raw packet to the community broker."""
|
||||
try:
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
return
|
||||
|
||||
pubkey_hex = public_key.hex().upper()
|
||||
|
||||
device_name = ""
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
packet = _format_raw_packet(data, device_name, pubkey_hex)
|
||||
iata = config.get("iata", "").upper().strip()
|
||||
if not _IATA_RE.fullmatch(iata):
|
||||
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
|
||||
return
|
||||
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)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Fanout module wrapping the private MQTT publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _config_to_settings(config: dict) -> SimpleNamespace:
|
||||
"""Map a fanout config blob to a settings namespace for the MqttPublisher."""
|
||||
return SimpleNamespace(
|
||||
mqtt_broker_host=config.get("broker_host", ""),
|
||||
mqtt_broker_port=config.get("broker_port", 1883),
|
||||
mqtt_username=config.get("username", ""),
|
||||
mqtt_password=config.get("password", ""),
|
||||
mqtt_use_tls=config.get("use_tls", False),
|
||||
mqtt_tls_insecure=config.get("tls_insecure", False),
|
||||
mqtt_topic_prefix=config.get("topic_prefix", "meshcore"),
|
||||
mqtt_publish_messages=True,
|
||||
mqtt_publish_raw_packets=True,
|
||||
)
|
||||
|
||||
|
||||
class MqttPrivateModule(FanoutModule):
|
||||
"""Wraps an MqttPublisher instance for private MQTT forwarding."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._publisher = MqttPublisher()
|
||||
|
||||
async def start(self) -> None:
|
||||
settings = _config_to_settings(self.config)
|
||||
await self._publisher.start(settings)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self._publisher.stop()
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
if not self._publisher.connected or self._publisher._settings is None:
|
||||
return
|
||||
prefix = self.config.get("topic_prefix", "meshcore")
|
||||
topic = _build_message_topic(prefix, data)
|
||||
await self._publisher.publish(topic, data)
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
if not self._publisher.connected or self._publisher._settings is None:
|
||||
return
|
||||
prefix = self.config.get("topic_prefix", "meshcore")
|
||||
topic = _build_raw_packet_topic(prefix, data)
|
||||
await self._publisher.publish(topic, data)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("broker_host"):
|
||||
return "disconnected"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Fanout module for webhook (HTTP POST) delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookModule(FanoutModule):
|
||||
"""Delivers message data to an HTTP endpoint via POST (or configurable method)."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
||||
self._last_error = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
await self._send(data, event_type="message")
|
||||
|
||||
async def _send(self, data: dict, *, event_type: str) -> None:
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
url = self.config.get("url", "")
|
||||
if not url:
|
||||
return
|
||||
|
||||
method = self.config.get("method", "POST").upper()
|
||||
extra_headers = self.config.get("headers", {})
|
||||
hmac_secret = self.config.get("hmac_secret", "")
|
||||
hmac_header = self.config.get("hmac_header", "X-Webhook-Signature")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": event_type,
|
||||
**extra_headers,
|
||||
}
|
||||
|
||||
body_bytes = json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
|
||||
|
||||
if hmac_secret:
|
||||
sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
|
||||
headers[hmac_header or "X-Webhook-Signature"] = f"sha256={sig}"
|
||||
|
||||
try:
|
||||
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
||||
resp.raise_for_status()
|
||||
self._last_error = None
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._last_error = f"HTTP {exc.response.status_code}"
|
||||
logger.warning(
|
||||
"Webhook %s returned %s for %s",
|
||||
self.config_id,
|
||||
exc.response.status_code,
|
||||
url,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("url"):
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
125
app/loopback.py
Normal file
125
app/loopback.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Loopback transport: bridges a browser-side serial/BLE connection over WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from starlette.websockets import WebSocket, WebSocketState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoopbackTransport:
|
||||
"""ConnectionProtocol implementation that tunnels bytes over a WebSocket.
|
||||
|
||||
For serial mode, applies the same 0x3c + 2-byte LE size framing that
|
||||
meshcore's SerialConnection uses. For BLE mode, passes raw bytes through
|
||||
(matching BLEConnection behaviour).
|
||||
"""
|
||||
|
||||
def __init__(self, websocket: WebSocket, mode: Literal["serial", "ble"]) -> None:
|
||||
self._ws = websocket
|
||||
self._mode = mode
|
||||
self._reader: Any = None
|
||||
self._disconnect_callback: Any = None
|
||||
|
||||
# Serial framing state (mirrors meshcore serial_cx.py handle_rx)
|
||||
self._header = b""
|
||||
self._inframe = b""
|
||||
self._frame_started = False
|
||||
self._frame_size = 0
|
||||
|
||||
# -- ConnectionProtocol methods ------------------------------------------
|
||||
|
||||
async def connect(self) -> str:
|
||||
"""No-op — the WebSocket is already established."""
|
||||
info = f"Loopback ({self._mode})"
|
||||
logger.info("Loopback transport connected: %s", info)
|
||||
return info
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Ask the browser to release the hardware and close the WS."""
|
||||
try:
|
||||
if self._ws.client_state == WebSocketState.CONNECTED:
|
||||
await self._ws.send_json({"type": "disconnect"})
|
||||
except Exception:
|
||||
pass # WS may already be closed
|
||||
|
||||
async def send(self, data: Any) -> None:
|
||||
"""Send data to the browser (which writes it to the physical radio).
|
||||
|
||||
Serial mode: prepend 0x3c + 2-byte LE size header.
|
||||
BLE mode: send raw bytes.
|
||||
"""
|
||||
try:
|
||||
if self._ws.client_state != WebSocketState.CONNECTED:
|
||||
return
|
||||
if self._mode == "serial":
|
||||
size = len(data)
|
||||
pkt = b"\x3c" + size.to_bytes(2, byteorder="little") + bytes(data)
|
||||
await self._ws.send_bytes(pkt)
|
||||
else:
|
||||
await self._ws.send_bytes(bytes(data))
|
||||
except Exception as e:
|
||||
logger.debug("Loopback send error: %s", e)
|
||||
|
||||
def set_reader(self, reader: Any) -> None:
|
||||
self._reader = reader
|
||||
|
||||
def set_disconnect_callback(self, callback: Any) -> None:
|
||||
self._disconnect_callback = callback
|
||||
|
||||
# -- Incoming data from browser ------------------------------------------
|
||||
|
||||
def handle_rx(self, data: bytes) -> None:
|
||||
"""Process bytes received from the browser.
|
||||
|
||||
Serial mode: accumulate bytes, strip framing, deliver payload.
|
||||
BLE mode: deliver raw bytes directly.
|
||||
"""
|
||||
if self._mode == "serial":
|
||||
self._handle_rx_serial(data)
|
||||
else:
|
||||
self._handle_rx_ble(data)
|
||||
|
||||
def _handle_rx_ble(self, data: bytes) -> None:
|
||||
if self._reader is not None:
|
||||
asyncio.create_task(self._reader.handle_rx(data))
|
||||
|
||||
def _handle_rx_serial(self, data: bytes) -> None:
|
||||
"""Mirror meshcore's SerialConnection.handle_rx state machine."""
|
||||
raw = bytes(data)
|
||||
headerlen = len(self._header)
|
||||
|
||||
if not self._frame_started:
|
||||
if len(raw) >= 3 - headerlen:
|
||||
self._header = self._header + raw[: 3 - headerlen]
|
||||
self._frame_started = True
|
||||
self._frame_size = int.from_bytes(self._header[1:], byteorder="little")
|
||||
remainder = raw[3 - headerlen :]
|
||||
# Reset header for next frame
|
||||
self._header = b""
|
||||
if remainder:
|
||||
self._handle_rx_serial(remainder)
|
||||
else:
|
||||
self._header = self._header + raw
|
||||
else:
|
||||
framelen = len(self._inframe)
|
||||
if framelen + len(raw) < self._frame_size:
|
||||
self._inframe = self._inframe + raw
|
||||
else:
|
||||
self._inframe = self._inframe + raw[: self._frame_size - framelen]
|
||||
if self._reader is not None:
|
||||
asyncio.create_task(self._reader.handle_rx(self._inframe))
|
||||
remainder = raw[self._frame_size - framelen :]
|
||||
self._frame_started = False
|
||||
self._inframe = b""
|
||||
if remainder:
|
||||
self._handle_rx_serial(remainder)
|
||||
|
||||
def reset_framing(self) -> None:
|
||||
"""Reset the serial framing state machine."""
|
||||
self._header = b""
|
||||
self._inframe = b""
|
||||
self._frame_started = False
|
||||
self._frame_size = 0
|
||||
49
app/main.py
49
app/main.py
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -19,8 +18,8 @@ from app.radio_sync import (
|
||||
from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
fanout,
|
||||
health,
|
||||
loopback,
|
||||
messages,
|
||||
packets,
|
||||
radio,
|
||||
@@ -35,22 +34,6 @@ 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(broadcast_on_success=False)
|
||||
if connected:
|
||||
await radio_manager.post_connect_setup()
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
logger.info("Connected to radio")
|
||||
else:
|
||||
logger.warning("Failed to connect to radio on startup")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Manage database and radio connection lifecycle."""
|
||||
@@ -64,30 +47,30 @@ 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()
|
||||
|
||||
# Start fanout modules (MQTT, etc.) from database configs
|
||||
from app.fanout.manager import fanout_manager
|
||||
# Start MQTT publisher if configured
|
||||
from app.mqtt import mqtt_publisher
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
try:
|
||||
await fanout_manager.load_from_db()
|
||||
mqtt_settings = await AppSettingsRepository.get()
|
||||
await mqtt_publisher.start(mqtt_settings)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start fanout modules: %s", e)
|
||||
|
||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||
app.state.startup_radio_task = startup_radio_task
|
||||
logger.warning("Failed to start MQTT publisher: %s", e)
|
||||
|
||||
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 mqtt_publisher.stop()
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_message_polling()
|
||||
await stop_periodic_advert()
|
||||
@@ -134,7 +117,6 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
app.include_router(repeaters.router, prefix="/api")
|
||||
@@ -145,6 +127,7 @@ app.include_router(read_state.router, prefix="/api")
|
||||
app.include_router(settings.router, prefix="/api")
|
||||
app.include_router(statistics.router, prefix="/api")
|
||||
app.include_router(ws.router, prefix="/api")
|
||||
app.include_router(loopback.router, prefix="/api")
|
||||
|
||||
# Serve frontend static files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
|
||||
@@ -254,69 +254,6 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 31)
|
||||
applied += 1
|
||||
|
||||
# Migration 32: Add community MQTT columns to app_settings
|
||||
if version < 32:
|
||||
logger.info("Applying migration 32: add community MQTT columns to app_settings")
|
||||
await _migrate_032_add_community_mqtt_columns(conn)
|
||||
await set_version(conn, 32)
|
||||
applied += 1
|
||||
|
||||
# Migration 33: Seed #remoteterm channel on initial install
|
||||
if version < 33:
|
||||
logger.info("Applying migration 33: seed #remoteterm channel")
|
||||
await _migrate_033_seed_remoteterm_channel(conn)
|
||||
await set_version(conn, 33)
|
||||
applied += 1
|
||||
|
||||
# Migration 34: Add flood_scope column to app_settings
|
||||
if version < 34:
|
||||
logger.info("Applying migration 34: add flood_scope column to app_settings")
|
||||
await _migrate_034_add_flood_scope(conn)
|
||||
await set_version(conn, 34)
|
||||
applied += 1
|
||||
|
||||
# Migration 35: Add blocked_keys and blocked_names columns to app_settings
|
||||
if version < 35:
|
||||
logger.info("Applying migration 35: add blocked_keys and blocked_names to app_settings")
|
||||
await _migrate_035_add_block_lists(conn)
|
||||
await set_version(conn, 35)
|
||||
applied += 1
|
||||
|
||||
# Migration 36: Create fanout_configs table and migrate existing MQTT settings
|
||||
if version < 36:
|
||||
logger.info("Applying migration 36: create fanout_configs and migrate MQTT settings")
|
||||
await _migrate_036_create_fanout_configs(conn)
|
||||
await set_version(conn, 36)
|
||||
applied += 1
|
||||
|
||||
# Migration 37: Migrate bots from app_settings to fanout_configs
|
||||
if version < 37:
|
||||
logger.info("Applying migration 37: migrate bots to fanout_configs")
|
||||
await _migrate_037_bots_to_fanout(conn)
|
||||
await set_version(conn, 37)
|
||||
applied += 1
|
||||
|
||||
# Migration 38: Drop legacy MQTT, community MQTT, and bots columns from app_settings
|
||||
if version < 38:
|
||||
logger.info("Applying migration 38: drop legacy MQTT/bot columns from app_settings")
|
||||
await _migrate_038_drop_legacy_columns(conn)
|
||||
await set_version(conn, 38)
|
||||
applied += 1
|
||||
|
||||
# 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
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -452,14 +389,39 @@ 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 using canonical framing validation.
|
||||
Extract payload from a raw packet for hashing (migration-local copy of decoder logic).
|
||||
|
||||
Returns the payload bytes, or None if packet is malformed.
|
||||
"""
|
||||
from app.path_utils import parse_packet_envelope
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.payload if envelope is not None else None
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Skip path bytes
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
offset += path_length
|
||||
|
||||
# Rest is payload (may be empty, matching decoder.py behavior)
|
||||
return raw_packet[offset:]
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _migrate_005_backfill_payload_hashes(conn: aiosqlite.Connection) -> None:
|
||||
@@ -609,14 +571,38 @@ 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 using canonical framing validation.
|
||||
Extract path hex string from a raw packet (migration-local copy of decoder logic).
|
||||
|
||||
Returns the path as a hex string, or None if packet is malformed.
|
||||
"""
|
||||
from app.path_utils import parse_packet_envelope
|
||||
if len(raw_packet) < 2:
|
||||
return None
|
||||
|
||||
envelope = parse_packet_envelope(raw_packet)
|
||||
return envelope.path.hex() if envelope is not None else None
|
||||
try:
|
||||
header = raw_packet[0]
|
||||
route_type = header & 0x03
|
||||
offset = 1
|
||||
|
||||
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
|
||||
if route_type in (0x00, 0x03):
|
||||
if len(raw_packet) < offset + 4:
|
||||
return None
|
||||
offset += 4
|
||||
|
||||
# Get path length
|
||||
if len(raw_packet) < offset + 1:
|
||||
return None
|
||||
path_length = raw_packet[offset]
|
||||
offset += 1
|
||||
|
||||
# Extract path bytes
|
||||
if len(raw_packet) < offset + path_length:
|
||||
return None
|
||||
path_bytes = raw_packet[offset : offset + path_length]
|
||||
|
||||
return path_bytes.hex()
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _migrate_007_backfill_message_paths(conn: aiosqlite.Connection) -> None:
|
||||
@@ -1643,7 +1629,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, path_len),
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
@@ -1667,7 +1653,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, path_len),
|
||||
UNIQUE(public_key, path_hex),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
)
|
||||
"""
|
||||
@@ -1905,480 +1891,3 @@ async def _migrate_031_add_mqtt_columns(conn: aiosqlite.Connection) -> None:
|
||||
await conn.execute(f"ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_032_add_community_mqtt_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Add community MQTT configuration columns to app_settings."""
|
||||
# Guard: app_settings may not exist in partial-schema test setups
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
new_columns = [
|
||||
("community_mqtt_enabled", "INTEGER DEFAULT 0"),
|
||||
("community_mqtt_iata", "TEXT DEFAULT ''"),
|
||||
("community_mqtt_broker_host", "TEXT DEFAULT 'mqtt-us-v1.letsmesh.net'"),
|
||||
("community_mqtt_broker_port", "INTEGER DEFAULT 443"),
|
||||
("community_mqtt_email", "TEXT DEFAULT ''"),
|
||||
]
|
||||
|
||||
for col_name, col_def in new_columns:
|
||||
if col_name not in columns:
|
||||
await conn.execute(f"ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_033_seed_remoteterm_channel(conn: aiosqlite.Connection) -> None:
|
||||
"""Seed the #remoteterm hashtag channel so new installs have it by default.
|
||||
|
||||
Uses INSERT OR IGNORE so it's a no-op if the channel already exists
|
||||
(e.g. existing users who already added it manually). The channels table
|
||||
is created by the base schema before migrations run, so it always exists
|
||||
in production.
|
||||
"""
|
||||
try:
|
||||
await conn.execute(
|
||||
"INSERT OR IGNORE INTO channels (key, name, is_hashtag, on_radio) VALUES (?, ?, ?, ?)",
|
||||
("8959AE053F2201801342A1DBDDA184F6", "#remoteterm", 1, 0),
|
||||
)
|
||||
await conn.commit()
|
||||
except Exception:
|
||||
logger.debug("Skipping #remoteterm seed (channels table not ready)")
|
||||
|
||||
|
||||
async def _migrate_034_add_flood_scope(conn: aiosqlite.Connection) -> None:
|
||||
"""Add flood_scope column to app_settings for outbound region tagging.
|
||||
|
||||
Empty string means disabled (no scope set, messages sent unscoped).
|
||||
"""
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN flood_scope TEXT DEFAULT ''")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("flood_scope column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping flood_scope migration")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_035_add_block_lists(conn: aiosqlite.Connection) -> None:
|
||||
"""Add blocked_keys and blocked_names columns to app_settings.
|
||||
|
||||
These store JSON arrays of blocked public keys and display names.
|
||||
Blocking hides messages from the UI but does not affect MQTT or bots.
|
||||
"""
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_keys TEXT DEFAULT '[]'")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("blocked_keys column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping blocked_keys migration")
|
||||
else:
|
||||
raise
|
||||
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_names TEXT DEFAULT '[]'")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("blocked_names column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping blocked_names migration")
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None:
|
||||
"""Create fanout_configs table and migrate existing MQTT settings.
|
||||
|
||||
Reads existing MQTT settings from app_settings and creates corresponding
|
||||
fanout_configs rows. Old columns are NOT dropped (rollback safety).
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
# 1. Create fanout_configs table
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS fanout_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
scope TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. Read existing MQTT settings
|
||||
try:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
|
||||
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets,
|
||||
community_mqtt_enabled, community_mqtt_iata,
|
||||
community_mqtt_broker_host, community_mqtt_broker_port,
|
||||
community_mqtt_email
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
|
||||
if row is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
sort_order = 0
|
||||
|
||||
# 3. Migrate private MQTT if configured
|
||||
broker_host = row["mqtt_broker_host"] or ""
|
||||
if broker_host:
|
||||
publish_messages = bool(row["mqtt_publish_messages"])
|
||||
publish_raw = bool(row["mqtt_publish_raw_packets"])
|
||||
enabled = publish_messages or publish_raw
|
||||
|
||||
config = {
|
||||
"broker_host": broker_host,
|
||||
"broker_port": row["mqtt_broker_port"] or 1883,
|
||||
"username": row["mqtt_username"] or "",
|
||||
"password": row["mqtt_password"] or "",
|
||||
"use_tls": bool(row["mqtt_use_tls"]),
|
||||
"tls_insecure": bool(row["mqtt_tls_insecure"]),
|
||||
"topic_prefix": row["mqtt_topic_prefix"] or "meshcore",
|
||||
}
|
||||
|
||||
scope = {
|
||||
"messages": "all" if publish_messages else "none",
|
||||
"raw_packets": "all" if publish_raw else "none",
|
||||
}
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
"mqtt_private",
|
||||
"Private MQTT",
|
||||
1 if enabled else 0,
|
||||
json.dumps(config),
|
||||
json.dumps(scope),
|
||||
sort_order,
|
||||
now,
|
||||
),
|
||||
)
|
||||
sort_order += 1
|
||||
logger.info("Migrated private MQTT settings to fanout_configs (enabled=%s)", enabled)
|
||||
|
||||
# 4. Migrate community MQTT if enabled OR configured (preserve disabled-but-configured)
|
||||
community_enabled = bool(row["community_mqtt_enabled"])
|
||||
community_iata = row["community_mqtt_iata"] or ""
|
||||
community_host = row["community_mqtt_broker_host"] or ""
|
||||
community_email = row["community_mqtt_email"] or ""
|
||||
community_has_config = bool(
|
||||
community_iata
|
||||
or community_email
|
||||
or (community_host and community_host != "mqtt-us-v1.letsmesh.net")
|
||||
)
|
||||
if community_enabled or community_has_config:
|
||||
config = {
|
||||
"broker_host": community_host or "mqtt-us-v1.letsmesh.net",
|
||||
"broker_port": row["community_mqtt_broker_port"] or 443,
|
||||
"iata": community_iata,
|
||||
"email": community_email,
|
||||
}
|
||||
|
||||
scope = {
|
||||
"messages": "none",
|
||||
"raw_packets": "all",
|
||||
}
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
"mqtt_community",
|
||||
"Community MQTT",
|
||||
1 if community_enabled else 0,
|
||||
json.dumps(config),
|
||||
json.dumps(scope),
|
||||
sort_order,
|
||||
now,
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"Migrated community MQTT settings to fanout_configs (enabled=%s)", community_enabled
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
|
||||
"""Migrate bots from app_settings.bots JSON to fanout_configs rows."""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
try:
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
|
||||
if row is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
bots_json = row["bots"] or "[]"
|
||||
try:
|
||||
bots = json.loads(bots_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
bots = []
|
||||
|
||||
if not bots:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
# Use sort_order starting at 200 to place bots after MQTT configs (0-99)
|
||||
for i, bot in enumerate(bots):
|
||||
bot_name = bot.get("name") or f"Bot {i + 1}"
|
||||
bot_enabled = bool(bot.get("enabled", False))
|
||||
bot_code = bot.get("code", "")
|
||||
|
||||
config_blob = json.dumps({"code": bot_code})
|
||||
scope = json.dumps({"messages": "all", "raw_packets": "none"})
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
|
||||
VALUES (?, 'bot', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
bot_name,
|
||||
1 if bot_enabled else 0,
|
||||
config_blob,
|
||||
scope,
|
||||
200 + i,
|
||||
now,
|
||||
),
|
||||
)
|
||||
logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Drop legacy MQTT, community MQTT, and bots columns from app_settings.
|
||||
|
||||
These columns were migrated to fanout_configs in migrations 36 and 37.
|
||||
SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
|
||||
the columns remain but are harmless (no longer read or written).
|
||||
"""
|
||||
# Check if app_settings table exists (some test DBs may not have it)
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
columns_to_drop = [
|
||||
"bots",
|
||||
"mqtt_broker_host",
|
||||
"mqtt_broker_port",
|
||||
"mqtt_username",
|
||||
"mqtt_password",
|
||||
"mqtt_use_tls",
|
||||
"mqtt_tls_insecure",
|
||||
"mqtt_topic_prefix",
|
||||
"mqtt_publish_messages",
|
||||
"mqtt_publish_raw_packets",
|
||||
"community_mqtt_enabled",
|
||||
"community_mqtt_iata",
|
||||
"community_mqtt_broker_host",
|
||||
"community_mqtt_broker_port",
|
||||
"community_mqtt_email",
|
||||
]
|
||||
|
||||
for column in columns_to_drop:
|
||||
try:
|
||||
await conn.execute(f"ALTER TABLE app_settings DROP COLUMN {column}")
|
||||
logger.debug("Dropped %s from app_settings", column)
|
||||
except aiosqlite.OperationalError as e:
|
||||
error_msg = str(e).lower()
|
||||
if "no such column" in error_msg:
|
||||
logger.debug("app_settings.%s already dropped, skipping", column)
|
||||
elif "syntax error" in error_msg or "drop column" in error_msg:
|
||||
logger.debug("SQLite doesn't support DROP COLUMN, %s column will remain", column)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_039_add_contact_out_path_hash_mode(conn: aiosqlite.Connection) -> None:
|
||||
"""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 cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
column_cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
columns = {row[1] for row in await column_cursor.fetchall()}
|
||||
|
||||
added_column = False
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
"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()
|
||||
|
||||
131
app/models.py
131
app/models.py
@@ -6,11 +6,10 @@ from pydantic import BaseModel, Field
|
||||
class Contact(BaseModel):
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int = 0
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
@@ -33,7 +32,6 @@ class Contact(BaseModel):
|
||||
"flags": self.flags,
|
||||
"out_path": self.last_path or "",
|
||||
"out_path_len": self.last_path_len,
|
||||
"out_path_hash_mode": self.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,
|
||||
@@ -53,10 +51,6 @@ class Contact(BaseModel):
|
||||
"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",
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
"lat": radio_data.get("adv_lat"),
|
||||
"lon": radio_data.get("adv_lon"),
|
||||
"last_advert": radio_data.get("last_advert"),
|
||||
@@ -85,8 +79,7 @@ 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 as a full hop identifier, or null for direct",
|
||||
default=None, description="First hop toward us (2-char hex), or null for direct"
|
||||
)
|
||||
first_seen: int = Field(description="Unix timestamp of first observation")
|
||||
last_seen: int = Field(description="Unix timestamp of most recent observation")
|
||||
@@ -152,43 +145,11 @@ class Channel(BaseModel):
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
|
||||
|
||||
class ChannelMessageCounts(BaseModel):
|
||||
"""Time-windowed message counts for a channel."""
|
||||
|
||||
last_1h: int = 0
|
||||
last_24h: int = 0
|
||||
last_48h: int = 0
|
||||
last_7d: int = 0
|
||||
all_time: int = 0
|
||||
|
||||
|
||||
class ChannelTopSender(BaseModel):
|
||||
"""A top sender in a channel over the last 24 hours."""
|
||||
|
||||
sender_name: str
|
||||
sender_key: str | None = None
|
||||
message_count: int
|
||||
|
||||
|
||||
class ChannelDetail(BaseModel):
|
||||
"""Comprehensive channel profile data."""
|
||||
|
||||
channel: Channel
|
||||
message_counts: ChannelMessageCounts = Field(default_factory=ChannelMessageCounts)
|
||||
first_message_at: int | None = None
|
||||
unique_sender_count: int = 0
|
||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessagePath(BaseModel):
|
||||
"""A single path that a message took to reach us."""
|
||||
|
||||
path: str = Field(description="Hex-encoded routing path")
|
||||
path: str = Field(description="Hex-encoded routing path (2 chars per hop)")
|
||||
received_at: int = Field(description="Unix timestamp when this path was received")
|
||||
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):
|
||||
@@ -203,17 +164,8 @@ class Message(BaseModel):
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class MessagesAroundResponse(BaseModel):
|
||||
messages: list[Message]
|
||||
has_older: bool
|
||||
has_newer: bool
|
||||
|
||||
|
||||
class RawPacketDecryptedInfo(BaseModel):
|
||||
@@ -411,6 +363,15 @@ class Favorite(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class BotConfig(BaseModel):
|
||||
"""Configuration for a single bot."""
|
||||
|
||||
id: str = Field(description="UUID for stable identity across renames/reorders")
|
||||
name: str = Field(description="User-editable name")
|
||||
enabled: bool = Field(default=False, description="Whether this bot is enabled")
|
||||
code: str = Field(default="", description="Python code for this bot")
|
||||
|
||||
|
||||
class UnreadCounts(BaseModel):
|
||||
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
|
||||
|
||||
@@ -462,31 +423,46 @@ class AppSettings(BaseModel):
|
||||
default=0,
|
||||
description="Unix timestamp of last advertisement sent (0 = never)",
|
||||
)
|
||||
flood_scope: str = Field(
|
||||
bots: list[BotConfig] = Field(
|
||||
default_factory=list,
|
||||
description="List of bot configurations",
|
||||
)
|
||||
mqtt_broker_host: str = Field(
|
||||
default="",
|
||||
description="Outbound flood scope / region name (empty = disabled, no tagging)",
|
||||
description="MQTT broker hostname (empty = disabled)",
|
||||
)
|
||||
blocked_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
mqtt_broker_port: int = Field(
|
||||
default=1883,
|
||||
description="MQTT broker port",
|
||||
)
|
||||
blocked_names: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
mqtt_username: str = Field(
|
||||
default="",
|
||||
description="MQTT username (optional)",
|
||||
)
|
||||
mqtt_password: str = Field(
|
||||
default="",
|
||||
description="MQTT password (optional)",
|
||||
)
|
||||
mqtt_use_tls: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use TLS for MQTT connection",
|
||||
)
|
||||
mqtt_tls_insecure: bool = Field(
|
||||
default=False,
|
||||
description="Skip TLS certificate verification (for self-signed certs)",
|
||||
)
|
||||
mqtt_topic_prefix: str = Field(
|
||||
default="meshcore",
|
||||
description="MQTT topic prefix",
|
||||
)
|
||||
mqtt_publish_messages: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish decrypted messages to MQTT",
|
||||
)
|
||||
mqtt_publish_raw_packets: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
|
||||
|
||||
class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
scope: dict
|
||||
sort_order: int = 0
|
||||
created_at: int = 0
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
@@ -501,16 +477,6 @@ 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
|
||||
@@ -524,4 +490,3 @@ class StatisticsResponse(BaseModel):
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
|
||||
223
app/mqtt.py
Normal file
223
app/mqtt.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from app.models import AppSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reconnect backoff: start at 5s, cap at 30s
|
||||
_BACKOFF_MIN = 5
|
||||
_BACKOFF_MAX = 30
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: aiomqtt.Client | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._settings: AppSettings | None = None
|
||||
self._settings_version: int = 0
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
|
||||
async def start(self, settings: AppSettings) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background task and disconnect."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
|
||||
async def restart(self, settings: AppSettings) -> None:
|
||||
"""Called when MQTT settings change — stop + start."""
|
||||
await self.stop()
|
||||
await self.start(settings)
|
||||
|
||||
async def publish(self, topic: str, payload: dict[str, Any]) -> None:
|
||||
"""Publish a JSON payload. Drops silently if not connected."""
|
||||
if self._client is None or not self.connected:
|
||||
return
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.warning("MQTT publish failed on %s: %s", topic, e)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
|
||||
def _mqtt_configured(self) -> bool:
|
||||
"""Check if MQTT is configured (broker host is set)."""
|
||||
return bool(self._settings and self._settings.mqtt_broker_host)
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Background loop: connect, wait, reconnect on failure."""
|
||||
from app.websocket import broadcast_error, broadcast_success
|
||||
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
while True:
|
||||
if not self._mqtt_configured():
|
||||
self.connected = False
|
||||
self._client = None
|
||||
# Wait until settings change (which might configure MQTT)
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await self._version_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
continue
|
||||
|
||||
settings = self._settings
|
||||
assert settings is not None # guaranteed by _mqtt_configured()
|
||||
version_at_connect = self._settings_version
|
||||
|
||||
try:
|
||||
tls_context = self._build_tls_context(settings)
|
||||
|
||||
async with aiomqtt.Client(
|
||||
hostname=settings.mqtt_broker_host,
|
||||
port=settings.mqtt_broker_port,
|
||||
username=settings.mqtt_username or None,
|
||||
password=settings.mqtt_password or None,
|
||||
tls_context=tls_context,
|
||||
) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
broadcast_success(
|
||||
"MQTT connected",
|
||||
f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}",
|
||||
)
|
||||
_broadcast_mqtt_health()
|
||||
|
||||
# Wait until cancelled or settings version changes.
|
||||
# The 60s timeout is a housekeeping wake-up; actual connection
|
||||
# liveness is handled by paho-mqtt's keepalive mechanism.
|
||||
while self._settings_version == version_at_connect:
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
|
||||
broadcast_error(
|
||||
"MQTT connection failure",
|
||||
"Please correct the settings or disable.",
|
||||
)
|
||||
_broadcast_mqtt_health()
|
||||
logger.warning("MQTT connection error: %s (reconnecting in %ds)", e, backoff)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
backoff = min(backoff * 2, _BACKOFF_MAX)
|
||||
|
||||
@staticmethod
|
||||
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
|
||||
"""Build TLS context from settings, or None if TLS is disabled."""
|
||||
if not settings.mqtt_use_tls:
|
||||
return None
|
||||
ctx = ssl.create_default_context()
|
||||
if settings.mqtt_tls_insecure:
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
mqtt_publisher = MqttPublisher()
|
||||
|
||||
|
||||
def _broadcast_mqtt_health() -> None:
|
||||
"""Push updated health (including mqtt_status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
|
||||
if event_type not in ("message", "raw_packet"):
|
||||
return
|
||||
if not mqtt_publisher.connected or mqtt_publisher._settings is None:
|
||||
return
|
||||
asyncio.create_task(_mqtt_maybe_publish(event_type, data))
|
||||
|
||||
|
||||
async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
|
||||
"""Check settings and build topic, then publish."""
|
||||
settings = mqtt_publisher._settings
|
||||
if settings is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if event_type == "message" and settings.mqtt_publish_messages:
|
||||
topic = _build_message_topic(settings.mqtt_topic_prefix, data)
|
||||
await mqtt_publisher.publish(topic, data)
|
||||
|
||||
elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
|
||||
topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
|
||||
await mqtt_publisher.publish(topic, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MQTT broadcast error: %s", e)
|
||||
|
||||
|
||||
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a decrypted message."""
|
||||
msg_type = data.get("type", "")
|
||||
conversation_key = data.get("conversation_key", "unknown")
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"{prefix}/dm:{conversation_key}"
|
||||
elif msg_type == "CHAN":
|
||||
return f"{prefix}/gm:{conversation_key}"
|
||||
return f"{prefix}/message:{conversation_key}"
|
||||
|
||||
|
||||
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
|
||||
"""Build MQTT topic for a raw packet."""
|
||||
info = data.get("decrypted_info")
|
||||
if info and isinstance(info, dict):
|
||||
contact_key = info.get("contact_key")
|
||||
channel_key = info.get("channel_key")
|
||||
if contact_key:
|
||||
return f"{prefix}/raw/dm:{contact_key}"
|
||||
if channel_key:
|
||||
return f"{prefix}/raw/gm:{channel_key}"
|
||||
return f"{prefix}/raw/unrouted"
|
||||
@@ -58,7 +58,6 @@ async def _handle_duplicate_message(
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
received: int,
|
||||
path_len: int | None = None,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record.
|
||||
|
||||
@@ -91,7 +90,7 @@ async def _handle_duplicate_message(
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len)
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received)
|
||||
else:
|
||||
# Get current paths for broadcast
|
||||
paths = existing_msg.paths or []
|
||||
@@ -129,9 +128,8 @@ async def create_message_from_decrypted(
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
trigger_bot: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted channel packet content.
|
||||
|
||||
@@ -147,7 +145,7 @@ async def create_message_from_decrypted(
|
||||
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)
|
||||
trigger_bot: Whether to trigger bot response (False for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
@@ -174,7 +172,6 @@ async def create_message_from_decrypted(
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
@@ -185,7 +182,7 @@ async def create_message_from_decrypted(
|
||||
# 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, received, path_len
|
||||
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -196,13 +193,9 @@ async def create_message_from_decrypted(
|
||||
|
||||
# Build paths array for broadcast
|
||||
# Use "is not None" to include empty string (direct/0-hop messages)
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
# Broadcast new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
@@ -213,13 +206,27 @@ async def create_message_from_decrypted(
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
).model_dump(),
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
# Run bot if enabled (for incoming channel messages, not historical decryption)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=sender,
|
||||
sender_key=None, # Channel messages don't have a sender public key
|
||||
message_text=message_text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_normalized,
|
||||
channel_name=channel_name,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
is_outgoing=False,
|
||||
)
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
@@ -230,9 +237,8 @@ async def create_dm_message_from_decrypted(
|
||||
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,
|
||||
trigger_bot: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
@@ -247,7 +253,7 @@ async def create_dm_message_from_decrypted(
|
||||
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)
|
||||
trigger_bot: Whether to trigger bot response (False for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
@@ -267,9 +273,6 @@ async def create_dm_message_from_decrypted(
|
||||
# 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",
|
||||
@@ -278,10 +281,8 @@ async def create_dm_message_from_decrypted(
|
||||
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:
|
||||
@@ -294,7 +295,6 @@ async def create_dm_message_from_decrypted(
|
||||
decrypted.timestamp,
|
||||
path,
|
||||
received,
|
||||
path_len,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -309,14 +309,9 @@ async def create_dm_message_from_decrypted(
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
paths = [MessagePath(path=path or "", received_at=received)] 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 new message to connected clients
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
@@ -328,15 +323,34 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
).model_dump(),
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
# Run bot if enabled (for all real-time DMs, including our own outgoing messages)
|
||||
if trigger_bot:
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
# Get contact name for the bot
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
sender_name = contact.name if contact else None
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=sender_name,
|
||||
sender_key=their_public_key,
|
||||
message_text=decrypted.message,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
is_outgoing=outgoing,
|
||||
)
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
@@ -397,7 +411,6 @@ 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,
|
||||
@@ -406,9 +419,8 @@ async def run_historical_dm_decryption(
|
||||
our_public_key=our_public_key_bytes.hex(),
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
trigger_bot=False, # Historical decryption should not trigger bot
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
@@ -506,13 +518,6 @@ async def process_raw_packet(
|
||||
payload_type = packet_info.payload_type if packet_info else None
|
||||
payload_type_name = payload_type.name if payload_type else "Unknown"
|
||||
|
||||
if packet_info is None and len(raw_bytes) > 2:
|
||||
logger.warning(
|
||||
"Failed to parse %d-byte packet (id=%d); stored undecrypted",
|
||||
len(raw_bytes),
|
||||
packet_id,
|
||||
)
|
||||
|
||||
# Log packet arrival at debug level
|
||||
path_hex = packet_info.path.hex() if packet_info and packet_info.path else ""
|
||||
logger.debug(
|
||||
@@ -622,7 +627,6 @@ async def _process_group_text(
|
||||
timestamp=decrypted.timestamp,
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -691,11 +695,9 @@ 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)",
|
||||
@@ -719,7 +721,6 @@ async def _process_advertisement(
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
|
||||
# Record name history
|
||||
@@ -740,7 +741,6 @@ async def _process_advertisement(
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -752,16 +752,6 @@ async def _process_advertisement(
|
||||
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,
|
||||
)
|
||||
|
||||
# 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.
|
||||
@@ -892,8 +882,7 @@ async def _process_direct_message(
|
||||
their_public_key=contact.public_key,
|
||||
our_public_key=our_public_key.hex(),
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
path=packet_info.path.hex() if packet_info.path else None,
|
||||
outgoing=is_outgoing,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@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 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 []
|
||||
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_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
|
||||
124
app/radio.py
124
app/radio.py
@@ -128,8 +128,7 @@ 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._loopback_active: bool = False
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -260,68 +259,6 @@ class RadioManager:
|
||||
# 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.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
|
||||
)
|
||||
|
||||
# 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
|
||||
self.path_hash_mode = 0
|
||||
self.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:
|
||||
self.path_hash_mode = device_query.payload["path_hash_mode"]
|
||||
self.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:
|
||||
self.path_hash_mode = raw[81]
|
||||
self.path_hash_mode_supported = True
|
||||
logger.warning(
|
||||
"path_hash_mode=%d extracted from raw frame "
|
||||
"(stale .pyc? try: rm %s)",
|
||||
self.path_hash_mode,
|
||||
getattr(
|
||||
__import__("meshcore.reader", fromlist=["reader"]),
|
||||
"__cached__",
|
||||
"meshcore __pycache__/reader.*.pyc",
|
||||
),
|
||||
)
|
||||
if self.path_hash_mode_supported:
|
||||
logger.info("Path hash mode: %d (supported)", self.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)
|
||||
@@ -381,6 +318,36 @@ class RadioManager:
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self._setup_complete
|
||||
|
||||
@property
|
||||
def loopback_active(self) -> bool:
|
||||
return self._loopback_active
|
||||
|
||||
def connect_loopback(self, mc: MeshCore, connection_info: str) -> None:
|
||||
"""Adopt a MeshCore instance created by the loopback WebSocket endpoint."""
|
||||
self._meshcore = mc
|
||||
self._connection_info = connection_info
|
||||
self._loopback_active = True
|
||||
self._last_connected = True
|
||||
self._setup_complete = False
|
||||
|
||||
async def disconnect_loopback(self) -> None:
|
||||
"""Tear down a loopback session and resume normal auto-detect."""
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
mc = self._meshcore
|
||||
self._meshcore = None
|
||||
self._loopback_active = False
|
||||
self._connection_info = None
|
||||
self._setup_complete = False
|
||||
|
||||
if mc is not None:
|
||||
try:
|
||||
await mc.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
broadcast_health(False, None)
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
if self._meshcore is not None:
|
||||
@@ -462,8 +429,6 @@ class RadioManager:
|
||||
await self._meshcore.disconnect()
|
||||
self._meshcore = None
|
||||
self._setup_complete = 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:
|
||||
@@ -522,13 +487,15 @@ class RadioManager:
|
||||
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)
|
||||
|
||||
# Skip auto-detect/reconnect while loopback is active
|
||||
if self._loopback_active:
|
||||
continue
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
# Detect status change
|
||||
@@ -537,7 +504,6 @@ class RadioManager:
|
||||
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
|
||||
@@ -547,7 +513,6 @@ class RadioManager:
|
||||
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).
|
||||
@@ -556,34 +521,19 @@ class RadioManager:
|
||||
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)
|
||||
# Log error but continue monitoring - don't let the monitor die
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
|
||||
self._reconnect_task = asyncio.create_task(monitor_loop())
|
||||
logger.info("Radio connection monitor started")
|
||||
|
||||
@@ -117,16 +117,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to get contacts from radio: %s. "
|
||||
"If you see this repeatedly, the radio may be visible on the "
|
||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
||||
"another process with the serial port open (other RemoteTerm "
|
||||
"instances, serial monitors, etc.), verify the firmware is "
|
||||
"up-to-date and in client mode (not repeater), or try a "
|
||||
"power cycle.",
|
||||
result,
|
||||
)
|
||||
logger.error("Failed to get contacts from radio: %s", result)
|
||||
return {"synced": 0, "removed": 0, "error": str(result)}
|
||||
|
||||
contacts = result.payload or {}
|
||||
@@ -145,17 +136,6 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
claimed,
|
||||
public_key[:12],
|
||||
)
|
||||
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
|
||||
@@ -325,7 +305,7 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Error draining messages: %s", e, exc_info=True)
|
||||
logger.debug("Error draining messages: %s", e)
|
||||
break
|
||||
|
||||
return count
|
||||
@@ -359,7 +339,7 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Message poll exception: %s", e, exc_info=True)
|
||||
logger.debug("Message poll exception: %s", e)
|
||||
|
||||
return count
|
||||
|
||||
@@ -381,19 +361,14 @@ async def _message_poll_loop():
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
if count > 0:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
await poll_for_messages(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Error in message poll loop: %s", e, exc_info=True)
|
||||
logger.debug("Error in message poll loop: %s", e)
|
||||
|
||||
|
||||
def start_message_polling():
|
||||
@@ -671,19 +646,8 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
reason = result.payload
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to load contact %s: %s%s",
|
||||
contact.public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
"Failed to load contact %s: %s", contact.public_key[:12], result.payload
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
|
||||
@@ -5,7 +5,6 @@ from app.repository.contacts import (
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
)
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
@@ -17,7 +16,6 @@ __all__ = [
|
||||
"ContactAdvertPathRepository",
|
||||
"ContactNameHistoryRepository",
|
||||
"ContactRepository",
|
||||
"FanoutConfigRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"StatisticsRepository",
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.models import (
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
)
|
||||
from app.path_utils import first_hop_hex
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
@@ -23,24 +22,18 @@ class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: dict[str, Any]) -> None:
|
||||
out_path_hash_mode = contact.get("out_path_hash_mode")
|
||||
if out_path_hash_mode is None:
|
||||
out_path_hash_mode = -1 if contact.get("last_path_len", -1) == -1 else 0
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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 = excluded.out_path_hash_mode,
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
lon = COALESCE(excluded.lon, contacts.lon),
|
||||
@@ -56,7 +49,6 @@ class ContactRepository:
|
||||
contact.get("flags", 0),
|
||||
contact.get("last_path"),
|
||||
contact.get("last_path_len", -1),
|
||||
out_path_hash_mode,
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat"),
|
||||
contact.get("lon"),
|
||||
@@ -78,7 +70,6 @@ class ContactRepository:
|
||||
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_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
@@ -209,17 +200,10 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_path(
|
||||
public_key: str,
|
||||
path: str,
|
||||
path_len: int,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
async def update_path(public_key: str, path: str, path_len: int) -> None:
|
||||
await db.conn.execute(
|
||||
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
|
||||
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
|
||||
last_seen = ? WHERE public_key = ?""",
|
||||
(path, path_len, out_path_hash_mode, int(time.time()), public_key.lower()),
|
||||
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
|
||||
(path, path_len, int(time.time()), public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@@ -231,19 +215,6 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_on_radio_except(keep_keys: list[str]) -> None:
|
||||
"""Set on_radio=False for all contacts NOT in keep_keys."""
|
||||
if not keep_keys:
|
||||
await db.conn.execute("UPDATE contacts SET on_radio = 0 WHERE on_radio = 1")
|
||||
else:
|
||||
placeholders = ",".join("?" * len(keep_keys))
|
||||
await db.conn.execute(
|
||||
f"UPDATE contacts SET on_radio = 0 WHERE on_radio = 1 AND public_key NOT IN ({placeholders})",
|
||||
keep_keys,
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
normalized = public_key.lower()
|
||||
@@ -303,11 +274,10 @@ class ContactAdvertPathRepository:
|
||||
@staticmethod
|
||||
def _row_to_path(row) -> ContactAdvertPath:
|
||||
path = row["path_hex"] or ""
|
||||
path_len = row["path_len"]
|
||||
next_hop = first_hop_hex(path, path_len)
|
||||
next_hop = path[:2].lower() if len(path) >= 2 else None
|
||||
return ContactAdvertPath(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
path_len=row["path_len"],
|
||||
next_hop=next_hop,
|
||||
first_seen=row["first_seen"],
|
||||
last_seen=row["last_seen"],
|
||||
@@ -320,7 +290,6 @@ class ContactAdvertPathRepository:
|
||||
path_hex: str,
|
||||
timestamp: int,
|
||||
max_paths: int = 10,
|
||||
hop_count: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Upsert a unique advert path observation for a contact and prune to N most recent.
|
||||
@@ -330,15 +299,16 @@ class ContactAdvertPathRepository:
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
normalized_path = path_hex.lower()
|
||||
path_len = hop_count if hop_count is not None else len(normalized_path) // 2
|
||||
path_len = len(normalized_path) // 2
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||
ON CONFLICT(public_key, path_hex) DO UPDATE SET
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
path_len = excluded.path_len,
|
||||
heard_count = contact_advert_paths.heard_count + 1
|
||||
""",
|
||||
(normalized_key, normalized_path, path_len, timestamp, timestamp),
|
||||
@@ -349,8 +319,8 @@ class ContactAdvertPathRepository:
|
||||
"""
|
||||
DELETE FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
AND path_hex NOT IN (
|
||||
SELECT path_hex
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Repository for fanout_configs table."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory cache of config metadata (name, type) for status reporting.
|
||||
# Populated by get_all/get/create/update and read by FanoutManager.get_statuses().
|
||||
_configs_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
"""Convert a database row to a config dict."""
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"name": row["name"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"config": json.loads(row["config"]) if row["config"] else {},
|
||||
"scope": json.loads(row["scope"]) if row["scope"] else {},
|
||||
"sort_order": row["sort_order"] or 0,
|
||||
"created_at": row["created_at"] or 0,
|
||||
}
|
||||
_configs_cache[result["id"]] = result
|
||||
return result
|
||||
|
||||
|
||||
class FanoutConfigRepository:
|
||||
"""CRUD operations for fanout_configs table."""
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> list[dict[str, Any]]:
|
||||
"""Get all fanout configs ordered by sort_order."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM fanout_configs ORDER BY sort_order, created_at"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get(config_id: str) -> dict[str, Any] | None:
|
||||
"""Get a single fanout config by ID."""
|
||||
cursor = await db.conn.execute("SELECT * FROM fanout_configs WHERE id = ?", (config_id,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
config_type: str,
|
||||
name: str,
|
||||
config: dict,
|
||||
scope: dict,
|
||||
enabled: bool = True,
|
||||
config_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new fanout config."""
|
||||
new_id = config_id or str(uuid.uuid4())
|
||||
now = int(time.time())
|
||||
|
||||
# Get next sort_order
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM fanout_configs"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
sort_order = row[0] if row else 0
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
config_type,
|
||||
name,
|
||||
1 if enabled else 0,
|
||||
json.dumps(config),
|
||||
json.dumps(scope),
|
||||
sort_order,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
result = await FanoutConfigRepository.get(new_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def update(config_id: str, **fields: Any) -> dict[str, Any] | None:
|
||||
"""Update a fanout config. Only provided fields are updated."""
|
||||
updates = []
|
||||
params: list[Any] = []
|
||||
|
||||
for field in ("name", "enabled", "config", "scope", "sort_order"):
|
||||
if field in fields:
|
||||
value = fields[field]
|
||||
if field == "enabled":
|
||||
value = 1 if value else 0
|
||||
elif field in ("config", "scope"):
|
||||
value = json.dumps(value)
|
||||
updates.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
|
||||
if not updates:
|
||||
return await FanoutConfigRepository.get(config_id)
|
||||
|
||||
params.append(config_id)
|
||||
query = f"UPDATE fanout_configs SET {', '.join(updates)} WHERE id = ?"
|
||||
await db.conn.execute(query, params)
|
||||
await db.conn.commit()
|
||||
|
||||
return await FanoutConfigRepository.get(config_id)
|
||||
|
||||
@staticmethod
|
||||
async def delete(config_id: str) -> None:
|
||||
"""Delete a fanout config."""
|
||||
await db.conn.execute("DELETE FROM fanout_configs WHERE id = ?", (config_id,))
|
||||
await db.conn.commit()
|
||||
_configs_cache.pop(config_id, None)
|
||||
|
||||
@staticmethod
|
||||
async def get_enabled() -> list[dict[str, Any]]:
|
||||
"""Get all enabled fanout configs."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM fanout_configs WHERE enabled = 1 ORDER BY sort_order, created_at"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
@@ -15,7 +15,7 @@ class MessageRepository:
|
||||
try:
|
||||
paths_data = json.loads(paths_json)
|
||||
return [MessagePath(**p) for p in paths_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError, ValueError):
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -26,7 +26,6 @@ class MessageRepository:
|
||||
conversation_key: str,
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
@@ -44,10 +43,7 @@ class MessageRepository:
|
||||
# Convert single path to paths array format
|
||||
paths_json = None
|
||||
if path is not None:
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
paths_json = json.dumps([entry])
|
||||
paths_json = json.dumps([{"path": path, "received_at": received_at}])
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
@@ -78,10 +74,7 @@ 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
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -92,10 +85,7 @@ class MessageRepository:
|
||||
|
||||
# Atomic append: use json_insert to avoid read-modify-write race when
|
||||
# multiple duplicate packets arrive concurrently for the same message.
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
new_entry = json.dumps(entry)
|
||||
new_entry = json.dumps({"path": path, "received_at": ts})
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
COALESCE(paths, '[]'), '$[#]', json(?)
|
||||
@@ -138,54 +128,6 @@ class MessageRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def backfill_channel_sender_key(public_key: str, name: str) -> int:
|
||||
"""Backfill sender_key on channel messages that match a contact's name.
|
||||
|
||||
When a contact becomes known (via advert, sync, or manual creation),
|
||||
any channel messages with a matching sender_name but no sender_key
|
||||
are updated to associate them with this contact's public key.
|
||||
"""
|
||||
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),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
def _normalize_conversation_key(conversation_key: str) -> tuple[str, str]:
|
||||
"""Normalize a conversation key and return (sql_clause, normalized_key).
|
||||
|
||||
Returns the WHERE clause fragment and the normalized key value.
|
||||
"""
|
||||
if len(conversation_key) == 64:
|
||||
return "AND conversation_key = ?", conversation_key.lower()
|
||||
elif len(conversation_key) == 32:
|
||||
return "AND conversation_key = ?", conversation_key.upper()
|
||||
else:
|
||||
return "AND conversation_key LIKE ?", f"{conversation_key}%"
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
sender_key=row["sender_key"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
sender_name=row["sender_name"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
limit: int = 100,
|
||||
@@ -194,165 +136,57 @@ class MessageRepository:
|
||||
conversation_key: str | None = None,
|
||||
before: int | None = None,
|
||||
before_id: int | None = None,
|
||||
after: int | None = None,
|
||||
after_id: int | None = None,
|
||||
q: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages 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"))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
params.extend(blocked_keys)
|
||||
|
||||
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}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
query += f" {clause}"
|
||||
params.append(norm_key)
|
||||
normalized_key = conversation_key
|
||||
# Prefer exact matching for full keys.
|
||||
if len(conversation_key) == 64:
|
||||
normalized_key = conversation_key.lower()
|
||||
query += " AND conversation_key = ?"
|
||||
params.append(normalized_key)
|
||||
elif len(conversation_key) == 32:
|
||||
normalized_key = conversation_key.upper()
|
||||
query += " AND conversation_key = ?"
|
||||
params.append(normalized_key)
|
||||
else:
|
||||
# Prefix match is only for legacy/partial key callers.
|
||||
query += " AND conversation_key LIKE ?"
|
||||
params.append(f"{conversation_key}%")
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{escaped_q}%")
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
# 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 > ?))"
|
||||
params.extend([after, after, after_id])
|
||||
query += " ORDER BY received_at ASC, 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 < ?))"
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
cursor = await db.conn.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [MessageRepository._row_to_message(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_around(
|
||||
message_id: int,
|
||||
msg_type: str | None = None,
|
||||
conversation_key: str | None = None,
|
||||
context_size: int = 100,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> tuple[list[Message], bool, bool]:
|
||||
"""Get messages around a target message.
|
||||
|
||||
Returns (messages, has_older, has_newer).
|
||||
"""
|
||||
# Build common WHERE clause for optional conversation/type filtering.
|
||||
# If the target message doesn't match filters, return an empty result.
|
||||
where_parts: list[str] = []
|
||||
base_params: list[Any] = []
|
||||
if msg_type:
|
||||
where_parts.append("type = ?")
|
||||
base_params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
where_parts.append(clause.removeprefix("AND "))
|
||||
base_params.append(norm_key)
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
where_parts.append(
|
||||
f"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"))"
|
||||
return [
|
||||
Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
base_params.extend(blocked_keys)
|
||||
base_params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND sender_name IS NOT NULL AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
base_params.extend(blocked_names)
|
||||
|
||||
where_sql = " AND ".join(["1=1", *where_parts])
|
||||
|
||||
# 1. Get the target message (must satisfy filters if provided)
|
||||
target_cursor = await db.conn.execute(
|
||||
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
|
||||
(message_id, *base_params),
|
||||
)
|
||||
target_row = await target_cursor.fetchone()
|
||||
if not target_row:
|
||||
return [], False, False
|
||||
|
||||
target = MessageRepository._row_to_message(target_row)
|
||||
|
||||
# 2. Get context_size+1 messages before target (DESC)
|
||||
before_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
AND (received_at < ? OR (received_at = ? AND id < ?))
|
||||
ORDER BY received_at DESC, id DESC LIMIT ?
|
||||
"""
|
||||
before_params = [
|
||||
*base_params,
|
||||
target.received_at,
|
||||
target.received_at,
|
||||
target.id,
|
||||
context_size + 1,
|
||||
for row in rows
|
||||
]
|
||||
before_cursor = await db.conn.execute(before_query, before_params)
|
||||
before_rows = list(await before_cursor.fetchall())
|
||||
|
||||
has_older = len(before_rows) > context_size
|
||||
before_messages = [MessageRepository._row_to_message(r) for r in before_rows[:context_size]]
|
||||
|
||||
# 3. Get context_size+1 messages after target (ASC)
|
||||
after_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
AND (received_at > ? OR (received_at = ? AND id > ?))
|
||||
ORDER BY received_at ASC, id ASC LIMIT ?
|
||||
"""
|
||||
after_params = [
|
||||
*base_params,
|
||||
target.received_at,
|
||||
target.received_at,
|
||||
target.id,
|
||||
context_size + 1,
|
||||
]
|
||||
after_cursor = await db.conn.execute(after_query, after_params)
|
||||
after_rows = list(await after_cursor.fetchall())
|
||||
|
||||
has_newer = len(after_rows) > context_size
|
||||
after_messages = [MessageRepository._row_to_message(r) for r in after_rows[:context_size]]
|
||||
|
||||
# Combine: before (reversed to ASC) + target + after
|
||||
all_messages = list(reversed(before_messages)) + [target] + after_messages
|
||||
return all_messages, has_older, has_newer
|
||||
|
||||
@staticmethod
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
@@ -378,14 +212,31 @@ class MessageRepository:
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM messages WHERE id = ?",
|
||||
"""
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE id = ?
|
||||
""",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return MessageRepository._row_to_message(row)
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
@@ -397,7 +248,9 @@ class MessageRepository:
|
||||
"""Look up a message by its unique content fields."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
""",
|
||||
@@ -407,20 +260,36 @@ class MessageRepository:
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return MessageRepository._row_to_message(row)
|
||||
paths = None
|
||||
if row["paths"]:
|
||||
try:
|
||||
paths_data = json.loads(row["paths"])
|
||||
paths = [
|
||||
MessagePath(path=p["path"], received_at=p["received_at"]) for p in paths_data
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=paths,
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_counts(
|
||||
name: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> dict:
|
||||
async def get_unread_counts(name: str | None = None) -> dict:
|
||||
"""Get unread message counts, mention flags, and last message times for all conversations.
|
||||
|
||||
Args:
|
||||
name: User's display name for @[name] mention detection. If None, mentions are skipped.
|
||||
blocked_keys: Public keys whose messages should be excluded from counts.
|
||||
blocked_names: Display names whose messages should be excluded from counts.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
@@ -431,25 +300,9 @@ class MessageRepository:
|
||||
|
||||
mention_token = f"@[{name}]" if name else None
|
||||
|
||||
# Build optional block-list WHERE fragments for channel messages
|
||||
chan_block_sql = ""
|
||||
chan_block_params: list[Any] = []
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
chan_block_sql += (
|
||||
f" AND NOT (m.sender_key IS NOT NULL AND LOWER(m.sender_key) IN ({placeholders}))"
|
||||
)
|
||||
chan_block_params.extend(blocked_keys)
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
chan_block_sql += (
|
||||
f" AND NOT (m.sender_name IS NOT NULL AND m.sender_name IN ({placeholders}))"
|
||||
)
|
||||
chan_block_params.extend(blocked_names)
|
||||
|
||||
# Channel unreads
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
SUM(CASE
|
||||
@@ -460,10 +313,9 @@ class MessageRepository:
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
{chan_block_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or "", *chan_block_params),
|
||||
(mention_token or "", mention_token or ""),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -472,17 +324,9 @@ class MessageRepository:
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Build block-list exclusion for contact (DM) unreads
|
||||
contact_block_sql = ""
|
||||
contact_block_params: list[Any] = []
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
contact_block_sql += f" AND LOWER(m.conversation_key) NOT IN ({placeholders})"
|
||||
contact_block_params.extend(blocked_keys)
|
||||
|
||||
# Contact unreads
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
"""
|
||||
SELECT m.conversation_key,
|
||||
COUNT(*) as unread_count,
|
||||
SUM(CASE
|
||||
@@ -493,10 +337,9 @@ class MessageRepository:
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
{contact_block_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or "", *contact_block_params),
|
||||
(mention_token or "", mention_token or ""),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -545,72 +388,6 @@ class MessageRepository:
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_stats(conversation_key: str) -> dict:
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
||||
|
||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
now = int(_time.time())
|
||||
t_1h = now - 3600
|
||||
t_24h = now - 86400
|
||||
t_48h = now - 172800
|
||||
t_7d = now - 604800
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS all_time,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_1h,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_24h,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_48h,
|
||||
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_7d,
|
||||
MIN(received_at) AS first_message_at,
|
||||
COUNT(DISTINCT sender_key) AS unique_sender_count
|
||||
FROM messages WHERE type = 'CHAN' AND conversation_key = ?
|
||||
""",
|
||||
(t_1h, t_24h, t_48h, t_7d, conversation_key),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None # Aggregate query always returns a row
|
||||
|
||||
message_counts = {
|
||||
"last_1h": row["last_1h"] or 0,
|
||||
"last_24h": row["last_24h"] or 0,
|
||||
"last_48h": row["last_48h"] or 0,
|
||||
"last_7d": row["last_7d"] or 0,
|
||||
"all_time": row["all_time"] or 0,
|
||||
}
|
||||
|
||||
cursor2 = await db.conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(sender_name, sender_key, 'Unknown') AS display_name,
|
||||
sender_key, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE type = 'CHAN' AND conversation_key = ?
|
||||
AND received_at >= ? AND sender_key IS NOT NULL
|
||||
GROUP BY sender_key ORDER BY cnt DESC LIMIT 5
|
||||
""",
|
||||
(conversation_key, t_24h),
|
||||
)
|
||||
top_rows = await cursor2.fetchall()
|
||||
top_senders = [
|
||||
{
|
||||
"sender_name": r["display_name"],
|
||||
"sender_key": r["sender_key"],
|
||||
"message_count": r["cnt"],
|
||||
}
|
||||
for r in top_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"message_counts": message_counts,
|
||||
"first_message_at": row["first_message_at"],
|
||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||
"top_senders_24h": top_senders,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a contact has sent the most messages.
|
||||
|
||||
@@ -4,8 +4,7 @@ import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, Favorite
|
||||
from app.path_utils import parse_packet_envelope
|
||||
from app.models import AppSettings, BotConfig, Favorite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,8 +26,10 @@ class AppSettingsRepository:
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names
|
||||
advert_interval, last_advert_time, bots,
|
||||
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
|
||||
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
|
||||
mqtt_publish_messages, mqtt_publish_raw_packets
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -64,21 +65,19 @@ class AppSettingsRepository:
|
||||
)
|
||||
last_message_times = {}
|
||||
|
||||
# Parse blocked_keys JSON
|
||||
blocked_keys: list[str] = []
|
||||
if row["blocked_keys"]:
|
||||
# Parse bots JSON
|
||||
bots: list[BotConfig] = []
|
||||
if row["bots"]:
|
||||
try:
|
||||
blocked_keys = json.loads(row["blocked_keys"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_keys = []
|
||||
|
||||
# Parse blocked_names JSON
|
||||
blocked_names: list[str] = []
|
||||
if row["blocked_names"]:
|
||||
try:
|
||||
blocked_names = json.loads(row["blocked_names"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_names = []
|
||||
bots_data = json.loads(row["bots"])
|
||||
bots = [BotConfig(**b) for b in bots_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse bots JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["bots"][:100] if row["bots"] else None,
|
||||
)
|
||||
bots = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
@@ -94,9 +93,16 @@ class AppSettingsRepository:
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
bots=bots,
|
||||
mqtt_broker_host=row["mqtt_broker_host"] or "",
|
||||
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
|
||||
mqtt_username=row["mqtt_username"] or "",
|
||||
mqtt_password=row["mqtt_password"] or "",
|
||||
mqtt_use_tls=bool(row["mqtt_use_tls"]),
|
||||
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
|
||||
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
|
||||
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
|
||||
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -109,9 +115,16 @@ class AppSettingsRepository:
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
bots: list[BotConfig] | None = None,
|
||||
mqtt_broker_host: str | None = None,
|
||||
mqtt_broker_port: int | None = None,
|
||||
mqtt_username: str | None = None,
|
||||
mqtt_password: str | None = None,
|
||||
mqtt_use_tls: bool | None = None,
|
||||
mqtt_tls_insecure: bool | None = None,
|
||||
mqtt_topic_prefix: str | None = None,
|
||||
mqtt_publish_messages: bool | None = None,
|
||||
mqtt_publish_raw_packets: bool | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -150,17 +163,46 @@ class AppSettingsRepository:
|
||||
updates.append("last_advert_time = ?")
|
||||
params.append(last_advert_time)
|
||||
|
||||
if flood_scope is not None:
|
||||
updates.append("flood_scope = ?")
|
||||
params.append(flood_scope)
|
||||
if bots is not None:
|
||||
updates.append("bots = ?")
|
||||
bots_json = json.dumps([b.model_dump() for b in bots])
|
||||
params.append(bots_json)
|
||||
|
||||
if blocked_keys is not None:
|
||||
updates.append("blocked_keys = ?")
|
||||
params.append(json.dumps(blocked_keys))
|
||||
if mqtt_broker_host is not None:
|
||||
updates.append("mqtt_broker_host = ?")
|
||||
params.append(mqtt_broker_host)
|
||||
|
||||
if blocked_names is not None:
|
||||
updates.append("blocked_names = ?")
|
||||
params.append(json.dumps(blocked_names))
|
||||
if mqtt_broker_port is not None:
|
||||
updates.append("mqtt_broker_port = ?")
|
||||
params.append(mqtt_broker_port)
|
||||
|
||||
if mqtt_username is not None:
|
||||
updates.append("mqtt_username = ?")
|
||||
params.append(mqtt_username)
|
||||
|
||||
if mqtt_password is not None:
|
||||
updates.append("mqtt_password = ?")
|
||||
params.append(mqtt_password)
|
||||
|
||||
if mqtt_use_tls is not None:
|
||||
updates.append("mqtt_use_tls = ?")
|
||||
params.append(1 if mqtt_use_tls else 0)
|
||||
|
||||
if mqtt_tls_insecure is not None:
|
||||
updates.append("mqtt_tls_insecure = ?")
|
||||
params.append(1 if mqtt_tls_insecure else 0)
|
||||
|
||||
if mqtt_topic_prefix is not None:
|
||||
updates.append("mqtt_topic_prefix = ?")
|
||||
params.append(mqtt_topic_prefix)
|
||||
|
||||
if mqtt_publish_messages is not None:
|
||||
updates.append("mqtt_publish_messages = ?")
|
||||
params.append(1 if mqtt_publish_messages else 0)
|
||||
|
||||
if mqtt_publish_raw_packets is not None:
|
||||
updates.append("mqtt_publish_raw_packets = ?")
|
||||
params.append(1 if mqtt_publish_raw_packets else 0)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
@@ -190,27 +232,6 @@ class AppSettingsRepository:
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def toggle_blocked_key(key: str) -> AppSettings:
|
||||
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
|
||||
normalized = key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
if normalized in settings.blocked_keys:
|
||||
new_keys = [k for k in settings.blocked_keys if k != normalized]
|
||||
else:
|
||||
new_keys = settings.blocked_keys + [normalized]
|
||||
return await AppSettingsRepository.update(blocked_keys=new_keys)
|
||||
|
||||
@staticmethod
|
||||
async def toggle_blocked_name(name: str) -> AppSettings:
|
||||
"""Toggle a display name in the blocked list."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
if name in settings.blocked_names:
|
||||
new_names = [n for n in settings.blocked_names if n != name]
|
||||
else:
|
||||
new_names = settings.blocked_names + [name]
|
||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
@@ -270,53 +291,6 @@ 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."""
|
||||
@@ -396,7 +370,6 @@ 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,
|
||||
@@ -411,5 +384,4 @@ class StatisticsRepository:
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.models import Channel
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
@@ -29,24 +29,6 @@ async def list_channels() -> list[Channel]:
|
||||
return await ChannelRepository.get_all()
|
||||
|
||||
|
||||
@router.get("/{key}/detail", response_model=ChannelDetail)
|
||||
async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
"""Get comprehensive channel profile data with message statistics."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
stats = await MessageRepository.get_channel_stats(channel.key)
|
||||
|
||||
return ChannelDetail(
|
||||
channel=channel,
|
||||
message_counts=ChannelMessageCounts(**stats["message_counts"]),
|
||||
first_message_at=stats["first_message_at"],
|
||||
unique_sender_count=stats["unique_sender_count"],
|
||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=Channel)
|
||||
async def get_channel(key: str) -> Channel:
|
||||
"""Get a specific channel by key (32-char hex string)."""
|
||||
@@ -145,9 +127,4 @@ 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"}
|
||||
|
||||
@@ -110,7 +110,6 @@ async def create_contact(
|
||||
"flags": existing.flags,
|
||||
"last_path": existing.last_path,
|
||||
"last_path_len": existing.last_path_len,
|
||||
"out_path_hash_mode": existing.out_path_hash_mode,
|
||||
"last_advert": existing.last_advert,
|
||||
"lat": existing.lat,
|
||||
"lon": existing.lon,
|
||||
@@ -140,7 +139,6 @@ async def create_contact(
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"out_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
@@ -156,14 +154,6 @@ async def create_contact(
|
||||
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
|
||||
)
|
||||
|
||||
# Trigger historical decryption if requested
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
@@ -206,8 +196,8 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
|
||||
# Compute nearest repeaters from first-hop prefixes in advert paths
|
||||
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
|
||||
for p in advert_paths:
|
||||
prefix = p.next_hop
|
||||
if prefix:
|
||||
if p.path and len(p.path) >= 2:
|
||||
prefix = p.path[:2].lower()
|
||||
if prefix not in first_hop_stats:
|
||||
first_hop_stats[prefix] = {
|
||||
"heard_count": 0,
|
||||
@@ -281,30 +271,16 @@ async def sync_contacts_from_radio() -> dict:
|
||||
contacts = result.payload
|
||||
count = 0
|
||||
|
||||
synced_keys: list[str] = []
|
||||
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)
|
||||
)
|
||||
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,
|
||||
)
|
||||
count += 1
|
||||
|
||||
# Clear on_radio for contacts not found on the radio
|
||||
await ContactRepository.clear_on_radio_except(synced_keys)
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
@@ -392,10 +368,6 @@ async def delete_contact(public_key: str) -> dict:
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
logger.info("Deleted contact %s", contact.public_key[:12])
|
||||
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -464,7 +436,7 @@ async def reset_contact_path(public_key: str) -> dict:
|
||||
"""Reset a contact's routing path to flood."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1)
|
||||
logger.info("Reset path to flood for %s", contact.public_key[:12])
|
||||
|
||||
# Push the updated path to radio if connected and contact is on radio
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
"""REST API for fanout config CRUD."""
|
||||
|
||||
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.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise"}
|
||||
|
||||
_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):
|
||||
type: str = Field(description="Integration type: 'mqtt_private' or 'mqtt_community'")
|
||||
name: str = Field(min_length=1, description="User-assigned label")
|
||||
config: dict = Field(default_factory=dict, description="Type-specific config blob")
|
||||
scope: dict = Field(default_factory=dict, description="Scope controls")
|
||||
enabled: bool = Field(default=True, description="Whether enabled on creation")
|
||||
|
||||
|
||||
class FanoutConfigUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, description="Updated label")
|
||||
config: dict | None = Field(default=None, description="Updated config blob")
|
||||
scope: dict | None = Field(default=None, description="Updated scope controls")
|
||||
enabled: bool | None = Field(default=None, description="Enable/disable toggle")
|
||||
|
||||
|
||||
def _validate_mqtt_private_config(config: dict) -> None:
|
||||
"""Validate mqtt_private config blob."""
|
||||
if not config.get("broker_host"):
|
||||
raise HTTPException(status_code=400, detail="broker_host is required for mqtt_private")
|
||||
port = config.get("broker_port", 1883)
|
||||
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")
|
||||
|
||||
|
||||
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(
|
||||
status_code=400,
|
||||
detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
|
||||
)
|
||||
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)."""
|
||||
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")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
|
||||
def _validate_apprise_config(config: dict) -> None:
|
||||
"""Validate apprise config blob."""
|
||||
urls = config.get("urls", "")
|
||||
if not urls or not urls.strip():
|
||||
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
||||
|
||||
|
||||
def _validate_webhook_config(config: dict) -> None:
|
||||
"""Validate webhook config blob."""
|
||||
url = config.get("url", "")
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="url is required for webhook")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="url must start with http:// or https://")
|
||||
method = config.get("method", "POST").upper()
|
||||
if method not in ("POST", "PUT", "PATCH"):
|
||||
raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
|
||||
headers = config.get("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
raise HTTPException(status_code=400, detail="headers must be a JSON object")
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
return {"messages": "none", "raw_packets": "all"}
|
||||
if config_type == "bot":
|
||||
return {"messages": "all", "raw_packets": "none"}
|
||||
if config_type in ("webhook", "apprise"):
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="scope.messages must be 'all', 'none', or a filter object",
|
||||
)
|
||||
return {"messages": messages, "raw_packets": "none"}
|
||||
# For mqtt_private, validate scope values
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="scope.messages must be 'all', 'none', or a filter object",
|
||||
)
|
||||
raw_packets = scope.get("raw_packets", "all")
|
||||
if raw_packets not in ("all", "none"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="scope.raw_packets must be 'all' or 'none'",
|
||||
)
|
||||
return {"messages": messages, "raw_packets": raw_packets}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_fanout_configs() -> list[dict]:
|
||||
"""List all fanout configs."""
|
||||
return await FanoutConfigRepository.get_all()
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
"""Create a new fanout config."""
|
||||
if body.type not in _VALID_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||
)
|
||||
|
||||
if body.type == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
|
||||
# Only validate config when creating as enabled — disabled configs
|
||||
# are drafts the user hasn't finished configuring yet.
|
||||
if body.enabled:
|
||||
if body.type == "mqtt_private":
|
||||
_validate_mqtt_private_config(body.config)
|
||||
elif body.type == "mqtt_community":
|
||||
_validate_mqtt_community_config(body.config)
|
||||
elif body.type == "bot":
|
||||
_validate_bot_config(body.config)
|
||||
elif body.type == "webhook":
|
||||
_validate_webhook_config(body.config)
|
||||
elif body.type == "apprise":
|
||||
_validate_apprise_config(body.config)
|
||||
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type=body.type,
|
||||
name=body.name,
|
||||
config=body.config,
|
||||
scope=scope,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
|
||||
# Start the module if enabled
|
||||
if cfg["enabled"]:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(cfg["id"])
|
||||
|
||||
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.patch("/{config_id}")
|
||||
async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict:
|
||||
"""Update a fanout config. Triggers module reload."""
|
||||
existing = await FanoutConfigRepository.get(config_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
if existing["type"] == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
|
||||
kwargs = {}
|
||||
if body.name is not None:
|
||||
kwargs["name"] = body.name
|
||||
if body.enabled is not None:
|
||||
kwargs["enabled"] = body.enabled
|
||||
if body.config is not None:
|
||||
kwargs["config"] = body.config
|
||||
if body.scope is not None:
|
||||
kwargs["scope"] = _enforce_scope(existing["type"], body.scope)
|
||||
|
||||
# Validate config when the result will be enabled
|
||||
will_be_enabled = body.enabled if body.enabled is not None else existing["enabled"]
|
||||
if will_be_enabled:
|
||||
config_to_validate = body.config if body.config is not None else existing["config"]
|
||||
if existing["type"] == "mqtt_private":
|
||||
_validate_mqtt_private_config(config_to_validate)
|
||||
elif existing["type"] == "mqtt_community":
|
||||
_validate_mqtt_community_config(config_to_validate)
|
||||
elif existing["type"] == "bot":
|
||||
_validate_bot_config(config_to_validate)
|
||||
elif existing["type"] == "webhook":
|
||||
_validate_webhook_config(config_to_validate)
|
||||
elif existing["type"] == "apprise":
|
||||
_validate_apprise_config(config_to_validate)
|
||||
|
||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Reload the module to pick up changes
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(config_id)
|
||||
|
||||
logger.info("Updated fanout config %s", config_id)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{config_id}")
|
||||
async def delete_fanout_config(config_id: str) -> dict:
|
||||
"""Delete a fanout config."""
|
||||
existing = await FanoutConfigRepository.get(config_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Stop the module first
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.remove_config(config_id)
|
||||
await FanoutConfigRepository.delete(config_id)
|
||||
|
||||
logger.info("Deleted fanout config %s", config_id)
|
||||
return {"deleted": True}
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
@@ -14,12 +13,11 @@ router = APIRouter(tags=["health"])
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
connection_info: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
||||
bots_disabled: bool = False
|
||||
mqtt_status: str | None = None
|
||||
loopback_eligible: bool = False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
@@ -37,34 +35,26 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
# Fanout module statuses
|
||||
fanout_statuses: dict[str, Any] = {}
|
||||
# MQTT status
|
||||
mqtt_status: str | None = None
|
||||
try:
|
||||
from app.fanout.manager import fanout_manager
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
fanout_statuses = fanout_manager.get_statuses()
|
||||
if mqtt_publisher._mqtt_configured():
|
||||
mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
|
||||
else:
|
||||
mqtt_status = "disabled"
|
||||
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
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"radio_initializing": radio_initializing,
|
||||
"connection_info": connection_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
"bots_disabled": settings.disable_bots,
|
||||
"mqtt_status": mqtt_status,
|
||||
"loopback_eligible": settings.loopback_eligible,
|
||||
}
|
||||
|
||||
|
||||
|
||||
116
app/routers/loopback.py
Normal file
116
app/routers/loopback.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""WebSocket endpoint for loopback transport (browser-bridged radio connection)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from app.config import settings
|
||||
from app.loopback import LoopbackTransport
|
||||
from app.radio import radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/transport")
|
||||
async def loopback_transport(websocket: WebSocket) -> None:
|
||||
"""Bridge a browser-side serial/BLE connection to the backend MeshCore stack.
|
||||
|
||||
Protocol:
|
||||
1. Client sends init JSON: {"type": "init", "mode": "serial"|"ble"}
|
||||
2. Binary frames flow bidirectionally (raw bytes for BLE, framed for serial)
|
||||
3. Either side can send {"type": "disconnect"} to tear down
|
||||
"""
|
||||
# Guard: reject if an explicit transport is configured via env vars
|
||||
if not settings.loopback_eligible:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4003, reason="Explicit transport configured")
|
||||
return
|
||||
|
||||
# Guard: reject if the radio is already connected (direct or another loopback)
|
||||
if radio_manager.is_connected:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4004, reason="Radio already connected")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
transport: LoopbackTransport | None = None
|
||||
setup_task: asyncio.Task | None = None
|
||||
|
||||
try:
|
||||
# Wait for init message
|
||||
init_raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
|
||||
init_msg = json.loads(init_raw)
|
||||
|
||||
if init_msg.get("type") != "init" or init_msg.get("mode") not in ("serial", "ble"):
|
||||
await websocket.close(code=4001, reason="Invalid init message")
|
||||
return
|
||||
|
||||
mode = init_msg["mode"]
|
||||
logger.info("Loopback init: mode=%s", mode)
|
||||
|
||||
# Create transport and MeshCore instance
|
||||
transport = LoopbackTransport(websocket, mode)
|
||||
|
||||
from meshcore import MeshCore
|
||||
|
||||
mc = MeshCore(transport, auto_reconnect=False, max_reconnect_attempts=0)
|
||||
await mc.connect()
|
||||
|
||||
if not mc.is_connected:
|
||||
logger.warning("Loopback MeshCore failed to connect")
|
||||
await websocket.close(code=4005, reason="MeshCore handshake failed")
|
||||
return
|
||||
|
||||
connection_info = f"Loopback ({mode})"
|
||||
radio_manager.connect_loopback(mc, connection_info)
|
||||
|
||||
# Run post-connect setup in background so the receive loop can run
|
||||
setup_task = asyncio.create_task(radio_manager.post_connect_setup())
|
||||
|
||||
# Main receive loop
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if "bytes" in message and message["bytes"]:
|
||||
transport.handle_rx(message["bytes"])
|
||||
elif "text" in message and message["text"]:
|
||||
try:
|
||||
text_msg = json.loads(message["text"])
|
||||
if text_msg.get("type") == "disconnect":
|
||||
logger.info("Loopback client requested disconnect")
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Loopback WebSocket disconnected")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Loopback init timeout")
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.close(code=4002, reason="Init timeout")
|
||||
except Exception as e:
|
||||
logger.exception("Loopback error: %s", e)
|
||||
finally:
|
||||
if setup_task is not None:
|
||||
setup_task.cancel()
|
||||
try:
|
||||
await setup_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
await radio_manager.disconnect_loopback()
|
||||
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
@@ -6,42 +7,15 @@ from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.models import (
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
@router.get("/around/{message_id}", response_model=MessagesAroundResponse)
|
||||
async def get_messages_around(
|
||||
message_id: int,
|
||||
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
|
||||
conversation_key: str | None = Query(default=None, description="Filter by conversation key"),
|
||||
context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"),
|
||||
) -> MessagesAroundResponse:
|
||||
"""Get messages around a specific message for jump-to-message navigation."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
messages, has_older, has_newer = await MessageRepository.get_around(
|
||||
message_id=message_id,
|
||||
msg_type=type,
|
||||
conversation_key=conversation_key,
|
||||
context_size=context,
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Message])
|
||||
async def list_messages(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -54,18 +28,8 @@ async def list_messages(
|
||||
default=None, description="Cursor: received_at of last seen message"
|
||||
),
|
||||
before_id: int | None = Query(default=None, description="Cursor: id of last seen message"),
|
||||
after: int | None = Query(
|
||||
default=None, description="Forward cursor: received_at of last seen message"
|
||||
),
|
||||
after_id: int | None = Query(
|
||||
default=None, description="Forward cursor: id of last seen message"
|
||||
),
|
||||
q: str | None = Query(default=None, description="Full-text search query"),
|
||||
) -> list[Message]:
|
||||
"""List messages from the database."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
return await MessageRepository.get_all(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -73,11 +37,6 @@ async def list_messages(
|
||||
conversation_key=conversation_key,
|
||||
before=before,
|
||||
before_id=before_id,
|
||||
after=after,
|
||||
after_id=after_id,
|
||||
q=q,
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -175,9 +134,25 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
)
|
||||
|
||||
# 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())
|
||||
|
||||
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=None,
|
||||
sender_key=db_contact.public_key.lower(),
|
||||
message_text=request.text,
|
||||
is_dm=True,
|
||||
channel_key=None,
|
||||
channel_name=None,
|
||||
sender_timestamp=now,
|
||||
path=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@@ -222,11 +197,8 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
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(
|
||||
@@ -271,8 +243,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
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(
|
||||
@@ -294,9 +264,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -315,9 +282,23 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
paths=paths,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=db_channel.name,
|
||||
)
|
||||
|
||||
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
|
||||
from app.bot import run_bot_for_message
|
||||
|
||||
asyncio.create_task(
|
||||
run_bot_for_message(
|
||||
sender_name=radio_name or None,
|
||||
sender_key=None,
|
||||
message_text=request.text,
|
||||
is_dm=False,
|
||||
channel_key=channel_key_upper,
|
||||
channel_name=db_channel.name,
|
||||
sender_timestamp=now,
|
||||
path=None,
|
||||
is_outgoing=True,
|
||||
)
|
||||
)
|
||||
|
||||
return message
|
||||
@@ -380,12 +361,9 @@ async def resend_channel_message(
|
||||
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}: ") :]
|
||||
@@ -420,8 +398,6 @@ async def resend_channel_message(
|
||||
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).
|
||||
@@ -444,9 +420,6 @@ async def resend_channel_message(
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -71,8 +71,7 @@ async def _run_historical_channel_decryption(
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
realtime=False, # Historical decryption should not trigger fanout
|
||||
trigger_bot=False, # Historical decryption should not trigger bot
|
||||
)
|
||||
|
||||
if msg_id is not None:
|
||||
|
||||
@@ -28,12 +28,6 @@ class RadioConfigResponse(BaseModel):
|
||||
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="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)"
|
||||
)
|
||||
path_hash_mode_supported: bool = Field(
|
||||
default=False, description="Whether firmware supports path hash mode setting"
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -42,12 +36,6 @@ class RadioConfigUpdate(BaseModel):
|
||||
lon: float | None = None
|
||||
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
|
||||
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)",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
@@ -76,8 +64,6 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -117,20 +103,6 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
if update.path_hash_mode is not None:
|
||||
if not radio_manager.path_hash_mode_supported:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="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 HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to set path hash mode: {result.payload}",
|
||||
)
|
||||
radio_manager.path_hash_mode = update.path_hash_mode
|
||||
|
||||
# Sync time with system clock
|
||||
await sync_radio_time(mc)
|
||||
|
||||
|
||||
@@ -7,12 +7,7 @@ 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.repository import ChannelRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/read-state", tags=["read-state"])
|
||||
@@ -31,12 +26,7 @@ async def get_unreads() -> UnreadCounts:
|
||||
mc = radio_manager.meshcore
|
||||
if mc and mc.self_info:
|
||||
name = mc.self_info.get("name") or None
|
||||
settings = await AppSettingsRepository.get()
|
||||
blocked_keys = settings.blocked_keys or None
|
||||
blocked_names = settings.blocked_names or None
|
||||
data = await MessageRepository.get_unread_counts(
|
||||
name, blocked_keys=blocked_keys, blocked_names=blocked_names
|
||||
)
|
||||
data = await MessageRepository.get_unread_counts(name)
|
||||
return UnreadCounts(**data)
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,37 @@ import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.models import AppSettings, BotConfig
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def validate_bot_code(code: str, bot_name: str | None = None) -> None:
|
||||
"""Validate bot code syntax. Raises HTTPException on error."""
|
||||
if not code or not code.strip():
|
||||
return # Empty code is valid (disables bot)
|
||||
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
except SyntaxError as e:
|
||||
name_part = f"'{bot_name}' " if bot_name else ""
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
|
||||
def validate_all_bots(bots: list[BotConfig]) -> None:
|
||||
"""Validate all bots' code syntax. Raises HTTPException on first error."""
|
||||
for bot in bots:
|
||||
validate_bot_code(bot.code, bot.name)
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
default=None,
|
||||
@@ -34,26 +55,48 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
|
||||
)
|
||||
flood_scope: str | None = Field(
|
||||
bots: list[BotConfig] | None = Field(
|
||||
default=None,
|
||||
description="Outbound flood scope / region name (empty = disabled)",
|
||||
description="List of bot configurations",
|
||||
)
|
||||
blocked_keys: list[str] | None = Field(
|
||||
mqtt_broker_host: str | None = Field(
|
||||
default=None,
|
||||
description="Public keys whose messages are hidden from the UI",
|
||||
description="MQTT broker hostname (empty = disabled)",
|
||||
)
|
||||
blocked_names: list[str] | None = Field(
|
||||
mqtt_broker_port: int | None = Field(
|
||||
default=None,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="MQTT broker port",
|
||||
)
|
||||
mqtt_username: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT username (optional)",
|
||||
)
|
||||
mqtt_password: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT password (optional)",
|
||||
)
|
||||
mqtt_use_tls: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to use TLS for MQTT connection",
|
||||
)
|
||||
mqtt_tls_insecure: bool | None = Field(
|
||||
default=None,
|
||||
description="Skip TLS certificate verification (for self-signed certs)",
|
||||
)
|
||||
mqtt_topic_prefix: str | None = Field(
|
||||
default=None,
|
||||
description="MQTT topic prefix",
|
||||
)
|
||||
mqtt_publish_messages: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish decrypted messages to MQTT",
|
||||
)
|
||||
mqtt_publish_raw_packets: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to publish raw packets to MQTT",
|
||||
)
|
||||
|
||||
|
||||
class BlockKeyRequest(BaseModel):
|
||||
key: str = Field(description="Public key to toggle block status")
|
||||
|
||||
|
||||
class BlockNameRequest(BaseModel):
|
||||
name: str = Field(description="Display name to toggle block status")
|
||||
|
||||
|
||||
class FavoriteRequest(BaseModel):
|
||||
@@ -114,34 +157,38 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating advert_interval to %d", interval)
|
||||
kwargs["advert_interval"] = interval
|
||||
|
||||
# Block lists
|
||||
if update.blocked_keys is not None:
|
||||
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
|
||||
if update.blocked_names is not None:
|
||||
kwargs["blocked_names"] = update.blocked_names
|
||||
if update.bots is not None:
|
||||
validate_all_bots(update.bots)
|
||||
logger.info("Updating bots (count=%d)", len(update.bots))
|
||||
kwargs["bots"] = update.bots
|
||||
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
stripped = update.flood_scope.strip()
|
||||
kwargs["flood_scope"] = stripped
|
||||
flood_scope_changed = True
|
||||
# MQTT fields
|
||||
mqtt_fields = [
|
||||
"mqtt_broker_host",
|
||||
"mqtt_broker_port",
|
||||
"mqtt_username",
|
||||
"mqtt_password",
|
||||
"mqtt_use_tls",
|
||||
"mqtt_tls_insecure",
|
||||
"mqtt_topic_prefix",
|
||||
"mqtt_publish_messages",
|
||||
"mqtt_publish_raw_packets",
|
||||
]
|
||||
mqtt_changed = False
|
||||
for field in mqtt_fields:
|
||||
value = getattr(update, field)
|
||||
if value is not None:
|
||||
kwargs[field] = value
|
||||
mqtt_changed = True
|
||||
|
||||
if kwargs:
|
||||
result = await AppSettingsRepository.update(**kwargs)
|
||||
|
||||
# Apply flood scope to radio immediately if changed
|
||||
if flood_scope_changed:
|
||||
from app.radio import radio_manager
|
||||
# Restart MQTT publisher if any MQTT settings changed
|
||||
if mqtt_changed:
|
||||
from app.mqtt import mqtt_publisher
|
||||
|
||||
if radio_manager.is_connected:
|
||||
try:
|
||||
scope = result.flood_scope
|
||||
async with radio_manager.radio_operation("set_flood_scope") as mc:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply flood_scope to radio: %s", e)
|
||||
await mqtt_publisher.restart(result)
|
||||
|
||||
return result
|
||||
|
||||
@@ -171,20 +218,6 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||
"""Toggle a public key's blocked status."""
|
||||
logger.info("Toggling blocked key: %s", request.key[:12])
|
||||
return await AppSettingsRepository.toggle_blocked_key(request.key)
|
||||
|
||||
|
||||
@router.post("/blocked-names/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
"""Toggle a display name's blocked status."""
|
||||
logger.info("Toggling blocked name: %s", request.name)
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
|
||||
@@ -92,26 +92,17 @@ class WebSocketManager:
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
|
||||
def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> None:
|
||||
def broadcast_event(event_type: str, data: dict) -> None:
|
||||
"""Schedule a broadcast without blocking.
|
||||
|
||||
Convenience function that creates an asyncio task to broadcast
|
||||
an event to all connected WebSocket clients and forward to fanout modules.
|
||||
|
||||
Args:
|
||||
event_type: Event type string (e.g. "message", "raw_packet")
|
||||
data: Event payload dict
|
||||
realtime: If False, skip fanout dispatch (used for historical decryption)
|
||||
an event to all connected WebSocket clients and forward to MQTT.
|
||||
"""
|
||||
asyncio.create_task(ws_manager.broadcast(event_type, data))
|
||||
|
||||
if realtime:
|
||||
from app.fanout.manager import fanout_manager
|
||||
from app.mqtt import mqtt_broadcast
|
||||
|
||||
if event_type == "message":
|
||||
asyncio.create_task(fanout_manager.broadcast_message(data))
|
||||
elif event_type == "raw_packet":
|
||||
asyncio.create_task(fanout_manager.broadcast_raw(data))
|
||||
mqtt_broadcast(event_type, data)
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
# Set your serial device for passthrough here! #
|
||||
################################################
|
||||
devices:
|
||||
- /dev/ttyACM0:/dev/ttyUSB0
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0
|
||||
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
install-links=true
|
||||
@@ -12,9 +12,7 @@ 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`
|
||||
|
||||
## Frontend Map
|
||||
|
||||
@@ -29,7 +27,6 @@ frontend/src/
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
├── styles.css # Additional global app styles
|
||||
├── themes.css # Color theme definitions
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
@@ -51,13 +48,10 @@ frontend/src/
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||
│ └── theme.ts # Theme switching helpers
|
||||
│ └── radioPresets.ts # LoRa radio preset configurations
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
@@ -65,32 +59,29 @@ frontend/src/
|
||||
│ ├── MessageList.tsx
|
||||
│ ├── MessageInput.tsx
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SearchView.tsx # Full-text message search pane
|
||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── MapView.tsx
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer3D.tsx
|
||||
│ ├── PathModal.tsx
|
||||
│ ├── PathRouteMap.tsx
|
||||
│ ├── CrackerPanel.tsx
|
||||
│ ├── BotCodeEditor.tsx
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsRadioSection.tsx # Preset, freq/bw/sf/cr, txPower, lat/lon
|
||||
│ │ ├── SettingsIdentitySection.tsx # Name, keys, advert interval
|
||||
│ │ ├── SettingsConnectivitySection.tsx # Connection status, max contacts, reboot
|
||||
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ └── ThemeSelector.tsx # Color theme picker
|
||||
│ │ └── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ ├── repeater/
|
||||
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
|
||||
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
|
||||
@@ -103,8 +94,7 @@ frontend/src/
|
||||
│ │ └── RepeaterConsolePane.tsx # CLI console with history
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ ├── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__)
|
||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
└── test/
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
@@ -124,23 +114,15 @@ frontend/src/
|
||||
├── repeaterLogin.test.tsx
|
||||
├── repeaterMessageParsing.test.ts
|
||||
├── localLabel.test.ts
|
||||
├── messageInput.test.tsx
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
├── channelInfoKeyVisibility.test.tsx
|
||||
├── chatHeaderKeyVisibility.test.tsx
|
||||
├── searchView.test.tsx
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
└── useWebSocket.lifecycle.test.ts
|
||||
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
@@ -164,7 +146,7 @@ frontend/src/
|
||||
|
||||
### New Message modal
|
||||
|
||||
`NewMessageModal` resets form state on close. The component instance persists across open/close cycles for smooth animations.
|
||||
`NewMessageModal` intentionally preserves form state (tab, inputs, checkboxes) when closed and reopened. The component instance persists across open/close cycles. This is by design so users don't lose in-progress input if they accidentally dismiss the dialog.
|
||||
|
||||
### Message behavior
|
||||
|
||||
@@ -178,22 +160,16 @@ frontend/src/
|
||||
- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs).
|
||||
- `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame.
|
||||
- Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets.
|
||||
- Raw-packet 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.
|
||||
- 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.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
@@ -203,7 +179,6 @@ Supported routes:
|
||||
- `#map`
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#search`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
@@ -250,14 +225,11 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
- `bots`
|
||||
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
|
||||
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
|
||||
|
||||
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
||||
|
||||
`HealthStatus` includes `fanout_statuses: Record<string, FanoutStatusEntry>` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
|
||||
|
||||
`FanoutConfig` represents a single fanout integration: `{id, type, name, enabled, config, scope, sort_order, created_at}`.
|
||||
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
|
||||
|
||||
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
||||
|
||||
@@ -278,18 +250,6 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
|
||||
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
## Channel Info Pane
|
||||
|
||||
Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right drawer) showing channel details fetched from `GET /api/channels/{key}/detail`:
|
||||
|
||||
- Header: channel name, key (clickable copy), type badge (hashtag/private key), on-radio badge
|
||||
- Favorite toggle
|
||||
- Message activity: time-windowed counts (1h, 24h, 48h, 7d, all time) + unique senders
|
||||
- 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.
|
||||
|
||||
## Repeater Dashboard
|
||||
|
||||
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
@@ -304,16 +264,6 @@ For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of
|
||||
|
||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||
|
||||
## Message Search Pane
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
## Styling
|
||||
|
||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||
@@ -327,7 +277,7 @@ Do not rely on old class-only layout assumptions.
|
||||
|
||||
## Testing
|
||||
|
||||
Run all quality checks (backend + frontend) from the repo root:
|
||||
Run all quality checks (backend + frontend, parallelized) from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,453 +0,0 @@
|
||||
# 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
3
frontend/lib/meshcore-decoder/dist/cli.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
//# sourceMappingURL=cli.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
||||
409
frontend/lib/meshcore-decoder/dist/cli.js
vendored
409
frontend/lib/meshcore-decoder/dist/cli.js
vendored
@@ -1,409 +0,0 @@
|
||||
#!/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
|
||||
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,94 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,48 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,217 +0,0 @@
|
||||
"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
|
||||
File diff suppressed because one or more lines are too long
@@ -1,23 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,60 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,150 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,576 +0,0 @@
|
||||
"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
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,78 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,24 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,244 +0,0 @@
|
||||
"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
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,123 +0,0 @@
|
||||
"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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,279 +0,0 @@
|
||||
"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
|
||||
File diff suppressed because one or more lines are too long
@@ -1,12 +0,0 @@
|
||||
import { GroupTextPayload } from '../../types/payloads';
|
||||
import { PayloadSegment } from '../../types/packet';
|
||||
import { DecryptionOptions } from '../../types/crypto';
|
||||
export declare class GroupTextPayloadDecoder {
|
||||
static decode(payload: Uint8Array, options?: DecryptionOptions & {
|
||||
includeSegments?: boolean;
|
||||
segmentOffset?: number;
|
||||
}): GroupTextPayload & {
|
||||
segments?: PayloadSegment[];
|
||||
} | null;
|
||||
}
|
||||
//# sourceMappingURL=group-text.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"group-text.d.ts","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/group-text.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAIvD,qBAAa,uBAAuB;IAClC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG;QAAE,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,gBAAgB,GAAG;QAAE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;KAAE,GAAG,IAAI;CAyHnL"}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use strict";
|
||||
// Copyright (c) 2025 Michael Hart: https://github.com/michaelhart/meshcore-decoder
|
||||
// MIT License
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.GroupTextPayloadDecoder = void 0;
|
||||
const enums_1 = require("../../types/enums");
|
||||
const channel_crypto_1 = require("../../crypto/channel-crypto");
|
||||
const hex_1 = require("../../utils/hex");
|
||||
class GroupTextPayloadDecoder {
|
||||
static decode(payload, options) {
|
||||
try {
|
||||
if (payload.length < 3) {
|
||||
const result = {
|
||||
type: enums_1.PayloadType.GroupText,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: ['GroupText payload too short (need at least channel_hash(1) + MAC(2))'],
|
||||
channelHash: '',
|
||||
cipherMac: '',
|
||||
ciphertext: '',
|
||||
ciphertextLength: 0
|
||||
};
|
||||
if (options?.includeSegments) {
|
||||
result.segments = [{
|
||||
name: 'Invalid GroupText Data',
|
||||
description: 'GroupText payload too short (minimum 3 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 offset = 0;
|
||||
// channel hash (1 byte) - first byte of SHA256 of channel's shared key
|
||||
const channelHash = (0, hex_1.byteToHex)(payload[offset]);
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Channel Hash',
|
||||
description: 'First byte of SHA256 of channel\'s shared key',
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset,
|
||||
value: channelHash
|
||||
});
|
||||
}
|
||||
offset += 1;
|
||||
// MAC (2 bytes) - message authentication code
|
||||
const cipherMac = (0, hex_1.bytesToHex)(payload.subarray(offset, offset + 2));
|
||||
if (options?.includeSegments) {
|
||||
segments.push({
|
||||
name: 'Cipher MAC',
|
||||
description: 'MAC for encrypted data',
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + offset + 1,
|
||||
value: cipherMac
|
||||
});
|
||||
}
|
||||
offset += 2;
|
||||
// ciphertext (remaining bytes) - encrypted message
|
||||
const ciphertext = (0, hex_1.bytesToHex)(payload.subarray(offset));
|
||||
if (options?.includeSegments && payload.length > offset) {
|
||||
segments.push({
|
||||
name: 'Ciphertext',
|
||||
description: 'Encrypted message content (timestamp + flags + message)',
|
||||
startByte: segmentOffset + offset,
|
||||
endByte: segmentOffset + payload.length - 1,
|
||||
value: ciphertext
|
||||
});
|
||||
}
|
||||
const groupText = {
|
||||
type: enums_1.PayloadType.GroupText,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: true,
|
||||
channelHash,
|
||||
cipherMac,
|
||||
ciphertext,
|
||||
ciphertextLength: payload.length - 3
|
||||
};
|
||||
// attempt decryption if key store is provided
|
||||
if (options?.keyStore && options.keyStore.hasChannelKey(channelHash)) {
|
||||
// try all possible keys for this hash (handles collisions)
|
||||
const channelKeys = options.keyStore.getChannelKeys(channelHash);
|
||||
for (const channelKey of channelKeys) {
|
||||
const decryptionResult = channel_crypto_1.ChannelCrypto.decryptGroupTextMessage(ciphertext, cipherMac, channelKey);
|
||||
if (decryptionResult.success && decryptionResult.data) {
|
||||
groupText.decrypted = {
|
||||
timestamp: decryptionResult.data.timestamp,
|
||||
flags: decryptionResult.data.flags,
|
||||
sender: decryptionResult.data.sender,
|
||||
message: decryptionResult.data.message
|
||||
};
|
||||
break; // stop trying keys once we find one that works
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options?.includeSegments) {
|
||||
groupText.segments = segments;
|
||||
}
|
||||
return groupText;
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
type: enums_1.PayloadType.GroupText,
|
||||
version: enums_1.PayloadVersion.Version1,
|
||||
isValid: false,
|
||||
errors: [error instanceof Error ? error.message : 'Failed to decode GroupText payload'],
|
||||
channelHash: '',
|
||||
cipherMac: '',
|
||||
ciphertext: '',
|
||||
ciphertextLength: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.GroupTextPayloadDecoder = GroupTextPayloadDecoder;
|
||||
//# sourceMappingURL=group-text.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"group-text.js","sourceRoot":"","sources":["../../../src/decoder/payload-decoders/group-text.ts"],"names":[],"mappings":";AAAA,mFAAmF;AACnF,cAAc;;;AAId,6CAAgE;AAEhE,gEAA4D;AAC5D,yCAAwD;AAExD,MAAa,uBAAuB;IAClC,MAAM,CAAC,MAAM,CAAC,OAAmB,EAAE,OAAmF;QACpH,IAAI,CAAC;YACH,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACvB,MAAM,MAAM,GAAuD;oBACjE,IAAI,EAAE,mBAAW,CAAC,SAAS;oBAC3B,OAAO,EAAE,sBAAc,CAAC,QAAQ;oBAChC,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,CAAC,sEAAsE,CAAC;oBAChF,WAAW,EAAE,EAAE;oBACf,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,wBAAwB;4BAC9B,WAAW,EAAE,wDAAwD;4BACrE,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,uEAAuE;YACvE,MAAM,WAAW,GAAG,IAAA,eAAS,EAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;YAC/C,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,cAAc;oBACpB,WAAW,EAAE,+CAA+C;oBAC5D,SAAS,EAAE,aAAa,GAAG,MAAM;oBACjC,OAAO,EAAE,aAAa,GAAG,MAAM;oBAC/B,KAAK,EAAE,WAAW;iBACnB,CAAC,CAAC;YACL,CAAC;YACD,MAAM,IAAI,CAAC,CAAC;YAEZ,8CAA8C;YAC9C,MAAM,SAAS,GAAG,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;YACnE,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,YAAY;oBAClB,WAAW,EAAE,wBAAwB;oBACrC,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,mDAAmD;YACnD,MAAM,UAAU,GAAG,IAAA,gBAAU,EAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;YACxD,IAAI,OAAO,EAAE,eAAe,IAAI,OAAO,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC;gBACxD,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,YAAY;oBAClB,WAAW,EAAE,yDAAyD;oBACtE,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,SAAS,GAAuD;gBACpE,IAAI,EAAE,mBAAW,CAAC,SAAS;gBAC3B,OAAO,EAAE,sBAAc,CAAC,QAAQ;gBAChC,OAAO,EAAE,IAAI;gBACb,WAAW;gBACX,SAAS;gBACT,UAAU;gBACV,gBAAgB,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC;aACrC,CAAC;YAEF,8CAA8C;YAC9C,IAAI,OAAO,EAAE,QAAQ,IAAI,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,WAAW,CAAC,EAAE,CAAC;gBACrE,2DAA2D;gBAC3D,MAAM,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC;gBAEjE,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;oBACrC,MAAM,gBAAgB,GAAG,8BAAa,CAAC,uBAAuB,CAC5D,UAAU,EACV,SAAS,EACT,UAAU,CACX,CAAC;oBAEF,IAAI,gBAAgB,CAAC,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,CAAC;wBACtD,SAAS,CAAC,SAAS,GAAG;4BACpB,SAAS,EAAE,gBAAgB,CAAC,IAAI,CAAC,SAAS;4BAC1C,KAAK,EAAE,gBAAgB,CAAC,IAAI,CAAC,KAAK;4BAClC,MAAM,EAAE,gBAAgB,CAAC,IAAI,CAAC,MAAM;4BACpC,OAAO,EAAE,gBAAgB,CAAC,IAAI,CAAC,OAAO;yBACvC,CAAC;wBACF,MAAM,CAAC,+CAA+C;oBACxD,CAAC;gBACH,CAAC;YACH,CAAC;YAED,IAAI,OAAO,EAAE,eAAe,EAAE,CAAC;gBAC7B,SAAS,CAAC,QAAQ,GAAG,QAAQ,CAAC;YAChC,CAAC;YAED,OAAO,SAAS,CAAC;QACnB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO;gBACL,IAAI,EAAE,mBAAW,CAAC,SAAS;gBAC3B,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,oCAAoC,CAAC;gBACvF,WAAW,EAAE,EAAE;gBACf,SAAS,EAAE,EAAE;gBACb,UAAU,EAAE,EAAE;gBACd,gBAAgB,EAAE,CAAC;aACpB,CAAC;QACJ,CAAC;IACH,CAAC;CACF;AA1HD,0DA0HC"}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PathPayload } from '../../types/payloads';
|
||||
export declare class PathPayloadDecoder {
|
||||
static decode(payload: Uint8Array): PathPayload | null;
|
||||
}
|
||||
//# sourceMappingURL=path.d.ts.map
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user