mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 04:16:05 +02:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9358bf4199 | |||
| c31779f1a9 | |||
| 4eb29f376e | |||
| a69eb9c534 | |||
| 70aabb78aa | |||
| cafd9678ee | |||
| a8e346d0c5 | |||
| 55f05bf03b | |||
| 091ba06ccf | |||
| c5c828a4ed | |||
| 7eac3a9754 | |||
| 329df1a0d2 | |||
| ecb4c99a43 | |||
| 2f412e1a93 | |||
| 0353a98e87 | |||
| 3e2258c34b | |||
| e695d629b9 | |||
| 300677aca3 | |||
| b89f7ce76b | |||
| 82bd25a09f | |||
| 7528e4121f | |||
| b8f0228f68 | |||
| 25089930f1 | |||
| 291bd85c78 | |||
| 4bc87b4a0f | |||
| 6d0434d59e | |||
| f22184c166 | |||
| d10de8abf7 | |||
| 5f78294cd1 | |||
| 6b81dd3082 | |||
| cc2b16e53f | |||
| 330007e120 | |||
| f5a2a21f11 | |||
| a3e62885d4 | |||
| dbdd722c48 | |||
| c8c8e6b549 | |||
| b8683e57d8 | |||
| 491f159463 | |||
| ead74e975b | |||
| 4fbd245ee4 | |||
| dc7ec13cc5 | |||
| cfa2bf575c | |||
| e9ef68432a | |||
| 476adf393f | |||
| f7a311d74b | |||
| 09f807230b | |||
| c098f9eeb5 | |||
| 05493d06fc | |||
| 6c1b8bd7e9 |
@@ -0,0 +1,10 @@
|
|||||||
|
name: "RemoteTerm CodeQL config"
|
||||||
|
|
||||||
|
# Exclude rules that flag intentional design decisions:
|
||||||
|
# - AES-ECB is required by the MeshCore radio protocol wire format
|
||||||
|
# - Repeater/room passwords are not meaningfully sensitive secrets
|
||||||
|
query-filters:
|
||||||
|
- exclude:
|
||||||
|
id: py/weak-cryptographic-algorithm
|
||||||
|
- exclude:
|
||||||
|
id: js/clear-text-storage-of-sensitive-data
|
||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-checks:
|
backend-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [javascript-typescript, python]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
config-file: .github/codeql/codeql-config.yml
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
@@ -25,6 +25,9 @@ concurrency:
|
|||||||
group: publish-aur
|
group: publish-aur
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-aur:
|
publish-aur:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
|||||||
|
|
||||||
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
|
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, SQS)
|
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||||
|
- `app/tcp_proxy/AGENTS_tcp_proxy.md` - TCP companion protocol proxy (emulates a MeshCore radio for remote clients)
|
||||||
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
@@ -321,6 +322,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||||
|
| GET | `/api/radio/private-key` | Export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`) |
|
||||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
@@ -379,6 +381,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||||
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
||||||
|
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||||
| GET | `/api/fanout` | List all fanout configs |
|
| GET | `/api/fanout` | List all fanout configs |
|
||||||
| POST | `/api/fanout` | Create new fanout config |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
@@ -503,6 +506,11 @@ mc.subscribe(EventType.ACK, handler)
|
|||||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||||
|
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||||
|
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||||
|
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the MeshCore TCP companion protocol proxy (see `app/tcp_proxy/AGENTS_tcp_proxy.md`) |
|
||||||
|
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the TCP proxy server |
|
||||||
|
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the TCP proxy server |
|
||||||
|
|
||||||
**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`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
**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`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
## [3.12.3] - 2026-04-24
|
||||||
|
|
||||||
|
* Feature: Customizable Apprise strings
|
||||||
|
* Feature: Choose contact addition type
|
||||||
|
* Featuer: Make bulk-delete sortable by last-heard
|
||||||
|
* Misc: Bypass error on fail-to-unload-contact when it's not there
|
||||||
|
* Misc: Docs & test updates
|
||||||
|
|
||||||
|
## [3.12.2] - 2026-04-21
|
||||||
|
|
||||||
|
* Feature: Auto-disambiguate colliding LPP sensor names
|
||||||
|
* Feature: Radio config import/export
|
||||||
|
* Bugfix: Don't push stale firmware version/model on community MQTT
|
||||||
|
* Misc: Expose env vars in debug blob
|
||||||
|
* Misc: Longer linger for web push error
|
||||||
|
* Misc: Docs, test, & CI/CD improvements
|
||||||
|
|
||||||
|
## [3.12.1] - 2026-04-19
|
||||||
|
|
||||||
|
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
|
||||||
|
* Feature: Channel mute
|
||||||
|
* Misc: HA Documentation improvements
|
||||||
|
* Misc: Bump deps & update tests
|
||||||
|
* Misc: Improve warnings around web push in untrusted contexts
|
||||||
|
|
||||||
## [3.12.0] - 2026-04-17
|
## [3.12.0] - 2026-04-17
|
||||||
|
|
||||||
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
|
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# RemoteTerm for MeshCore
|
# RemoteTerm for MeshCore
|
||||||
|
|
||||||
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
|
Backend server + browser interface for MeshCore mesh radio networks, providing a rich, web-based power-user management and messaging system through a companion radio.
|
||||||
|
|
||||||
|
Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||||
|
|
||||||
* Send and receive DMs and channel messages
|
* Send and receive DMs and channel messages
|
||||||
* Cache all received packets, decrypting as you gain keys
|
* Cache all received packets, decrypting as you gain keys
|
||||||
@@ -8,8 +10,8 @@ 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)
|
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||||
* Access your radio remotely over your network or VPN
|
* Access your radio remotely over your network or VPN
|
||||||
* Search for hashtag channel names for channels you don't have keys for yet
|
* Search for hashtag channel names for channels you don't have keys for yet
|
||||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
* Forward packets, messages, and automatic repeater telemetry to MQTT, Home Assistant, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
* Use the more recent 1.14+ firmwares which support multibyte pathing
|
||||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||||
|
|
||||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
@@ -83,7 +85,7 @@ Access the app at http://localhost:8000.
|
|||||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
> Running on lightweight hardware, or just don't want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||||
@@ -118,7 +120,7 @@ bash scripts/setup/install_docker.sh
|
|||||||
|
|
||||||
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
|
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
|
||||||
|
|
||||||
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
Your local `docker-compose.yml` is gitignored so future pulls don't overwrite your Docker settings.
|
||||||
|
|
||||||
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||||
|
|
||||||
@@ -240,6 +242,7 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
|
|||||||
## Where To Go Next
|
## Where To Go Next
|
||||||
|
|
||||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||||
|
- Home Assistant-specific guidance and entity/sensor naming schemes: [README_HA.md](README_HA.md)
|
||||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||||
|
|
||||||
|
|||||||
+73
-6
@@ -1,24 +1,91 @@
|
|||||||
# Advanced Setup And Troubleshooting
|
# Advanced Setup And Troubleshooting
|
||||||
|
|
||||||
## Remediation Environment Variables
|
## Remediation & Advanced Environment Variables
|
||||||
|
|
||||||
These are intended for diagnosing or working around radios that behave oddly.
|
These are intended for diagnosing or working around radios that behave oddly, or enabling advanced functionality.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages ([docs](#message-poll-fallback)) |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send ([docs](#force-channel-slot-reconfigure)) |
|
||||||
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
|
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading ([docs](#autoevict-mode)) |
|
||||||
|
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot ([docs](#clock-wraparound)) |
|
||||||
|
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex for backup or migration. Only enable on a trusted network. Import via `PUT /api/radio/private-key` is always available. ([docs](#private-key-export)) |
|
||||||
|
|
||||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||||
|
|
||||||
- whether messages were left on the radio without reaching the app through event subscription
|
- whether messages were left on the radio without reaching the app through event subscription
|
||||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||||
|
|
||||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
If the audit finds a mismatch, you'll see an error in the application UI and your logs.
|
||||||
|
|
||||||
|
### Message Poll Fallback
|
||||||
|
|
||||||
|
If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net.
|
||||||
|
|
||||||
|
### Force Channel Slot Reconfigure
|
||||||
|
|
||||||
|
If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||||
|
|
||||||
|
### Clock Wraparound
|
||||||
|
|
||||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||||
|
|
||||||
|
### Private Key Export
|
||||||
|
|
||||||
|
`MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true` enables `GET /api/radio/private-key`, which returns the in-memory private key as hex for backup or migration. The key is held in memory only (exported from the radio on connect) and is never persisted to disk. Only enable this on a trusted network when you need to retrieve the key.
|
||||||
|
|
||||||
|
Import via `PUT /api/radio/private-key` is always available regardless of this setting — it is write-only and does not expose key material.
|
||||||
|
|
||||||
|
The Radio Settings config export/import feature uses these endpoints. When export is disabled, config exports will omit the private key and show a notice.
|
||||||
|
|
||||||
|
## MeshCore TCP Proxy
|
||||||
|
|
||||||
|
RemoteTerm can emulate a MeshCore companion radio over TCP, allowing MeshCore clients (mobile apps, meshcore-cli, meshcore-ha) to connect to it as if it were a directly-connected radio.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the TCP companion protocol proxy |
|
||||||
|
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the proxy TCP server |
|
||||||
|
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the proxy TCP server |
|
||||||
|
|
||||||
|
Once enabled, MeshCore clients can connect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meshcore-cli --tcp <host>:5001
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:** The proxy translates the MeshCore companion binary protocol into in-process RemoteTerm operations. Contacts, channels, and messages come from the RemoteTerm database. Outgoing messages are sent through RemoteTerm's send orchestration (with radio lock, retries, and ACK tracking). Incoming messages are pushed to connected clients in real time.
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Only favorite contacts are synced to clients
|
||||||
|
- Only favorite channels are pre-loaded into slots; clients can load additional channels via SET_CHANNEL (local to the proxy session, does not modify RemoteTerm channel configuration)
|
||||||
|
- DMs receive an immediate synthetic ACK; actual delivery retries are handled server-side by RemoteTerm
|
||||||
|
- Radio configuration changes (SET_NAME, SET_LATLON) are applied to the real radio
|
||||||
|
|
||||||
|
## Contact Loading Issues
|
||||||
|
|
||||||
|
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
|
||||||
|
|
||||||
|
On BLE connections with many contacts (or radios with large contact tables from organic advertisements), the initial contact enumeration may time out. If this happens, the app will still attempt to load your favorites and recent contacts onto the radio on a best-effort basis, but without a full snapshot of what's already on the radio, some adds may be redundant or fail.
|
||||||
|
|
||||||
|
If the radio's contact table is already full (from contacts added by advertisements or another client), the app may not be able to load all desired contacts. In this case you'll see a warning that auto-DM acking may not work for all contacts. To resolve this:
|
||||||
|
|
||||||
|
- **Clear the radio's contact table** using another MeshCore client (e.g., the official companion app), then restart RemoteTerm
|
||||||
|
- **Lower the contact fill target** in Radio Settings to reduce how many contacts the app tries to load
|
||||||
|
- **Enable autoevict mode** (see below) to let the radio automatically make room
|
||||||
|
- If you don't need auto-DM acking, you can safely ignore these warnings — **sending and receiving messages is never affected**
|
||||||
|
|
||||||
|
### Autoevict Mode
|
||||||
|
|
||||||
|
Setting `MESHCORE_LOAD_WITH_AUTOEVICT=true` enables an alternative contact loading strategy that avoids TABLE_FULL errors entirely. On connect, the app enables the radio's `AUTO_ADD_OVERWRITE_OLDEST` preference, which makes the radio automatically evict the oldest non-favorite contact when the contact table is full. This means:
|
||||||
|
|
||||||
|
- Contact adds never fail — the radio always makes room by evicting stale contacts
|
||||||
|
- The app can load contacts even when it can't enumerate the radio's existing contact table (e.g., on slow BLE connections)
|
||||||
|
- No contact removal step is needed during reconciliation
|
||||||
|
|
||||||
|
**Trade-off:** Contacts loaded by the app are not marked as radio-side favorites, so they are eviction candidates if the radio receives a new advertisement while full. In practice, freshly-loaded contacts have a recent `lastmod` timestamp and will be among the last to be evicted. If you disconnect the radio from RemoteTerm and use it standalone, your contacts will not be protected from eviction by newer advertisements.
|
||||||
|
|
||||||
## Sub-Path Reverse Proxy
|
## Sub-Path Reverse Proxy
|
||||||
|
|
||||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||||
|
|||||||
+194
-39
@@ -21,25 +21,23 @@ Devices will appear in HA under **Settings > Devices & Services > MQTT** within
|
|||||||
|
|
||||||
## How MeshCore IDs Map Into Home Assistant
|
## How MeshCore IDs Map Into Home Assistant
|
||||||
|
|
||||||
RemoteTerm uses each node's public key to derive a stable short identifier:
|
RemoteTerm uses each node's public key to derive a stable short identifier for MQTT topics:
|
||||||
|
|
||||||
- Full public key: `ae92577bae6c4f1d...`
|
- Full public key: `ae92577bae6c4f1d...`
|
||||||
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
||||||
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
|
- Example MQTT topic: `meshcore/ae92577bae6c/gps`
|
||||||
- Example runtime topic: `meshcore/ae92577bae6c/gps`
|
|
||||||
|
|
||||||
When this README shows `<node_id>`, it always means that 12-character value.
|
When this README shows `<node_id>`, it always means that 12-character value. Node IDs appear in:
|
||||||
|
|
||||||
The same node ID appears in:
|
- MQTT discovery topics under `homeassistant/...`
|
||||||
|
|
||||||
- Home Assistant entity IDs
|
|
||||||
- Home Assistant discovery topics under `homeassistant/...`
|
|
||||||
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
|
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
|
||||||
|
|
||||||
You can also see these IDs in RemoteTerm's Home Assistant integration UI:
|
**Entity IDs** are different — HA auto-generates them from the device name and entity name, not from the node ID. For example, a radio named "MyRadio" produces entities like `binary_sensor.myradio_connected` and `event.myradio_messages`. A contact named "Alice" produces `device_tracker.alice`. You can find your actual entity IDs in **Settings > Devices & Services > MQTT** in HA, and you can rename them in HA's UI without affecting the integration.
|
||||||
|
|
||||||
|
You can also see the MQTT topic IDs in RemoteTerm's Home Assistant integration UI:
|
||||||
|
|
||||||
- `What gets created in Home Assistant`
|
- `What gets created in Home Assistant`
|
||||||
- `Published Topic Summary`
|
- `Published topic summary`
|
||||||
|
|
||||||
## What Gets Created
|
## What Gets Created
|
||||||
|
|
||||||
@@ -49,8 +47,8 @@ Always created. Updates every 60 seconds.
|
|||||||
|
|
||||||
| Entity | Type | Description |
|
| Entity | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
|
| `binary_sensor.<radio_name>_connected` | Connectivity | Radio online/offline |
|
||||||
| `sensor.meshcore_<radio_node_id>_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
| `sensor.<radio_name>_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
||||||
|
|
||||||
### Repeater Devices
|
### Repeater Devices
|
||||||
|
|
||||||
@@ -60,13 +58,13 @@ Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm'
|
|||||||
|
|
||||||
| Entity | Type | Unit | Description |
|
| Entity | Type | Unit | Description |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
|
| `sensor.<repeater_name>_battery_voltage` | Voltage | V | Battery level |
|
||||||
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
|
| `sensor.<repeater_name>_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||||
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
| `sensor.<repeater_name>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||||
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
| `sensor.<repeater_name>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||||
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
|
| `sensor.<repeater_name>_packets_received` | -- | count | Total packets received |
|
||||||
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
|
| `sensor.<repeater_name>_packets_sent` | -- | count | Total packets sent |
|
||||||
| `sensor.meshcore_<repeater_node_id>_uptime` | Duration | s | Uptime since last reboot |
|
| `sensor.<repeater_name>_uptime` | Duration | s | Uptime since last reboot |
|
||||||
|
|
||||||
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
|
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
|
||||||
|
|
||||||
@@ -76,11 +74,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
|
|||||||
|
|
||||||
| Entity | Description |
|
| Entity | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `device_tracker.meshcore_<contact_node_id>` | GPS position (latitude/longitude) |
|
| `device_tracker.<contact_name>` | GPS position (latitude/longitude) |
|
||||||
|
|
||||||
### Message Event Entity
|
### Message Event Entity
|
||||||
|
|
||||||
A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
|
A single radio-scoped event entity, `event.<radio_name>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
|
||||||
|
|
||||||
| Attribute | Example | Description |
|
| Attribute | Example | Description |
|
||||||
|-----------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
@@ -95,9 +93,9 @@ A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, f
|
|||||||
|
|
||||||
## Entity Naming
|
## Entity Naming
|
||||||
|
|
||||||
Entity IDs use the first 12 characters of the node's public key as an identifier. For example, a contact with public key `ae92577bae6c...` gets entity ID `device_tracker.meshcore_ae92577bae6c`. You can rename entities in HA's UI without affecting the integration.
|
HA auto-generates entity IDs by slugifying the device name and entity name. For a radio named "My Radio", entities look like `binary_sensor.my_radio_connected` and `event.my_radio_messages`. For a repeater named "Hilltop", `sensor.hilltop_battery_voltage`. For a contact named "Alice", `device_tracker.alice`. You can rename entities in HA's UI without affecting the integration.
|
||||||
|
|
||||||
That same 12-character node ID is also used in the MQTT topic paths. For example:
|
MQTT topic paths use the 12-character node ID (first 12 hex characters of the public key). For example:
|
||||||
|
|
||||||
- Local radio health: `meshcore/<radio_node_id>/health`
|
- Local radio health: `meshcore/<radio_node_id>/health`
|
||||||
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
|
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
|
||||||
@@ -117,7 +115,7 @@ That same 12-character node ID is also used in the MQTT topic paths. For example
|
|||||||
|
|
||||||
Notify when a tracked repeater's battery drops below a threshold.
|
Notify when a tracked repeater's battery drops below a threshold.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_<repeater_node_id>_battery_voltage`, below `3.8`, action: notification.
|
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.<repeater_name>_battery_voltage`, below `3.8`, action: notification.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -125,22 +123,22 @@ automation:
|
|||||||
- alias: "Repeater battery low"
|
- alias: "Repeater battery low"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: numeric_state
|
- platform: numeric_state
|
||||||
entity_id: sensor.meshcore_aabbccddeeff_battery_voltage
|
entity_id: sensor.hilltop_battery_voltage
|
||||||
below: 3.8
|
below: 3.8
|
||||||
action:
|
action:
|
||||||
- service: notify.mobile_app_your_phone
|
- service: notify.mobile_app_your_phone
|
||||||
data:
|
data:
|
||||||
title: "Repeater Battery Low"
|
title: "Repeater Battery Low"
|
||||||
message: >-
|
message: >-
|
||||||
{{ state_attr('sensor.meshcore_aabbccddeeff_battery_voltage', 'friendly_name') }}
|
{{ state_attr('sensor.hilltop_battery_voltage', 'friendly_name') }}
|
||||||
is at {{ states('sensor.meshcore_aabbccddeeff_battery_voltage') }}V
|
is at {{ states('sensor.hilltop_battery_voltage') }}V
|
||||||
```
|
```
|
||||||
|
|
||||||
### Radio offline alert
|
### Radio offline alert
|
||||||
|
|
||||||
Notify if the radio has been disconnected for more than 5 minutes.
|
Notify if the radio has been disconnected for more than 5 minutes.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_<radio_node_id>_connected`, to `off`, for `00:05:00`, action: notification.
|
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.<radio_name>_connected`, to `off`, for `00:05:00`, action: notification.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -148,7 +146,7 @@ automation:
|
|||||||
- alias: "Radio offline"
|
- alias: "Radio offline"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: binary_sensor.meshcore_aabbccddeeff_connected
|
entity_id: binary_sensor.myradio_connected
|
||||||
to: "off"
|
to: "off"
|
||||||
for: "00:05:00"
|
for: "00:05:00"
|
||||||
action:
|
action:
|
||||||
@@ -166,7 +164,7 @@ Trigger when a message arrives in a specific channel. Two approaches:
|
|||||||
|
|
||||||
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
|
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages`, action: notification.
|
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages`, action: notification.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -174,7 +172,7 @@ automation:
|
|||||||
- alias: "Emergency channel alert"
|
- alias: "Emergency channel alert"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
action:
|
action:
|
||||||
- service: notify.mobile_app_your_phone
|
- service: notify.mobile_app_your_phone
|
||||||
data:
|
data:
|
||||||
@@ -188,7 +186,7 @@ automation:
|
|||||||
|
|
||||||
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
|
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages` > Add condition > Template > enter the template below.
|
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages` > Add condition > Template > enter the template below.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -196,7 +194,7 @@ automation:
|
|||||||
- alias: "Emergency channel alert"
|
- alias: "Emergency channel alert"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
condition:
|
condition:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: >-
|
value_template: >-
|
||||||
@@ -218,7 +216,7 @@ automation:
|
|||||||
- alias: "DM from Alice"
|
- alias: "DM from Alice"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
condition:
|
condition:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: >-
|
value_template: >-
|
||||||
@@ -239,7 +237,7 @@ automation:
|
|||||||
- alias: "Keyword alert"
|
- alias: "Keyword alert"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
condition:
|
condition:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: >-
|
value_template: >-
|
||||||
@@ -264,7 +262,7 @@ Add a sensor card to any dashboard:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: sensor
|
type: sensor
|
||||||
entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
entity: sensor.hilltop_battery_voltage
|
||||||
name: "Hilltop Repeater Battery"
|
name: "Hilltop Repeater Battery"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -274,14 +272,171 @@ Or an entities card for multiple repeaters:
|
|||||||
type: entities
|
type: entities
|
||||||
title: "Repeater Status"
|
title: "Repeater Status"
|
||||||
entities:
|
entities:
|
||||||
- entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
- entity: sensor.hilltop_battery_voltage
|
||||||
name: "Hilltop"
|
name: "Hilltop"
|
||||||
- entity: sensor.meshcore_ccdd11223344_battery_voltage
|
- entity: sensor.valley_battery_voltage
|
||||||
name: "Valley"
|
name: "Valley"
|
||||||
- entity: sensor.meshcore_eeff55667788_battery_voltage
|
- entity: sensor.ridge_battery_voltage
|
||||||
name: "Ridge"
|
name: "Ridge"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Full monitoring dashboard with message feed
|
||||||
|
|
||||||
|
This example creates a dashboard with repeater vitals, a live message feed, and a network activity graph. Replace the three slug values below to match your setup — find your entity IDs in **Settings > Devices & Services > MQTT**.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ┌─────────────────────────────────────────────────────┐
|
||||||
|
# │ Replace these three values to match your entities │
|
||||||
|
# │ │
|
||||||
|
# │ radio_slug: the prefix on your radio sensors │
|
||||||
|
# │ e.g. sensor.MYRADIO_noise_floor │
|
||||||
|
# │ repeater_slug: the prefix on your repeater sensors │
|
||||||
|
# │ e.g. sensor.HILLTOP_battery_voltage │
|
||||||
|
# │ message_event: your message event entity ID │
|
||||||
|
# │ e.g. event.MYRADIO_messages │
|
||||||
|
# └─────────────────────────────────────────────────────┘
|
||||||
|
#
|
||||||
|
# radio_slug: myradio
|
||||||
|
# repeater_slug: hilltop
|
||||||
|
# message_event: event.myradio_messages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1 — Dashboard YAML** (Settings > Dashboards > Add > edit in YAML):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
views:
|
||||||
|
- title: MeshCore
|
||||||
|
icon: mdi:radio-tower
|
||||||
|
cards:
|
||||||
|
- type: entities
|
||||||
|
title: Hilltop — Current # ← repeater name
|
||||||
|
state_color: true
|
||||||
|
entities:
|
||||||
|
- entity: sensor.hilltop_battery_voltage # ← repeater_slug
|
||||||
|
name: Battery
|
||||||
|
- entity: sensor.hilltop_noise_floor # ← repeater_slug
|
||||||
|
name: Noise Floor
|
||||||
|
- entity: sensor.hilltop_last_rssi # ← repeater_slug
|
||||||
|
name: Last RSSI
|
||||||
|
- entity: sensor.hilltop_last_snr # ← repeater_slug
|
||||||
|
name: Last SNR
|
||||||
|
- entity: sensor.hilltop_uptime # ← repeater_slug
|
||||||
|
name: Uptime
|
||||||
|
- entity: sensor.hilltop_packets_received # ← repeater_slug
|
||||||
|
name: Packets Rx
|
||||||
|
- entity: sensor.hilltop_packets_sent # ← repeater_slug
|
||||||
|
name: Packets Tx
|
||||||
|
|
||||||
|
- type: statistics-graph
|
||||||
|
title: Battery Voltage
|
||||||
|
entities:
|
||||||
|
- sensor.hilltop_battery_voltage # ← repeater_slug
|
||||||
|
stat_types: [mean, min, max]
|
||||||
|
days_to_show: 7
|
||||||
|
period: hour
|
||||||
|
|
||||||
|
- type: statistics-graph
|
||||||
|
title: Noise Floor
|
||||||
|
entities:
|
||||||
|
- sensor.hilltop_noise_floor # ← repeater_slug
|
||||||
|
stat_types: [mean, min, max]
|
||||||
|
days_to_show: 7
|
||||||
|
period: hour
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
title: Message Feed (Last 10)
|
||||||
|
content: |
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
{% set msg = states('input_text.meshcore_msg_' ~ i) %}
|
||||||
|
{% if msg and msg not in ['unknown', '', 'unavailable'] %}
|
||||||
|
{{ msg }}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if states('input_text.meshcore_msg_1') in ['unknown', '', 'unavailable'] %}
|
||||||
|
*No messages yet.*
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
- type: statistics-graph
|
||||||
|
title: Overall Packets Received
|
||||||
|
entities:
|
||||||
|
- sensor.myradio_packets_received # ← radio_slug
|
||||||
|
stat_types: [change]
|
||||||
|
days_to_show: 7
|
||||||
|
period: hour
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 — Message feed helpers**: create 10 text helpers named `MeshCore Msg 1` through `MeshCore Msg 10` (Settings > Helpers > Add > Text). These act as a rolling buffer for the Markdown card above.
|
||||||
|
|
||||||
|
**Step 3 — Message feed automation** (Settings > Automations > Create > edit in YAML):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: MeshCore Message Feed Buffer
|
||||||
|
description: Rolling buffer of recent mesh messages for dashboard display
|
||||||
|
mode: queued
|
||||||
|
max: 10
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: event.myradio_messages # ← message_event
|
||||||
|
actions:
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_10
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_9') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_9
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_8') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_8
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_7') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_7
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_6') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_6
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_5') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_5
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_4') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_4
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_3') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_3
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_2') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_2
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_1') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_1
|
||||||
|
data:
|
||||||
|
value: >-
|
||||||
|
{{ as_timestamp(trigger.to_state.last_changed) |
|
||||||
|
timestamp_custom('%-I:%M %p') }} |
|
||||||
|
**{% if trigger.to_state.attributes.channel_name %}{{
|
||||||
|
trigger.to_state.attributes.channel_name }}{% else %}DM{% endif %}** |
|
||||||
|
{{ trigger.to_state.attributes.sender_name or 'Unknown' }}:
|
||||||
|
{{ (trigger.to_state.attributes.text or '')[:180] }}
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Devices don't appear in HA
|
### Devices don't appear in HA
|
||||||
|
|||||||
+15
-2
@@ -55,6 +55,7 @@ app/
|
|||||||
│ ├── send.py # pywebpush wrapper (async via thread executor)
|
│ ├── send.py # pywebpush wrapper (async via thread executor)
|
||||||
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
|
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
|
||||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||||
|
├── tcp_proxy/ # MeshCore TCP companion protocol proxy (see tcp_proxy/AGENTS_tcp_proxy.md)
|
||||||
├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler
|
├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler
|
||||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||||
@@ -196,6 +197,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
### Radio
|
### Radio
|
||||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||||
|
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
|
||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
@@ -266,6 +268,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
- `POST /settings/blocked-names/toggle`
|
- `POST /settings/blocked-names/toggle`
|
||||||
- `POST /settings/tracked-telemetry/toggle`
|
- `POST /settings/tracked-telemetry/toggle`
|
||||||
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
||||||
|
- `POST /settings/muted-channels/toggle`
|
||||||
|
|
||||||
### Fanout
|
### Fanout
|
||||||
- `GET /fanout` — list all fanout configs
|
- `GET /fanout` — list all fanout configs
|
||||||
@@ -396,7 +399,7 @@ tests/
|
|||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
├── test_migrations.py # Schema migration system
|
├── test_mqtt_ha.py # MQTT HA (high-availability) behavior
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
@@ -415,10 +418,20 @@ tests/
|
|||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
|
├── test_push_send.py # Web Push send/dispatch
|
||||||
|
├── test_radio_stats.py # Radio stats sampling and noise-floor history
|
||||||
|
├── test_repeater_telemetry.py # Repeater telemetry history recording
|
||||||
|
├── test_service_installer.py # Service installer script behavior
|
||||||
|
├── test_sqs_fanout.py # SQS fanout module
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
|
├── test_telemetry_interval.py # Telemetry interval scheduling math
|
||||||
├── test_version_info.py # Version/build metadata resolution
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
├── test_websocket_route.py # WS endpoint lifecycle
|
||||||
|
├── test_tcp_proxy_protocol.py # TCP proxy frame parsing and helpers
|
||||||
|
├── test_tcp_proxy_encoder.py # TCP proxy binary encoding
|
||||||
|
├── test_tcp_proxy_session.py # TCP proxy session command handlers
|
||||||
|
└── test_tcp_proxy_integration.py # TCP proxy end-to-end frame exchange
|
||||||
```
|
```
|
||||||
|
|
||||||
## Errata & Known Non-Issues
|
## Errata & Known Non-Issues
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||||
)
|
)
|
||||||
|
enable_local_private_key_export: bool = False
|
||||||
|
load_with_autoevict: bool = False
|
||||||
skip_post_connect_sync: bool = False
|
skip_post_connect_sync: bool = False
|
||||||
basic_auth_username: str = ""
|
basic_auth_username: str = ""
|
||||||
basic_auth_password: str = ""
|
basic_auth_password: str = ""
|
||||||
|
tcp_proxy_enabled: bool = False
|
||||||
|
tcp_proxy_bind: str = "0.0.0.0"
|
||||||
|
tcp_proxy_port: int = 5001
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_transport_exclusivity(self) -> "Settings":
|
def validate_transport_exclusivity(self) -> "Settings":
|
||||||
|
|||||||
+18
-2
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
flood_scope_override TEXT,
|
flood_scope_override TEXT,
|
||||||
path_hash_mode_override INTEGER,
|
path_hash_mode_override INTEGER,
|
||||||
last_read_at INTEGER,
|
last_read_at INTEGER,
|
||||||
favorite INTEGER DEFAULT 0
|
favorite INTEGER DEFAULT 0,
|
||||||
|
muted INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -112,7 +113,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
discovery_blocked_types TEXT DEFAULT '[]',
|
discovery_blocked_types TEXT DEFAULT '[]',
|
||||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||||
auto_resend_channel INTEGER DEFAULT 0,
|
auto_resend_channel INTEGER DEFAULT 0,
|
||||||
telemetry_interval_hours INTEGER DEFAULT 8
|
telemetry_interval_hours INTEGER DEFAULT 8,
|
||||||
|
vapid_private_key TEXT DEFAULT '',
|
||||||
|
vapid_public_key TEXT DEFAULT '',
|
||||||
|
push_conversations TEXT DEFAULT '[]'
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||||
|
|
||||||
@@ -134,6 +138,18 @@ CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_success_at INTEGER,
|
||||||
|
failure_count INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(endpoint)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Indexes are created after migrations so that legacy databases have all
|
# Indexes are created after migrations so that legacy databases have all
|
||||||
|
|||||||
+127
-36
@@ -11,6 +11,28 @@ from app.path_utils import split_path_hex
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
# Variables available for user format strings
|
||||||
|
FORMAT_VARIABLES = (
|
||||||
|
"type",
|
||||||
|
"text",
|
||||||
|
"sender_name",
|
||||||
|
"sender_key",
|
||||||
|
"channel_name",
|
||||||
|
"conversation_key",
|
||||||
|
"hops",
|
||||||
|
"hops_backticked",
|
||||||
|
"hop_count",
|
||||||
|
"rssi",
|
||||||
|
"snr",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_urls(raw: str) -> list[str]:
|
def _parse_urls(raw: str) -> list[str]:
|
||||||
"""Split multi-line URL string into individual URLs."""
|
"""Split multi-line URL string into individual URLs."""
|
||||||
@@ -36,41 +58,91 @@ def _normalize_discord_url(url: str) -> str:
|
|||||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
def _compute_hops(data: dict) -> tuple[str, str, int]:
|
||||||
"""Build a human-readable notification body from message data."""
|
"""Extract hop info from message data. Returns (hops, hops_backticked, hop_count)."""
|
||||||
|
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 path_str is None or path_str.strip() == "":
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
path_str = path_str.strip().lower()
|
||||||
|
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||||
|
hops = split_path_hex(path_str, hop_count)
|
||||||
|
if not hops:
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
", ".join(hops),
|
||||||
|
", ".join(f"`{h}`" for h in hops),
|
||||||
|
len(hops),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_vars(data: dict) -> dict[str, str]:
|
||||||
|
"""Build the variable dict for format string substitution."""
|
||||||
|
hops_raw, hops_bt, hop_count = _compute_hops(data)
|
||||||
|
|
||||||
|
paths = data.get("paths")
|
||||||
|
rssi = ""
|
||||||
|
snr = ""
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
rssi_val = first_path.get("rssi")
|
||||||
|
snr_val = first_path.get("snr")
|
||||||
|
if rssi_val is not None:
|
||||||
|
rssi = str(rssi_val)
|
||||||
|
if snr_val is not None:
|
||||||
|
snr = str(snr_val)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": data.get("type", ""),
|
||||||
|
"text": get_fanout_message_text(data),
|
||||||
|
"sender_name": data.get("sender_name") or "Unknown",
|
||||||
|
"sender_key": data.get("sender_key") or "",
|
||||||
|
"channel_name": data.get("channel_name") or data.get("conversation_key", "channel"),
|
||||||
|
"conversation_key": data.get("conversation_key", ""),
|
||||||
|
"hops": hops_raw,
|
||||||
|
"hops_backticked": hops_bt,
|
||||||
|
"hop_count": str(hop_count),
|
||||||
|
"rssi": rssi,
|
||||||
|
"snr": snr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_format(fmt: str, variables: dict[str, str]) -> str:
|
||||||
|
"""Apply template variables in a single pass to avoid re-expanding substituted values."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _replacer(m: re.Match[str]) -> str:
|
||||||
|
key = m.group(1)
|
||||||
|
return variables.get(key, m.group(0))
|
||||||
|
|
||||||
|
return re.sub(r"\{(\w+)\}", _replacer, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_body(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
|
||||||
|
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
|
||||||
|
) -> str:
|
||||||
|
"""Build a notification body from message data using format strings."""
|
||||||
|
variables = _build_template_vars(data)
|
||||||
msg_type = data.get("type", "")
|
msg_type = data.get("type", "")
|
||||||
text = get_fanout_message_text(data)
|
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
|
||||||
sender_name = data.get("sender_name") or "Unknown"
|
try:
|
||||||
|
return _apply_format(fmt, variables)
|
||||||
via = ""
|
except Exception:
|
||||||
if include_path:
|
logger.warning("Apprise format string error, falling back to default")
|
||||||
paths = data.get("paths")
|
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
return _apply_format(default, variables)
|
||||||
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:
|
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||||
@@ -106,8 +178,27 @@ class AppriseModule(FanoutModule):
|
|||||||
return
|
return
|
||||||
|
|
||||||
preserve_identity = self.config.get("preserve_identity", True)
|
preserve_identity = self.config.get("preserve_identity", True)
|
||||||
include_path = self.config.get("include_path", True)
|
|
||||||
body = _format_body(data, include_path=include_path)
|
# Read format strings; treat empty/whitespace as unset (use default).
|
||||||
|
# Fall back to legacy include_path for pre-migration configs.
|
||||||
|
body_format_dm = (self.config.get("body_format_dm") or "").strip() or None
|
||||||
|
body_format_channel = (self.config.get("body_format_channel") or "").strip() or None
|
||||||
|
if body_format_dm is None or body_format_channel is None:
|
||||||
|
include_path = self.config.get("include_path", True)
|
||||||
|
if body_format_dm is None:
|
||||||
|
body_format_dm = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
if body_format_channel is None:
|
||||||
|
body_format_channel = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
|
if include_path
|
||||||
|
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = await asyncio.to_thread(
|
success = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
|||||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||||
|
|
||||||
device_info = await self._fetch_device_info()
|
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
|
||||||
|
# radio_lifecycle) over the per-module _cached_device_info, which was only
|
||||||
|
# cleared on module restart and therefore served stale firmware versions after
|
||||||
|
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
|
||||||
|
# where device_info_loaded is False.
|
||||||
|
if radio_manager.device_info_loaded:
|
||||||
|
raw_ver = radio_manager.firmware_version or "unknown"
|
||||||
|
fw_build = radio_manager.firmware_build or ""
|
||||||
|
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
|
||||||
|
device_info = {
|
||||||
|
"model": radio_manager.device_model or "unknown",
|
||||||
|
"firmware_version": fw_str,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
device_info = await self._fetch_device_info()
|
||||||
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
||||||
|
|
||||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||||
|
|||||||
+25
-9
@@ -115,6 +115,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
|||||||
return f"lpp_{type_name}_ch{channel}"
|
return f"lpp_{type_name}_ch{channel}"
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]:
|
||||||
|
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
|
||||||
|
|
||||||
|
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
|
||||||
|
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
result: list[tuple[dict, str, int]] = []
|
||||||
|
for sensor in lpp_sensors:
|
||||||
|
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||||
|
n = counts.get(base, 0) + 1
|
||||||
|
counts[base] = n
|
||||||
|
result.append((sensor, base if n == 1 else f"{base}_{n}", n))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
@@ -123,8 +139,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if field is not None:
|
if field is not None:
|
||||||
payload[field] = data.get(field)
|
payload[field] = data.get(field)
|
||||||
|
|
||||||
for sensor in data.get("lpp_sensors", []) or []:
|
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
|
||||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
|
||||||
payload[key] = sensor.get("value")
|
payload[key] = sensor.get("value")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
@@ -139,16 +154,19 @@ def _lpp_discovery_configs(
|
|||||||
) -> list[tuple[str, dict]]:
|
) -> list[tuple[str, dict]]:
|
||||||
"""Build HA discovery configs for a repeater's LPP sensors."""
|
"""Build HA discovery configs for a repeater's LPP sensors."""
|
||||||
configs: list[tuple[str, dict]] = []
|
configs: list[tuple[str, dict]] = []
|
||||||
for sensor in lpp_sensors:
|
for sensor, field, occurrence in _assign_lpp_keys(lpp_sensors):
|
||||||
type_name = sensor.get("type_name", "unknown")
|
type_name = sensor.get("type_name", "unknown")
|
||||||
channel = sensor.get("channel", 0)
|
channel = sensor.get("channel", 0)
|
||||||
field = _lpp_sensor_key(type_name, channel)
|
|
||||||
meta = _LPP_HA_META.get(type_name, {})
|
meta = _LPP_HA_META.get(type_name, {})
|
||||||
|
|
||||||
nid = _node_id(pub_key)
|
nid = _node_id(pub_key)
|
||||||
object_id = field
|
object_id = field
|
||||||
display = type_name.replace("_", " ").title()
|
display = type_name.replace("_", " ").title()
|
||||||
name = f"{display} (Ch {channel})"
|
name = (
|
||||||
|
f"{display} (Ch {channel})"
|
||||||
|
if occurrence == 1
|
||||||
|
else f"{display} (Ch {channel}) #{occurrence}"
|
||||||
|
)
|
||||||
|
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -450,7 +468,7 @@ def _message_event_discovery_config(
|
|||||||
device = _device_payload(radio_key, radio_name, "Radio")
|
device = _device_payload(radio_key, radio_name, "Radio")
|
||||||
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
"name": "MeshCore Messages",
|
"name": "Messages",
|
||||||
"unique_id": f"meshcore_{nid}_messages",
|
"unique_id": f"meshcore_{nid}_messages",
|
||||||
"device": device,
|
"device": device,
|
||||||
"state_topic": f"{prefix}/{nid}/events/message",
|
"state_topic": f"{prefix}/{nid}/events/message",
|
||||||
@@ -731,9 +749,7 @@ class MqttHaModule(FanoutModule):
|
|||||||
payload = _repeater_telemetry_payload(data)
|
payload = _repeater_telemetry_payload(data)
|
||||||
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
||||||
rediscover = False
|
rediscover = False
|
||||||
for sensor in lpp_sensors:
|
for _, key, _ in _assign_lpp_keys(lpp_sensors):
|
||||||
# Check if discovery for this sensor has been published yet
|
|
||||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
|
||||||
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
||||||
if expected_topic not in self._discovery_topics:
|
if expected_topic not in self._discovery_topics:
|
||||||
rediscover = True
|
rediscover = True
|
||||||
|
|||||||
+41
-9
@@ -2,13 +2,14 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Windows event-loop advisory for MQTT fanout
|
# Windows event-loop advisory for MQTT fanout and TCP proxy
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
|
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
|
||||||
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
|
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) and
|
||||||
# We cannot fix this from inside the app — the loop is already created by the
|
# asyncio.start_server (TCP proxy) require. The loop is already created by
|
||||||
# time this module is imported. Log a prominent warning so Windows operators
|
# the time this module is imported, so we cannot switch it here. Log a
|
||||||
# who want MQTT know to add ``--loop none`` to their uvicorn command.
|
# prominent warning so Windows operators know to start uvicorn with the
|
||||||
|
# selector loop policy set before import.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
@@ -21,12 +22,15 @@ if sys.platform == "win32":
|
|||||||
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
|
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
|
||||||
"\n"
|
"\n"
|
||||||
" The running event loop is ProactorEventLoop, which is not\n"
|
" The running event loop is ProactorEventLoop, which is not\n"
|
||||||
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
|
" compatible with MQTT fanout or the TCP proxy.\n"
|
||||||
"\n"
|
"\n"
|
||||||
" If you use MQTT integrations, restart with --loop none:\n"
|
" If you use either feature, restart with:\n"
|
||||||
"\n"
|
"\n"
|
||||||
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
|
' python -c "import asyncio; asyncio.set_event_loop_policy('
|
||||||
" [... other options ...]\n"
|
'asyncio.WindowsSelectorEventLoopPolicy())" & '
|
||||||
|
"uv run uvicorn app.main:app [... options ...]\n"
|
||||||
|
"\n"
|
||||||
|
" Or add --loop asyncio to the uvicorn command.\n"
|
||||||
"\n"
|
"\n"
|
||||||
" Everything else works fine as-is.\n"
|
" Everything else works fine as-is.\n"
|
||||||
"\n" + "!" * 78 + "\n",
|
"\n" + "!" * 78 + "\n",
|
||||||
@@ -130,12 +134,21 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to start fanout modules")
|
logger.exception("Failed to start fanout modules")
|
||||||
|
|
||||||
|
if server_settings.tcp_proxy_enabled:
|
||||||
|
from app.tcp_proxy import start_tcp_proxy
|
||||||
|
|
||||||
|
await start_tcp_proxy()
|
||||||
|
|
||||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||||
app.state.startup_radio_task = startup_radio_task
|
app.state.startup_radio_task = startup_radio_task
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
logger.info("Shutting down")
|
logger.info("Shutting down")
|
||||||
|
if server_settings.tcp_proxy_enabled:
|
||||||
|
from app.tcp_proxy import stop_tcp_proxy
|
||||||
|
|
||||||
|
await stop_tcp_proxy()
|
||||||
if startup_radio_task and not startup_radio_task.done():
|
if startup_radio_task and not startup_radio_task.done():
|
||||||
startup_radio_task.cancel()
|
startup_radio_task.cancel()
|
||||||
try:
|
try:
|
||||||
@@ -180,6 +193,25 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
|||||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_server_errors(request: Request, call_next):
|
||||||
|
"""Capture 5xx errors and unhandled exceptions into the log ring buffer.
|
||||||
|
|
||||||
|
Starlette writes unhandled-exception tracebacks to stderr, bypassing
|
||||||
|
Python logging, so they never reach the debug dump. This middleware
|
||||||
|
catches them and logs via ``logger.exception()`` so the full traceback
|
||||||
|
is preserved in the ring buffer for the ``GET /api/debug`` snapshot.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||||
|
raise
|
||||||
|
if response.status_code >= 500:
|
||||||
|
logger.error("HTTP %d on %s %s", response.status_code, request.method, request.url.path)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# API routes - all prefixed with /api for production compatibility
|
# API routes - all prefixed with /api for production compatibility
|
||||||
app.include_router(health.router, prefix="/api")
|
app.include_router(health.router, prefix="/api")
|
||||||
app.include_router(debug.router, prefix="/api")
|
app.include_router(debug.router, prefix="/api")
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add muted column to channels table."""
|
||||||
|
table_check = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
|
||||||
|
)
|
||||||
|
if not await table_check.fetchone():
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(channels)")
|
||||||
|
columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
|
||||||
|
if "muted" not in columns:
|
||||||
|
await conn.execute("ALTER TABLE channels ADD COLUMN muted INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Migrate apprise fanout configs from include_path boolean to format strings."""
|
||||||
|
table_check = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
|
||||||
|
)
|
||||||
|
if not await table_check.fetchone():
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("SELECT id, config FROM fanout_configs WHERE type = 'apprise'")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
config_id = row["id"] if isinstance(row, dict) else row[0]
|
||||||
|
config_raw = row["config"] if isinstance(row, dict) else row[1]
|
||||||
|
try:
|
||||||
|
config = json.loads(config_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if already migrated
|
||||||
|
if "body_format_dm" in config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
include_path = config.get("include_path", True)
|
||||||
|
config["body_format_dm"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
config["body_format_channel"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL if include_path else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
config.pop("include_path", None)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE fanout_configs SET config = ? WHERE id = ?",
|
||||||
|
(json.dumps(config), config_id),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Migrated apprise config %s: include_path=%s -> format strings", config_id, include_path
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
@@ -221,6 +221,9 @@ class CreateContactRequest(BaseModel):
|
|||||||
|
|
||||||
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
||||||
name: str | None = Field(default=None, description="Display name for the contact")
|
name: str | None = Field(default=None, description="Display name for the contact")
|
||||||
|
type: int = Field(
|
||||||
|
default=0, ge=0, le=3, description="Contact type (0=unknown, 1=client, 2=repeater, 3=room)"
|
||||||
|
)
|
||||||
try_historical: bool = Field(
|
try_historical: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Attempt to decrypt historical DM packets for this contact",
|
description="Attempt to decrypt historical DM packets for this contact",
|
||||||
@@ -346,6 +349,7 @@ class Channel(BaseModel):
|
|||||||
)
|
)
|
||||||
last_read_at: int | None = None # Server-side read state tracking
|
last_read_at: int | None = None # Server-side read state tracking
|
||||||
favorite: bool = False
|
favorite: bool = False
|
||||||
|
muted: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ChannelMessageCounts(BaseModel):
|
class ChannelMessageCounts(BaseModel):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pywebpush import WebPushException
|
|||||||
|
|
||||||
from app.push.send import send_push
|
from app.push.send import send_push
|
||||||
from app.push.vapid import get_vapid_private_key
|
from app.push.vapid import get_vapid_private_key
|
||||||
|
from app.repository.channels import ChannelRepository
|
||||||
from app.repository.push_subscriptions import PushSubscriptionRepository
|
from app.repository.push_subscriptions import PushSubscriptionRepository
|
||||||
from app.repository.settings import AppSettingsRepository
|
from app.repository.settings import AppSettingsRepository
|
||||||
|
|
||||||
@@ -102,6 +103,15 @@ class PushManager:
|
|||||||
if state_key not in push_conversations:
|
if state_key not in push_conversations:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Skip muted channels
|
||||||
|
if data.get("type") == "CHAN" and data.get("conversation_key"):
|
||||||
|
try:
|
||||||
|
ch = await ChannelRepository.get_by_key(data["conversation_key"])
|
||||||
|
if ch and ch.muted:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Push dispatch: failed to check channel mute state", exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subs = await PushSubscriptionRepository.get_all()
|
subs = await PushSubscriptionRepository.get_all()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+300
-74
@@ -43,9 +43,41 @@ from app.websocket import broadcast_error, broadcast_event
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_MAX_CHANNELS = 40
|
DEFAULT_MAX_CHANNELS = 40
|
||||||
|
_GET_CONTACTS_TIMEOUT = 10
|
||||||
|
|
||||||
AdvertMode = Literal["flood", "zero_hop"]
|
AdvertMode = Literal["flood", "zero_hop"]
|
||||||
|
|
||||||
|
_AUTO_ADD_OVERWRITE_OLDEST = 0x01
|
||||||
|
_RADIO_CONTACT_FAVORITE = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
async def _enable_autoevict_on_radio(mc: MeshCore) -> bool:
|
||||||
|
"""Ensure the radio's AUTO_ADD_OVERWRITE_OLDEST preference bit is set."""
|
||||||
|
try:
|
||||||
|
current = await mc.commands.get_autoadd_config()
|
||||||
|
if current is None or current.type == EventType.ERROR:
|
||||||
|
logger.warning("Could not read autoadd config from radio: %s", current)
|
||||||
|
return False
|
||||||
|
current_flags = current.payload.get("config", 0)
|
||||||
|
if current_flags & _AUTO_ADD_OVERWRITE_OLDEST:
|
||||||
|
logger.debug("Radio autoevict already enabled (autoadd_config=0x%02x)", current_flags)
|
||||||
|
return True
|
||||||
|
new_flags = current_flags | _AUTO_ADD_OVERWRITE_OLDEST
|
||||||
|
result = await mc.commands.set_autoadd_config(new_flags)
|
||||||
|
if result is not None and result.type == EventType.OK:
|
||||||
|
logger.info(
|
||||||
|
"Enabled radio autoevict (autoadd_config 0x%02x -> 0x%02x)",
|
||||||
|
current_flags,
|
||||||
|
new_flags,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to enable radio autoevict: %s", result)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error enabling radio autoevict: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||||
"""Return key contact fields for sync failure diagnostics."""
|
"""Return key contact fields for sync failure diagnostics."""
|
||||||
@@ -239,7 +271,7 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
|||||||
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||||
|
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts(timeout=_GET_CONTACTS_TIMEOUT)
|
||||||
if result is None or result.type == EventType.ERROR:
|
if result is None or result.type == EventType.ERROR:
|
||||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||||
return False
|
return False
|
||||||
@@ -430,6 +462,16 @@ async def ensure_default_channels() -> None:
|
|||||||
|
|
||||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||||
"""Run fast startup sync, then background contact reconcile."""
|
"""Run fast startup sync, then background contact reconcile."""
|
||||||
|
autoevict_requested = settings.load_with_autoevict
|
||||||
|
autoevict = False
|
||||||
|
|
||||||
|
if autoevict_requested:
|
||||||
|
autoevict = await _enable_autoevict_on_radio(mc)
|
||||||
|
if not autoevict:
|
||||||
|
logger.warning(
|
||||||
|
"Autoevict requested but unavailable; falling back to snapshot-based "
|
||||||
|
"background contact reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||||
@@ -441,9 +483,25 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
|||||||
# Ensure default channels exist
|
# Ensure default channels exist
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
|
||||||
|
snapshot_failed = "error" in contacts_result
|
||||||
|
if snapshot_failed and not autoevict:
|
||||||
|
logger.warning(
|
||||||
|
"Radio contact snapshot failed — attempting best-effort contact "
|
||||||
|
"loading without a full picture of what's already on the radio"
|
||||||
|
)
|
||||||
|
broadcast_error(
|
||||||
|
"Could not enumerate radio contacts",
|
||||||
|
"Loading favorites and recent contacts on a best-effort basis — "
|
||||||
|
"some adds may be redundant or fail if the radio's contact table "
|
||||||
|
"is already full. Set MESHCORE_LOAD_WITH_AUTOEVICT=true for more "
|
||||||
|
"reliable loading without needing to read the radio first. "
|
||||||
|
"See 'Contact Loading Issues' in the Advanced Setup documentation.",
|
||||||
|
)
|
||||||
|
|
||||||
start_background_contact_reconciliation(
|
start_background_contact_reconciliation(
|
||||||
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||||
expected_mc=mc,
|
expected_mc=mc,
|
||||||
|
autoevict=autoevict,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1045,7 +1103,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
|||||||
synced = 0
|
synced = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts(timeout=_GET_CONTACTS_TIMEOUT)
|
||||||
|
|
||||||
if result is None or result.type == EventType.ERROR:
|
if result is None or result.type == EventType.ERROR:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1108,12 +1166,24 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
*,
|
*,
|
||||||
initial_radio_contacts: dict[str, dict],
|
initial_radio_contacts: dict[str, dict],
|
||||||
expected_mc: MeshCore,
|
expected_mc: MeshCore,
|
||||||
|
autoevict: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Converge radio contacts toward the desired favorites+recents working set."""
|
"""Converge radio contacts toward the desired favorites+recents working set.
|
||||||
|
|
||||||
|
When *autoevict* is ``True`` the removal phase is skipped entirely and the
|
||||||
|
desired working set is blind-refreshed. Re-adding the full desired list
|
||||||
|
refreshes each contact's recency on supported firmware, so one successful
|
||||||
|
full pass converges the radio toward the desired working set without relying
|
||||||
|
on a stale contact snapshot.
|
||||||
|
"""
|
||||||
radio_contacts = dict(initial_radio_contacts)
|
radio_contacts = dict(initial_radio_contacts)
|
||||||
removed = 0
|
removed = 0
|
||||||
loaded = 0
|
loaded = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
table_full = False
|
||||||
|
autoevict_next_index = 0
|
||||||
|
autoevict_full_pass_retries = 0
|
||||||
|
_MAX_AUTOEVICT_RETRIES = 3
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -1121,18 +1191,32 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
logger.info("Stopping background contact reconcile: radio transport changed")
|
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Pre-lock snapshot for quick-exit checks; authoritative list is
|
||||||
|
# re-fetched inside the radio lock below.
|
||||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_fill_contacts = [
|
||||||
|
contact for contact in selected_contacts if len(contact.public_key) >= 64
|
||||||
|
]
|
||||||
|
|
||||||
|
if autoevict:
|
||||||
|
if not desired_fill_contacts:
|
||||||
|
logger.info(
|
||||||
|
"Background contact blind fill complete: no desired contacts selected"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if autoevict_next_index >= len(desired_fill_contacts):
|
||||||
|
autoevict_next_index = 0
|
||||||
desired_contacts = {
|
desired_contacts = {
|
||||||
contact.public_key.lower(): contact
|
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||||
for contact in selected_contacts
|
|
||||||
if len(contact.public_key) >= 64
|
|
||||||
}
|
}
|
||||||
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
removable_keys = (
|
||||||
|
[] if autoevict else [key for key in radio_contacts if key not in desired_contacts]
|
||||||
|
)
|
||||||
missing_contacts = [
|
missing_contacts = [
|
||||||
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||||
]
|
]
|
||||||
|
|
||||||
if not removable_keys and not missing_contacts:
|
if not autoevict and not removable_keys and not missing_contacts:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Background contact reconcile complete: %d contacts on radio working set",
|
"Background contact reconcile complete: %d contacts on radio working set",
|
||||||
len(radio_contacts),
|
len(radio_contacts),
|
||||||
@@ -1140,6 +1224,8 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
break
|
break
|
||||||
|
|
||||||
progressed = False
|
progressed = False
|
||||||
|
autoevict_pass_complete = False
|
||||||
|
autoevict_pass_failed = False
|
||||||
try:
|
try:
|
||||||
async with radio_manager.radio_operation(
|
async with radio_manager.radio_operation(
|
||||||
"background_contact_reconcile",
|
"background_contact_reconcile",
|
||||||
@@ -1153,100 +1239,237 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
|
|
||||||
budget = CONTACT_RECONCILE_BATCH_SIZE
|
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_fill_contacts = [
|
||||||
|
contact for contact in selected_contacts if len(contact.public_key) >= 64
|
||||||
|
]
|
||||||
|
if autoevict and autoevict_next_index >= len(desired_fill_contacts):
|
||||||
|
autoevict_next_index = 0
|
||||||
desired_contacts = {
|
desired_contacts = {
|
||||||
contact.public_key.lower(): contact
|
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||||
for contact in selected_contacts
|
|
||||||
if len(contact.public_key) >= 64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for public_key in list(radio_contacts):
|
if not autoevict:
|
||||||
if budget <= 0:
|
for public_key in list(radio_contacts):
|
||||||
break
|
|
||||||
if public_key in desired_contacts:
|
|
||||||
continue
|
|
||||||
|
|
||||||
remove_payload = (
|
|
||||||
mc.get_contact_by_key_prefix(public_key[:12])
|
|
||||||
or radio_contacts.get(public_key)
|
|
||||||
or {"public_key": public_key}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
|
||||||
except Exception as exc:
|
|
||||||
failed += 1
|
|
||||||
budget -= 1
|
|
||||||
logger.warning(
|
|
||||||
"Error removing contact %s during background reconcile: %s",
|
|
||||||
public_key[:12],
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
budget -= 1
|
|
||||||
if remove_result.type == EventType.OK:
|
|
||||||
radio_contacts.pop(public_key, None)
|
|
||||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
|
||||||
removed += 1
|
|
||||||
progressed = True
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
logger.warning(
|
|
||||||
"Failed to remove contact %s during background reconcile: %s",
|
|
||||||
public_key[:12],
|
|
||||||
remove_result.payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
if budget > 0:
|
|
||||||
for public_key, contact in desired_contacts.items():
|
|
||||||
if budget <= 0:
|
if budget <= 0:
|
||||||
break
|
break
|
||||||
if public_key in radio_contacts:
|
if public_key in desired_contacts:
|
||||||
continue
|
|
||||||
|
|
||||||
if mc.get_contact_by_key_prefix(public_key[:12]):
|
|
||||||
radio_contacts[public_key] = {"public_key": public_key}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
remove_payload = (
|
||||||
|
mc.get_contact_by_key_prefix(public_key[:12])
|
||||||
|
or radio_contacts.get(public_key)
|
||||||
|
or {"public_key": public_key}
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
add_payload = contact.to_radio_dict()
|
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||||
add_result = await mc.commands.add_contact(add_payload)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
budget -= 1
|
budget -= 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Error adding contact %s during background reconcile: %s",
|
"Error removing contact %s during background reconcile: %s",
|
||||||
public_key[:12],
|
public_key[:12],
|
||||||
exc,
|
exc,
|
||||||
exc_info=True,
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
budget -= 1
|
budget -= 1
|
||||||
if add_result.type == EventType.OK:
|
not_found = (
|
||||||
radio_contacts[public_key] = add_payload
|
remove_result.type != EventType.OK
|
||||||
loaded += 1
|
and isinstance(remove_result.payload, dict)
|
||||||
|
and remove_result.payload.get("error_code") == 2
|
||||||
|
)
|
||||||
|
if remove_result.type == EventType.OK or not_found:
|
||||||
|
radio_contacts.pop(public_key, None)
|
||||||
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
|
removed += 1
|
||||||
progressed = True
|
progressed = True
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
reason = add_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(
|
logger.warning(
|
||||||
"Failed to add contact %s during background reconcile: %s%s",
|
"Failed to remove contact %s during background reconcile: %s",
|
||||||
public_key[:12],
|
public_key[:12],
|
||||||
reason,
|
remove_result.payload,
|
||||||
hint,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if budget > 0:
|
||||||
|
if autoevict:
|
||||||
|
# Budget is consumed by the slice bound rather than
|
||||||
|
# per-operation decrement — autoevict skips the
|
||||||
|
# removal phase so the full budget is always available.
|
||||||
|
batch_contacts = desired_fill_contacts[
|
||||||
|
autoevict_next_index : autoevict_next_index + budget
|
||||||
|
]
|
||||||
|
processed_contacts = 0
|
||||||
|
for contact in batch_contacts:
|
||||||
|
public_key = contact.public_key.lower()
|
||||||
|
try:
|
||||||
|
add_payload = contact.to_radio_dict()
|
||||||
|
# In autoevict mode, app-loaded contacts should
|
||||||
|
# remain evictable by the radio even if the
|
||||||
|
# stored contact record carries the favorite bit.
|
||||||
|
add_payload["flags"] = (
|
||||||
|
int(add_payload.get("flags", 0)) & ~_RADIO_CONTACT_FAVORITE
|
||||||
|
)
|
||||||
|
add_result = await mc.commands.add_contact(add_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"Error blind-filling contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
autoevict_pass_failed = True
|
||||||
|
processed_contacts += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if add_result.type == EventType.OK:
|
||||||
|
radio_contacts[public_key] = add_payload
|
||||||
|
loaded += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
autoevict_pass_failed = True
|
||||||
|
reason = add_result.payload
|
||||||
|
if isinstance(reason, dict) and reason.get("error_code") == 3:
|
||||||
|
logger.warning(
|
||||||
|
"Radio contact table full — stopping "
|
||||||
|
"contact reconcile (loaded %d this cycle)",
|
||||||
|
loaded,
|
||||||
|
)
|
||||||
|
table_full = True
|
||||||
|
break
|
||||||
|
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 blind-fill contact %s during background reconcile: %s%s",
|
||||||
|
public_key[:12],
|
||||||
|
reason,
|
||||||
|
hint,
|
||||||
|
)
|
||||||
|
processed_contacts += 1
|
||||||
|
|
||||||
|
autoevict_next_index += processed_contacts
|
||||||
|
autoevict_pass_complete = autoevict_next_index >= len(
|
||||||
|
desired_fill_contacts
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for public_key, contact in desired_contacts.items():
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
if public_key in radio_contacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||||
|
radio_contacts[public_key] = {"public_key": public_key}
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_payload = contact.to_radio_dict()
|
||||||
|
add_result = await mc.commands.add_contact(add_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
budget -= 1
|
||||||
|
logger.warning(
|
||||||
|
"Error adding contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget -= 1
|
||||||
|
if add_result.type == EventType.OK:
|
||||||
|
radio_contacts[public_key] = add_payload
|
||||||
|
loaded += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
reason = add_result.payload
|
||||||
|
if isinstance(reason, dict) and reason.get("error_code") == 3:
|
||||||
|
logger.warning(
|
||||||
|
"Radio contact table full — stopping "
|
||||||
|
"contact reconcile (loaded %d this cycle)",
|
||||||
|
loaded,
|
||||||
|
)
|
||||||
|
table_full = True
|
||||||
|
break
|
||||||
|
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 add contact %s during background reconcile: %s%s",
|
||||||
|
public_key[:12],
|
||||||
|
reason,
|
||||||
|
hint,
|
||||||
|
)
|
||||||
except RadioOperationBusyError:
|
except RadioOperationBusyError:
|
||||||
logger.debug("Background contact reconcile yielding: radio busy")
|
logger.debug("Background contact reconcile yielding: radio busy")
|
||||||
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if table_full:
|
||||||
|
if autoevict:
|
||||||
|
logger.error(
|
||||||
|
"We're expecting the radio to be in AUTO_ADD_OVERWRITE_OLDEST mode, "
|
||||||
|
"so a full-table error means we have no idea what is going on with "
|
||||||
|
"this radio; it is misbehaving. You should consider DM auto-acking "
|
||||||
|
"to be unreliable and/or not working for this radio. Sending and "
|
||||||
|
"receiving messages are not impacted by this error unless other "
|
||||||
|
"things are broken on your radio."
|
||||||
|
)
|
||||||
|
broadcast_error(
|
||||||
|
"Could not load all desired contacts onto the radio for auto-DM ack",
|
||||||
|
"Despite having auto-evict enabled, we got a contact-table-full error "
|
||||||
|
"from your radio. DM auto-ack is likely unavailable.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normal_table_full_message = (
|
||||||
|
"The radio's contact table is full. Clearing your radio contacts "
|
||||||
|
"using another client, lowering your contact fill target in "
|
||||||
|
"settings, or setting MESHCORE_LOAD_WITH_AUTOEVICT=true may "
|
||||||
|
"relieve this. See 'Contact Loading Issues' in the Advanced "
|
||||||
|
"README.md"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"Contact reconcile hit TABLE_FULL. %s",
|
||||||
|
normal_table_full_message,
|
||||||
|
)
|
||||||
|
broadcast_error(
|
||||||
|
"Could not load all desired contacts onto the radio for auto-DM ack",
|
||||||
|
normal_table_full_message,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if autoevict and autoevict_pass_complete:
|
||||||
|
if autoevict_pass_failed:
|
||||||
|
autoevict_full_pass_retries += 1
|
||||||
|
if autoevict_full_pass_retries >= _MAX_AUTOEVICT_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
"Background contact blind fill giving up after %d full passes "
|
||||||
|
"with persistent failures (loaded %d, failed %d)",
|
||||||
|
autoevict_full_pass_retries,
|
||||||
|
loaded,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
autoevict_next_index = 0
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Background contact blind fill complete: refreshed %d desired contacts",
|
||||||
|
len(desired_fill_contacts),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||||
if not progressed:
|
if not progressed:
|
||||||
continue
|
continue
|
||||||
@@ -1269,6 +1492,7 @@ def start_background_contact_reconciliation(
|
|||||||
*,
|
*,
|
||||||
initial_radio_contacts: dict[str, dict],
|
initial_radio_contacts: dict[str, dict],
|
||||||
expected_mc: MeshCore,
|
expected_mc: MeshCore,
|
||||||
|
autoevict: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start or replace the background contact reconcile task for the current radio."""
|
"""Start or replace the background contact reconcile task for the current radio."""
|
||||||
global _contact_reconcile_task
|
global _contact_reconcile_task
|
||||||
@@ -1280,11 +1504,13 @@ def start_background_contact_reconciliation(
|
|||||||
_reconcile_radio_contacts_in_background(
|
_reconcile_radio_contacts_in_background(
|
||||||
initial_radio_contacts=initial_radio_contacts,
|
initial_radio_contacts=initial_radio_contacts,
|
||||||
expected_mc=expected_mc,
|
expected_mc=expected_mc,
|
||||||
|
autoevict=autoevict,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started background contact reconcile for %d radio contact(s)",
|
"Started background contact reconcile for %d radio contact(s)%s",
|
||||||
len(initial_radio_contacts),
|
len(initial_radio_contacts),
|
||||||
|
" (autoevict mode)" if autoevict else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ChannelRepository:
|
|||||||
async with db.readonly() as conn:
|
async with db.readonly() as conn:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
|
||||||
FROM channels
|
FROM channels
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
""",
|
""",
|
||||||
@@ -45,6 +45,7 @@ class ChannelRepository:
|
|||||||
path_hash_mode_override=row["path_hash_mode_override"],
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
favorite=bool(row["favorite"]),
|
favorite=bool(row["favorite"]),
|
||||||
|
muted=bool(row["muted"]),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class ChannelRepository:
|
|||||||
async with db.readonly() as conn:
|
async with db.readonly() as conn:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
|
||||||
FROM channels
|
FROM channels
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
"""
|
||||||
@@ -69,6 +70,7 @@ class ChannelRepository:
|
|||||||
path_hash_mode_override=row["path_hash_mode_override"],
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
favorite=bool(row["favorite"]),
|
favorite=bool(row["favorite"]),
|
||||||
|
muted=bool(row["muted"]),
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -84,6 +86,17 @@ class ChannelRepository:
|
|||||||
rowcount = cursor.rowcount
|
rowcount = cursor.rowcount
|
||||||
return rowcount > 0
|
return rowcount > 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_muted(key: str, value: bool) -> bool:
|
||||||
|
"""Set or clear the muted flag for a channel. Returns True if row was found."""
|
||||||
|
async with db.tx() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"UPDATE channels SET muted = ? WHERE key = ?",
|
||||||
|
(1 if value else 0, key.upper()),
|
||||||
|
) as cursor:
|
||||||
|
rowcount = cursor.rowcount
|
||||||
|
return rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(key: str) -> None:
|
async def delete(key: str) -> None:
|
||||||
"""Delete a channel by key."""
|
"""Delete a channel by key."""
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ class MessageRepository:
|
|||||||
JOIN channels c ON m.conversation_key = c.key
|
JOIN channels c ON m.conversation_key = c.key
|
||||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||||
|
AND COALESCE(c.muted, 0) = 0
|
||||||
{blocked_sql}
|
{blocked_sql}
|
||||||
GROUP BY m.conversation_key
|
GROUP BY m.conversation_key
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ async def create_contact(
|
|||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
|
type=request.type,
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
|
|||||||
+34
-5
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
|
|||||||
path_hash_mode_supported: bool
|
path_hash_mode_supported: bool
|
||||||
channel_slot_reuse_enabled: bool
|
channel_slot_reuse_enabled: bool
|
||||||
channel_send_cache_capacity: int
|
channel_send_cache_capacity: int
|
||||||
remediation_flags: dict[str, bool]
|
|
||||||
|
|
||||||
|
|
||||||
class DebugContactAudit(BaseModel):
|
class DebugContactAudit(BaseModel):
|
||||||
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
|
|||||||
basic_auth_enabled: bool = False
|
basic_auth_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DebugEnvironment(BaseModel):
|
||||||
|
connection_type: str
|
||||||
|
serial_port: str
|
||||||
|
serial_baudrate: int
|
||||||
|
tcp_host: str
|
||||||
|
tcp_port: int
|
||||||
|
ble_address: str
|
||||||
|
log_level: str
|
||||||
|
database_path: str
|
||||||
|
disable_bots: bool
|
||||||
|
enable_message_poll_fallback: bool
|
||||||
|
force_channel_slot_reconfigure: bool
|
||||||
|
load_with_autoevict: bool
|
||||||
|
|
||||||
|
|
||||||
class DebugAppSettings(BaseModel):
|
class DebugAppSettings(BaseModel):
|
||||||
max_radio_contacts: int
|
max_radio_contacts: int
|
||||||
auto_decrypt_dm_on_advert: bool
|
auto_decrypt_dm_on_advert: bool
|
||||||
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
|
|||||||
captured_at: str
|
captured_at: str
|
||||||
system: DebugSystemInfo
|
system: DebugSystemInfo
|
||||||
application: DebugApplicationInfo
|
application: DebugApplicationInfo
|
||||||
|
environment: DebugEnvironment
|
||||||
health: DebugHealthSummary
|
health: DebugHealthSummary
|
||||||
settings: DebugAppSettings
|
settings: DebugAppSettings
|
||||||
runtime: DebugRuntimeInfo
|
runtime: DebugRuntimeInfo
|
||||||
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_environment() -> DebugEnvironment:
|
||||||
|
return DebugEnvironment(
|
||||||
|
connection_type=settings.connection_type,
|
||||||
|
serial_port=settings.serial_port,
|
||||||
|
serial_baudrate=settings.serial_baudrate,
|
||||||
|
tcp_host=settings.tcp_host,
|
||||||
|
tcp_port=settings.tcp_port,
|
||||||
|
ble_address=settings.ble_address,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
database_path=settings.database_path,
|
||||||
|
disable_bots=settings.disable_bots,
|
||||||
|
enable_message_poll_fallback=settings.enable_message_poll_fallback,
|
||||||
|
force_channel_slot_reconfigure=settings.force_channel_slot_reconfigure,
|
||||||
|
load_with_autoevict=settings.load_with_autoevict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||||
return DebugAppSettings(
|
return DebugAppSettings(
|
||||||
max_radio_contacts=app_settings.max_radio_contacts,
|
max_radio_contacts=app_settings.max_radio_contacts,
|
||||||
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
captured_at=datetime.now(UTC).isoformat(),
|
captured_at=datetime.now(UTC).isoformat(),
|
||||||
system=_build_system_info(),
|
system=_build_system_info(),
|
||||||
application=_build_application_info(),
|
application=_build_application_info(),
|
||||||
|
environment=_build_environment(),
|
||||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||||
settings=_build_debug_app_settings(app_settings),
|
settings=_build_debug_app_settings(app_settings),
|
||||||
runtime=DebugRuntimeInfo(
|
runtime=DebugRuntimeInfo(
|
||||||
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||||
remediation_flags={
|
|
||||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
|
||||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
database=DebugDatabaseInfo(
|
database=DebugDatabaseInfo(
|
||||||
total_dms=message_totals["total_dms"],
|
total_dms=message_totals["total_dms"],
|
||||||
|
|||||||
@@ -259,6 +259,21 @@ def _validate_apprise_config(config: dict) -> None:
|
|||||||
if not urls or not urls.strip():
|
if not urls or not urls.strip():
|
||||||
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import FORMAT_VARIABLES, _apply_format
|
||||||
|
|
||||||
|
dummy_vars: dict[str, str] = dict.fromkeys(FORMAT_VARIABLES, "test")
|
||||||
|
for field in ("body_format_dm", "body_format_channel"):
|
||||||
|
value = config.get(field)
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{field} must be a string")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
_apply_format(value, dummy_vars)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid format string in {field}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def _validate_webhook_config(config: dict) -> None:
|
def _validate_webhook_config(config: dict) -> None:
|
||||||
"""Validate webhook config blob."""
|
"""Validate webhook config blob."""
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
|
|||||||
# Core stats
|
# Core stats
|
||||||
battery_mv: int | None = None
|
battery_mv: int | None = None
|
||||||
uptime_secs: int | None = None
|
uptime_secs: int | None = None
|
||||||
|
queue_len: int | None = None
|
||||||
|
errors: int | None = None
|
||||||
# Radio stats
|
# Radio stats
|
||||||
noise_floor: int | None = None
|
noise_floor: int | None = None
|
||||||
last_rssi: int | None = None
|
last_rssi: int | None = None
|
||||||
@@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
|||||||
"timestamp": raw_stats.get("timestamp"),
|
"timestamp": raw_stats.get("timestamp"),
|
||||||
"battery_mv": raw_stats.get("battery_mv"),
|
"battery_mv": raw_stats.get("battery_mv"),
|
||||||
"uptime_secs": raw_stats.get("uptime_secs"),
|
"uptime_secs": raw_stats.get("uptime_secs"),
|
||||||
|
"queue_len": raw_stats.get("queue_len"),
|
||||||
|
"errors": raw_stats.get("errors"),
|
||||||
"noise_floor": raw_stats.get("noise_floor"),
|
"noise_floor": raw_stats.get("noise_floor"),
|
||||||
"last_rssi": raw_stats.get("last_rssi"),
|
"last_rssi": raw_stats.get("last_rssi"),
|
||||||
"last_snr": raw_stats.get("last_snr"),
|
"last_snr": raw_stats.get("last_snr"),
|
||||||
|
|||||||
@@ -385,6 +385,30 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
|||||||
return await get_radio_config()
|
return await get_radio_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/private-key")
|
||||||
|
async def get_private_key() -> dict:
|
||||||
|
"""Return the in-memory private key (exported from radio on startup).
|
||||||
|
|
||||||
|
Gated behind MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
from app.keystore import get_private_key as ks_get
|
||||||
|
|
||||||
|
if not settings.enable_local_private_key_export:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Private key export is disabled (set MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true)",
|
||||||
|
)
|
||||||
|
|
||||||
|
key = ks_get()
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Private key not available (not exported from radio)",
|
||||||
|
)
|
||||||
|
return {"private_key": key.hex()}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/private-key")
|
@router.put("/private-key")
|
||||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||||
"""Set the radio's private key. This is write-only."""
|
"""Set the radio's private key. This is write-only."""
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ class FavoriteToggleResponse(BaseModel):
|
|||||||
favorite: bool
|
favorite: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MuteChannelRequest(BaseModel):
|
||||||
|
key: str = Field(description="Channel key to toggle mute status")
|
||||||
|
|
||||||
|
|
||||||
|
class MuteChannelToggleResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
muted: bool
|
||||||
|
|
||||||
|
|
||||||
class TrackedTelemetryRequest(BaseModel):
|
class TrackedTelemetryRequest(BaseModel):
|
||||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||||
|
|
||||||
@@ -260,6 +269,25 @@ async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
|
|||||||
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/muted-channels/toggle", response_model=MuteChannelToggleResponse)
|
||||||
|
async def toggle_muted_channel(request: MuteChannelRequest) -> MuteChannelToggleResponse:
|
||||||
|
"""Toggle a channel's muted status."""
|
||||||
|
channel = await ChannelRepository.get_by_key(request.key)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
new_value = not channel.muted
|
||||||
|
await ChannelRepository.set_muted(request.key, new_value)
|
||||||
|
logger.info("%s channel mute: %s", "Muted" if new_value else "Unmuted", request.key[:12])
|
||||||
|
|
||||||
|
refreshed = await ChannelRepository.get_by_key(request.key)
|
||||||
|
if refreshed:
|
||||||
|
from app.websocket import broadcast_event
|
||||||
|
|
||||||
|
broadcast_event("channel", refreshed.model_dump())
|
||||||
|
|
||||||
|
return MuteChannelToggleResponse(key=request.key, muted=new_value)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||||
"""Toggle a public key's blocked status."""
|
"""Toggle a public key's blocked status."""
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ async def send_channel_message_with_effective_scope(
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||||
if send_result.type == EventType.ERROR:
|
if send_result.type == EventType.ERROR:
|
||||||
|
logger.error(
|
||||||
|
"Radio returned error during %s for channel %s: %s",
|
||||||
|
action_label,
|
||||||
|
channel.name,
|
||||||
|
send_result.payload,
|
||||||
|
)
|
||||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||||
else:
|
else:
|
||||||
radio_manager.note_channel_slot_used(channel_key)
|
radio_manager.note_channel_slot_used(channel_key)
|
||||||
@@ -856,7 +862,7 @@ async def send_channel_message_to_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never let watchdog setup failure break the send
|
logger.error("Echo watchdog setup failed", exc_info=True)
|
||||||
|
|
||||||
return outgoing_message
|
return outgoing_message
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# TCP Proxy Architecture
|
||||||
|
|
||||||
|
MeshCore companion protocol proxy: emulates a MeshCore radio over TCP,
|
||||||
|
translating the binary companion protocol into in-process RemoteTerm
|
||||||
|
operations. MeshCore clients (mobile apps, meshcore-cli, meshcore-ha)
|
||||||
|
connect to it and interact with RemoteTerm as if it were a physical radio.
|
||||||
|
|
||||||
|
Enable with `MESHCORE_TCP_PROXY_ENABLED=true`.
|
||||||
|
|
||||||
|
## Module Map
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/tcp_proxy/
|
||||||
|
├── __init__.py # start_tcp_proxy() / stop_tcp_proxy() lifecycle
|
||||||
|
├── protocol.py # Constants, FrameParser, frame helpers
|
||||||
|
├── encoder.py # Binary builders: contact, self_info, device_info
|
||||||
|
├── session.py # ProxySession: per-client command dispatch + event handlers
|
||||||
|
├── server.py # TCP server lifecycle, session registry, dispatch_event()
|
||||||
|
└── AGENTS_tcp_proxy.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol (protocol.py)
|
||||||
|
|
||||||
|
- Frame format: `0x3C`/`0x3E` marker + 2-byte LE length + payload
|
||||||
|
- Command constants (`CMD_*`): client → proxy (first payload byte)
|
||||||
|
- Response constants (`RESP_*`): proxy → client
|
||||||
|
- Push constants (`PUSH_*`): unsolicited proxy → client notifications
|
||||||
|
- `FrameParser`: stateful streaming frame decoder (mirrors meshcore_py `tcp_cx.py`)
|
||||||
|
- Helpers: `frame_response`, `build_ok`, `build_error`, `pad`, `encode_path_byte`
|
||||||
|
|
||||||
|
## Encoder (encoder.py)
|
||||||
|
|
||||||
|
Stateless binary serializers that build companion-protocol payloads from
|
||||||
|
domain data. All functions return raw `bytes` (no frame wrapping).
|
||||||
|
|
||||||
|
- `build_contact` / `build_contact_from_dict`: Contact → RESP_CONTACT / PUSH_NEW_ADVERT
|
||||||
|
- `build_self_info` / `build_self_info_from_runtime`: radio config → RESP_SELF_INFO
|
||||||
|
- `build_device_info`: → RESP_DEVICE_INFO (fixed proxy identity)
|
||||||
|
|
||||||
|
## Session (session.py)
|
||||||
|
|
||||||
|
One `ProxySession` per connected TCP client. Maintains per-client state:
|
||||||
|
|
||||||
|
- **contacts**: cached favorite contacts from DB
|
||||||
|
- **channels**: cached channel list
|
||||||
|
- **channel_slots** / **key_to_idx**: bidirectional channel index ↔ key mapping
|
||||||
|
- **_msg_queue**: queued incoming messages for the pull-based delivery model
|
||||||
|
|
||||||
|
### Command Dispatch
|
||||||
|
|
||||||
|
Command byte → handler method via class-level dispatch table. Unsupported
|
||||||
|
commands return `ERR_UNSUPPORTED`.
|
||||||
|
|
||||||
|
### Message Delivery (Pull Model)
|
||||||
|
|
||||||
|
MeshCore mobile apps use a pull model for incoming messages:
|
||||||
|
1. Broadcast event arrives → session builds a V3 message frame → queues it
|
||||||
|
2. Session sends `PUSH_MSG_WAITING` (0x83) to notify the client
|
||||||
|
3. Client calls `CMD_SYNC_NEXT_MESSAGE` (0x0A) to pull the message
|
||||||
|
4. Session dequeues and sends the frame
|
||||||
|
5. Client calls again → `RESP_NO_MORE_MSGS` when queue is empty
|
||||||
|
|
||||||
|
### DM Send Flow
|
||||||
|
|
||||||
|
1. Parse destination prefix/key from binary payload
|
||||||
|
2. Resolve to full public key via contacts cache
|
||||||
|
3. Send immediate `RESP_MSG_SENT` + `PUSH_ACK` (fake ACK) so client doesn't retry
|
||||||
|
4. Fire-and-forget `_do_send_dm()` task calls `send_direct_message_to_contact()`
|
||||||
|
5. RemoteTerm handles actual radio lock, retries, and ACK tracking
|
||||||
|
|
||||||
|
## Server (server.py)
|
||||||
|
|
||||||
|
- TCP server lifecycle (`start` / `stop`) following the `radio_stats.py` pattern
|
||||||
|
- Session registry (`register` / `unregister`)
|
||||||
|
- `dispatch_event()`: called from `broadcast_event()` in `websocket.py` for
|
||||||
|
`message`, `message_acked`, and `contact` events
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → TCP frame → FrameParser → ProxySession._dispatch
|
||||||
|
→ command handler → repository/service call → binary response → TCP frame
|
||||||
|
|
||||||
|
RemoteTerm event → broadcast_event → dispatch_event
|
||||||
|
→ ProxySession.on_event_* → push frame → TCP frame
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
- `app/config.py`: `tcp_proxy_enabled`, `tcp_proxy_bind`, `tcp_proxy_port`
|
||||||
|
- `app/main.py`: conditional `start_tcp_proxy()` / `stop_tcp_proxy()` in lifespan
|
||||||
|
- `app/websocket.py`: `dispatch_event()` hook in `broadcast_event()` for message/ack/contact
|
||||||
|
|
||||||
|
## Design Constraints
|
||||||
|
|
||||||
|
- Never mutate RemoteTerm state from SET_CHANNEL (local slot mapping only)
|
||||||
|
- Only sync favorite contacts to clients
|
||||||
|
- Channel slots: pre-load favorites only, ERR_NOT_FOUND for empty slots
|
||||||
|
- DM sends return immediate fake ACK (RemoteTerm handles retries)
|
||||||
|
- Message delivery uses the pull model (PUSH_MSG_WAITING → SYNC_NEXT_MESSAGE)
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the TCP companion protocol proxy |
|
||||||
|
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the proxy TCP server |
|
||||||
|
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the proxy TCP server |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/
|
||||||
|
├── test_tcp_proxy_protocol.py # FrameParser, frame helpers (pure, no async)
|
||||||
|
├── test_tcp_proxy_encoder.py # Binary encoding against expected wire bytes
|
||||||
|
├── test_tcp_proxy_session.py # Command handlers with mocked radio + repos
|
||||||
|
└── test_tcp_proxy_integration.py # Real TCP server, end-to-end frame exchange
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""MeshCore TCP companion protocol proxy.
|
||||||
|
|
||||||
|
Emulates a MeshCore companion radio over TCP, translating the binary
|
||||||
|
protocol into in-process RemoteTerm operations. Enable with
|
||||||
|
``MESHCORE_TCP_PROXY_ENABLED=true``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_tcp_proxy() -> None:
|
||||||
|
"""Start the TCP proxy server using settings from config."""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
from .server import start
|
||||||
|
|
||||||
|
await start(settings.tcp_proxy_bind, settings.tcp_proxy_port)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_tcp_proxy() -> None:
|
||||||
|
"""Stop the TCP proxy server."""
|
||||||
|
from .server import stop
|
||||||
|
|
||||||
|
await stop()
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Binary encoders that build companion-protocol response payloads.
|
||||||
|
|
||||||
|
All functions return raw ``bytes`` payloads (without frame wrapping).
|
||||||
|
The caller is responsible for framing via :func:`protocol.frame_response`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .protocol import (
|
||||||
|
PROXY_FW_BUILD,
|
||||||
|
PROXY_FW_VER,
|
||||||
|
PROXY_FW_VERSION,
|
||||||
|
PROXY_MAX_CHANNELS,
|
||||||
|
PROXY_MAX_CONTACTS_RAW,
|
||||||
|
PROXY_MODEL,
|
||||||
|
PUSH_NEW_ADVERT,
|
||||||
|
RESP_CONTACT,
|
||||||
|
RESP_DEVICE_INFO,
|
||||||
|
RESP_SELF_INFO,
|
||||||
|
encode_path_byte,
|
||||||
|
pad,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_contact(
|
||||||
|
public_key: str,
|
||||||
|
*,
|
||||||
|
contact_type: int = 0,
|
||||||
|
favorite: bool = False,
|
||||||
|
direct_path: str | None = None,
|
||||||
|
direct_path_len: int = -1,
|
||||||
|
direct_path_hash_mode: int = -1,
|
||||||
|
name: str | None = None,
|
||||||
|
last_advert: int = 0,
|
||||||
|
lat: float = 0.0,
|
||||||
|
lon: float = 0.0,
|
||||||
|
lastmod: int | None = None,
|
||||||
|
push: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
"""Build a ``RESP_CONTACT`` (or ``PUSH_NEW_ADVERT``) payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
push: If True, use ``PUSH_NEW_ADVERT`` (0x8A) instead of
|
||||||
|
``RESP_CONTACT`` (0x03) as the leading byte.
|
||||||
|
"""
|
||||||
|
out = bytearray()
|
||||||
|
out.append(PUSH_NEW_ADVERT if push else RESP_CONTACT)
|
||||||
|
|
||||||
|
out.extend(pad(bytes.fromhex(public_key), 32))
|
||||||
|
out.append(contact_type)
|
||||||
|
|
||||||
|
flags = 0x01 if favorite else 0x00
|
||||||
|
out.append(flags)
|
||||||
|
|
||||||
|
if direct_path_len >= 0 and direct_path_hash_mode >= 0:
|
||||||
|
out.append(encode_path_byte(direct_path_len, direct_path_hash_mode))
|
||||||
|
else:
|
||||||
|
out.append(0xFF) # no route known
|
||||||
|
|
||||||
|
path_bytes = bytes.fromhex(direct_path) if direct_path else b""
|
||||||
|
out.extend(pad(path_bytes, 64))
|
||||||
|
|
||||||
|
out.extend(pad((name or "").encode("utf-8", "replace"), 32))
|
||||||
|
out.extend(struct.pack("<I", last_advert))
|
||||||
|
|
||||||
|
out.extend(struct.pack("<i", int(lat * 1e6)))
|
||||||
|
out.extend(struct.pack("<i", int(lon * 1e6)))
|
||||||
|
|
||||||
|
out.extend(struct.pack("<I", lastmod or int(time.time())))
|
||||||
|
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_contact_from_dict(data: dict[str, Any], *, push: bool = False) -> bytes:
|
||||||
|
"""Build a contact payload from either a ``Contact`` model dict or a
|
||||||
|
WS event ``data`` dict. Accepts both snake_case model fields and
|
||||||
|
the shapes produced by Pydantic JSON serialisation."""
|
||||||
|
return build_contact(
|
||||||
|
public_key=data["public_key"],
|
||||||
|
contact_type=data.get("type") or 0,
|
||||||
|
favorite=bool(data.get("favorite")),
|
||||||
|
direct_path=data.get("direct_path") or None,
|
||||||
|
direct_path_len=data.get("direct_path_len", -1),
|
||||||
|
direct_path_hash_mode=data.get("direct_path_hash_mode", -1),
|
||||||
|
name=data.get("name"),
|
||||||
|
last_advert=int(data.get("last_advert") or 0),
|
||||||
|
lat=float(data.get("lat") or 0),
|
||||||
|
lon=float(data.get("lon") or 0),
|
||||||
|
lastmod=int(data.get("lastmod") or data.get("first_seen") or 0) or None,
|
||||||
|
push=push,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_self_info(
|
||||||
|
*,
|
||||||
|
public_key: str = "00" * 32,
|
||||||
|
name: str = "RemoteTerm",
|
||||||
|
tx_power: int = 20,
|
||||||
|
max_tx_power: int = 22,
|
||||||
|
lat: float = 0.0,
|
||||||
|
lon: float = 0.0,
|
||||||
|
multi_acks: bool = False,
|
||||||
|
advert_loc: bool = False,
|
||||||
|
radio_freq: float = 915.0,
|
||||||
|
radio_bw: float = 250.0,
|
||||||
|
radio_sf: int = 10,
|
||||||
|
radio_cr: int = 7,
|
||||||
|
) -> bytes:
|
||||||
|
"""Build a ``RESP_SELF_INFO`` payload (response to ``CMD_APP_START``)."""
|
||||||
|
out = bytearray()
|
||||||
|
out.append(RESP_SELF_INFO)
|
||||||
|
out.append(1) # adv_type = CHAT
|
||||||
|
out.append(tx_power)
|
||||||
|
out.append(max_tx_power)
|
||||||
|
out.extend(pad(bytes.fromhex(public_key), 32))
|
||||||
|
out.extend(struct.pack("<i", int(lat * 1e6)))
|
||||||
|
out.extend(struct.pack("<i", int(lon * 1e6)))
|
||||||
|
out.append(1 if multi_acks else 0)
|
||||||
|
out.append(1 if advert_loc else 0)
|
||||||
|
out.append(0) # telemetry_mode
|
||||||
|
out.append(0) # manual_add_contacts
|
||||||
|
out.extend(struct.pack("<I", int(radio_freq * 1000)))
|
||||||
|
out.extend(struct.pack("<I", int(radio_bw * 1000)))
|
||||||
|
out.append(radio_sf)
|
||||||
|
out.append(radio_cr)
|
||||||
|
out.extend(name.encode("utf-8"))
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_self_info_from_runtime(self_info: dict[str, Any]) -> bytes:
|
||||||
|
"""Build ``RESP_SELF_INFO`` from ``radio_runtime.self_info``."""
|
||||||
|
return build_self_info(
|
||||||
|
public_key=self_info.get("public_key") or "00" * 32,
|
||||||
|
name=self_info.get("name") or "RemoteTerm",
|
||||||
|
tx_power=self_info.get("tx_power") or 20,
|
||||||
|
max_tx_power=self_info.get("max_tx_power") or 22,
|
||||||
|
lat=float(self_info.get("adv_lat") or 0),
|
||||||
|
lon=float(self_info.get("adv_lon") or 0),
|
||||||
|
multi_acks=bool(self_info.get("multi_acks")),
|
||||||
|
advert_loc=bool(self_info.get("adv_loc_policy")),
|
||||||
|
radio_freq=float(self_info.get("radio_freq") or 915.0),
|
||||||
|
radio_bw=float(self_info.get("radio_bw") or 250.0),
|
||||||
|
radio_sf=int(self_info.get("radio_sf") or 10),
|
||||||
|
radio_cr=int(self_info.get("radio_cr") or 7),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_device_info(path_hash_mode: int = 0) -> bytes:
|
||||||
|
"""Build a ``RESP_DEVICE_INFO`` payload (response to ``CMD_DEVICE_QUERY``)."""
|
||||||
|
out = bytearray()
|
||||||
|
out.append(RESP_DEVICE_INFO)
|
||||||
|
out.append(PROXY_FW_VER)
|
||||||
|
out.append(PROXY_MAX_CONTACTS_RAW) # ×2 by reader
|
||||||
|
out.append(PROXY_MAX_CHANNELS)
|
||||||
|
out.extend(struct.pack("<I", 0)) # ble_pin
|
||||||
|
out.extend(pad(PROXY_FW_BUILD.encode(), 12))
|
||||||
|
out.extend(pad(PROXY_MODEL.encode(), 40))
|
||||||
|
out.extend(pad(PROXY_FW_VERSION.encode(), 20))
|
||||||
|
out.append(0) # repeat mode (fw v9+)
|
||||||
|
out.append(path_hash_mode) # (fw v10+)
|
||||||
|
return bytes(out)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""MeshCore companion protocol constants, frame helpers, and streaming parser."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ── Frame markers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FRAME_TX = 0x3C # client → radio
|
||||||
|
FRAME_RX = 0x3E # radio → client
|
||||||
|
MAX_FRAME_SIZE = 300 # firmware MAX_FRAME_SIZE is 172; we allow a bit more
|
||||||
|
|
||||||
|
# ── Command types (client → proxy) ──────────────────────────────────
|
||||||
|
|
||||||
|
CMD_APP_START = 0x01
|
||||||
|
CMD_SEND_TXT_MSG = 0x02
|
||||||
|
CMD_SEND_CHANNEL_TXT_MSG = 0x03
|
||||||
|
CMD_GET_CONTACTS = 0x04
|
||||||
|
CMD_GET_DEVICE_TIME = 0x05
|
||||||
|
CMD_SET_DEVICE_TIME = 0x06
|
||||||
|
CMD_SEND_SELF_ADVERT = 0x07
|
||||||
|
CMD_SET_ADVERT_NAME = 0x08
|
||||||
|
CMD_ADD_UPDATE_CONTACT = 0x09
|
||||||
|
CMD_SYNC_NEXT_MESSAGE = 0x0A
|
||||||
|
CMD_SET_RADIO_PARAMS = 0x0B
|
||||||
|
CMD_SET_RADIO_TX_POWER = 0x0C
|
||||||
|
CMD_RESET_PATH = 0x0D
|
||||||
|
CMD_SET_ADVERT_LATLON = 0x0E
|
||||||
|
CMD_REMOVE_CONTACT = 0x0F
|
||||||
|
CMD_REBOOT = 0x13
|
||||||
|
CMD_GET_BATT_AND_STORAGE = 0x14
|
||||||
|
CMD_DEVICE_QUERY = 0x16
|
||||||
|
CMD_EXPORT_PRIVATE_KEY = 0x17
|
||||||
|
CMD_HAS_CONNECTION = 0x1C
|
||||||
|
CMD_GET_CONTACT_BY_KEY = 0x1E
|
||||||
|
CMD_GET_CHANNEL = 0x1F
|
||||||
|
CMD_SET_CHANNEL = 0x20
|
||||||
|
CMD_SET_FLOOD_SCOPE = 0x36
|
||||||
|
CMD_GET_STATS = 0x38
|
||||||
|
|
||||||
|
CMD_NAMES: dict[int, str] = {
|
||||||
|
0x01: "APP_START",
|
||||||
|
0x02: "SEND_TXT_MSG",
|
||||||
|
0x03: "SEND_CHAN_MSG",
|
||||||
|
0x04: "GET_CONTACTS",
|
||||||
|
0x05: "GET_TIME",
|
||||||
|
0x06: "SET_TIME",
|
||||||
|
0x07: "SEND_ADVERT",
|
||||||
|
0x08: "SET_NAME",
|
||||||
|
0x09: "ADD_CONTACT",
|
||||||
|
0x0A: "SYNC_MSG",
|
||||||
|
0x0B: "SET_RADIO",
|
||||||
|
0x0C: "SET_TX_POWER",
|
||||||
|
0x0D: "RESET_PATH",
|
||||||
|
0x0E: "SET_LATLON",
|
||||||
|
0x0F: "REMOVE_CONTACT",
|
||||||
|
0x13: "REBOOT",
|
||||||
|
0x14: "GET_BATTERY",
|
||||||
|
0x16: "DEVICE_QUERY",
|
||||||
|
0x17: "EXPORT_PRIV_KEY",
|
||||||
|
0x1C: "HAS_CONNECTION",
|
||||||
|
0x1E: "GET_CONTACT_BY_KEY",
|
||||||
|
0x1F: "GET_CHANNEL",
|
||||||
|
0x20: "SET_CHANNEL",
|
||||||
|
0x36: "SET_FLOOD_SCOPE",
|
||||||
|
0x38: "GET_STATS",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Response / push types (proxy → client) ──────────────────────────
|
||||||
|
|
||||||
|
RESP_OK = 0x00
|
||||||
|
RESP_ERR = 0x01
|
||||||
|
RESP_CONTACT_START = 0x02
|
||||||
|
RESP_CONTACT = 0x03
|
||||||
|
RESP_CONTACT_END = 0x04
|
||||||
|
RESP_SELF_INFO = 0x05
|
||||||
|
RESP_MSG_SENT = 0x06
|
||||||
|
RESP_CONTACT_MSG_RECV = 0x07
|
||||||
|
RESP_CHANNEL_MSG_RECV = 0x08
|
||||||
|
RESP_CURRENT_TIME = 0x09
|
||||||
|
RESP_NO_MORE_MSGS = 0x0A
|
||||||
|
RESP_BATTERY = 0x0C
|
||||||
|
RESP_DEVICE_INFO = 0x0D
|
||||||
|
RESP_DISABLED = 0x0F
|
||||||
|
RESP_CONTACT_MSG_RECV_V3 = 0x10
|
||||||
|
RESP_CHANNEL_MSG_RECV_V3 = 0x11
|
||||||
|
RESP_CHANNEL_INFO = 0x12
|
||||||
|
|
||||||
|
PUSH_ACK = 0x82
|
||||||
|
PUSH_MSG_WAITING = 0x83
|
||||||
|
PUSH_NEW_ADVERT = 0x8A
|
||||||
|
|
||||||
|
# ── Error codes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ERR_UNSUPPORTED = 1
|
||||||
|
ERR_NOT_FOUND = 2
|
||||||
|
|
||||||
|
# ── Virtual device identity ─────────────────────────────────────────
|
||||||
|
|
||||||
|
PROXY_FW_VER = 11
|
||||||
|
PROXY_MAX_CONTACTS_RAW = 255 # reader multiplies by 2 → 510
|
||||||
|
PROXY_MAX_CHANNELS = 40
|
||||||
|
PROXY_MODEL = "RemoteTerm Proxy"
|
||||||
|
PROXY_FW_VERSION = "v0.1.0-proxy"
|
||||||
|
PROXY_FW_BUILD = "proxy"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Frame helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def frame_response(payload: bytes) -> bytes:
|
||||||
|
"""Wrap *payload* in a ``0x3E`` frame for sending to the client."""
|
||||||
|
return bytes([FRAME_RX]) + len(payload).to_bytes(2, "little") + payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_ok(value: int | None = None) -> bytes:
|
||||||
|
"""Build a ``RESP_OK`` payload, optionally with a 4-byte LE value."""
|
||||||
|
if value is not None:
|
||||||
|
return bytes([RESP_OK]) + value.to_bytes(4, "little")
|
||||||
|
return bytes([RESP_OK])
|
||||||
|
|
||||||
|
|
||||||
|
def build_error(code: int = ERR_UNSUPPORTED) -> bytes:
|
||||||
|
"""Build a ``RESP_ERR`` payload with the given error code."""
|
||||||
|
return bytes([RESP_ERR, code])
|
||||||
|
|
||||||
|
|
||||||
|
def pad(data: bytes, length: int) -> bytes:
|
||||||
|
"""Pad or truncate *data* to exactly *length* bytes."""
|
||||||
|
return data[:length].ljust(length, b"\x00")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_path_byte(hop_count: int, hash_mode: int) -> int:
|
||||||
|
"""Encode hop count + hash mode into a single packed byte.
|
||||||
|
|
||||||
|
Returns ``0xFF`` (direct / non-flood) when either value is negative.
|
||||||
|
"""
|
||||||
|
if hop_count < 0 or hash_mode < 0:
|
||||||
|
return 0xFF
|
||||||
|
return ((hash_mode & 0x03) << 6) | (hop_count & 0x3F)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Streaming frame parser ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FrameParser:
|
||||||
|
"""Stateful parser for ``0x3C``-framed TCP data.
|
||||||
|
|
||||||
|
Mirrors the framing logic in ``meshcore_py`` ``tcp_cx.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.header = b""
|
||||||
|
self.inframe = b""
|
||||||
|
self.frame_size = 0
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def feed(self, data: bytes) -> list[bytes]:
|
||||||
|
"""Feed raw TCP bytes, return a list of complete payloads."""
|
||||||
|
payloads: list[bytes] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while offset < len(data):
|
||||||
|
remaining = data[offset:]
|
||||||
|
|
||||||
|
if not self.started:
|
||||||
|
needed = 3 - len(self.header)
|
||||||
|
chunk = remaining[:needed]
|
||||||
|
self.header += chunk
|
||||||
|
offset += len(chunk)
|
||||||
|
|
||||||
|
if len(self.header) < 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.header[0] != FRAME_TX:
|
||||||
|
self.header = b""
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.frame_size = int.from_bytes(self.header[1:3], "little")
|
||||||
|
if self.frame_size > MAX_FRAME_SIZE:
|
||||||
|
self.header = b""
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.started = True
|
||||||
|
else:
|
||||||
|
needed = self.frame_size - len(self.inframe)
|
||||||
|
chunk = remaining[:needed]
|
||||||
|
self.inframe += chunk
|
||||||
|
offset += len(chunk)
|
||||||
|
|
||||||
|
if len(self.inframe) >= self.frame_size:
|
||||||
|
payloads.append(self.inframe)
|
||||||
|
self.header = b""
|
||||||
|
self.inframe = b""
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
return payloads
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""TCP server lifecycle, session registry, and broadcast event dispatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .session import ProxySession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Session registry ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sessions: set[ProxySession] = set()
|
||||||
|
_server: asyncio.Server | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def register(session: ProxySession) -> None:
|
||||||
|
_sessions.add(session)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(session: ProxySession) -> None:
|
||||||
|
_sessions.discard(session)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event dispatch (called from broadcast_event) ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_event(event_type: str, data: dict[str, Any]) -> None:
|
||||||
|
"""Dispatch a broadcast event to all connected proxy sessions.
|
||||||
|
|
||||||
|
Called from :func:`app.websocket.broadcast_event` for ``message``,
|
||||||
|
``message_acked``, and ``contact`` events.
|
||||||
|
"""
|
||||||
|
for session in list(_sessions):
|
||||||
|
try:
|
||||||
|
if event_type == "message":
|
||||||
|
await session.on_event_message(data)
|
||||||
|
elif event_type == "contact":
|
||||||
|
await session.on_event_contact(data)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error dispatching %s to %s", event_type, session.addr)
|
||||||
|
|
||||||
|
|
||||||
|
# ── TCP client handler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_client(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
session = ProxySession(reader, writer)
|
||||||
|
register(session)
|
||||||
|
try:
|
||||||
|
await session.run()
|
||||||
|
finally:
|
||||||
|
unregister(session)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Server lifecycle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def start(host: str, port: int) -> None:
|
||||||
|
"""Start the TCP proxy server."""
|
||||||
|
global _server
|
||||||
|
if _server is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_server = await asyncio.start_server(_handle_client, host, port)
|
||||||
|
addrs = ", ".join(str(s.getsockname()) for s in _server.sockets)
|
||||||
|
logger.info("TCP proxy listening on %s", addrs)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop() -> None:
|
||||||
|
"""Stop the TCP proxy server and disconnect all clients."""
|
||||||
|
global _server
|
||||||
|
if _server is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Close all active sessions
|
||||||
|
for session in list(_sessions):
|
||||||
|
try:
|
||||||
|
session.writer.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_sessions.clear()
|
||||||
|
|
||||||
|
_server.close()
|
||||||
|
await _server.wait_closed()
|
||||||
|
_server = None
|
||||||
|
logger.info("TCP proxy stopped")
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
"""Per-client MeshCore companion protocol session.
|
||||||
|
|
||||||
|
Each connected TCP client gets its own ``ProxySession`` which:
|
||||||
|
- parses incoming 0x3C frames via :class:`protocol.FrameParser`
|
||||||
|
- dispatches commands to handler methods
|
||||||
|
- translates between binary companion payloads and in-process
|
||||||
|
repository / service calls
|
||||||
|
- receives broadcast events and queues push frames for the client
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .encoder import (
|
||||||
|
build_contact_from_dict,
|
||||||
|
build_device_info,
|
||||||
|
build_self_info_from_runtime,
|
||||||
|
)
|
||||||
|
from .protocol import (
|
||||||
|
CMD_ADD_UPDATE_CONTACT,
|
||||||
|
CMD_APP_START,
|
||||||
|
CMD_DEVICE_QUERY,
|
||||||
|
CMD_EXPORT_PRIVATE_KEY,
|
||||||
|
CMD_GET_BATT_AND_STORAGE,
|
||||||
|
CMD_GET_CHANNEL,
|
||||||
|
CMD_GET_CONTACT_BY_KEY,
|
||||||
|
CMD_GET_CONTACTS,
|
||||||
|
CMD_GET_DEVICE_TIME,
|
||||||
|
CMD_HAS_CONNECTION,
|
||||||
|
CMD_NAMES,
|
||||||
|
CMD_REMOVE_CONTACT,
|
||||||
|
CMD_RESET_PATH,
|
||||||
|
CMD_SEND_CHANNEL_TXT_MSG,
|
||||||
|
CMD_SEND_SELF_ADVERT,
|
||||||
|
CMD_SEND_TXT_MSG,
|
||||||
|
CMD_SET_ADVERT_LATLON,
|
||||||
|
CMD_SET_ADVERT_NAME,
|
||||||
|
CMD_SET_CHANNEL,
|
||||||
|
CMD_SET_DEVICE_TIME,
|
||||||
|
CMD_SET_FLOOD_SCOPE,
|
||||||
|
CMD_SYNC_NEXT_MESSAGE,
|
||||||
|
ERR_NOT_FOUND,
|
||||||
|
ERR_UNSUPPORTED,
|
||||||
|
PROXY_MAX_CHANNELS,
|
||||||
|
PUSH_ACK,
|
||||||
|
PUSH_MSG_WAITING,
|
||||||
|
RESP_BATTERY,
|
||||||
|
RESP_CHANNEL_INFO,
|
||||||
|
RESP_CHANNEL_MSG_RECV_V3,
|
||||||
|
RESP_CONTACT_END,
|
||||||
|
RESP_CONTACT_MSG_RECV_V3,
|
||||||
|
RESP_CONTACT_START,
|
||||||
|
RESP_CURRENT_TIME,
|
||||||
|
RESP_DISABLED,
|
||||||
|
RESP_MSG_SENT,
|
||||||
|
RESP_NO_MORE_MSGS,
|
||||||
|
FrameParser,
|
||||||
|
build_error,
|
||||||
|
build_ok,
|
||||||
|
encode_path_byte,
|
||||||
|
frame_response,
|
||||||
|
pad,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxySession:
|
||||||
|
"""Handles one MeshCore TCP client, translating commands to RemoteTerm
|
||||||
|
repository and service calls."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
self.reader = reader
|
||||||
|
self.writer = writer
|
||||||
|
self.addr = writer.get_extra_info("peername")
|
||||||
|
self.parser = FrameParser()
|
||||||
|
|
||||||
|
# Cached state
|
||||||
|
self.contacts: list[dict[str, Any]] = []
|
||||||
|
self.channels: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Channel index ↔ key mapping
|
||||||
|
self.channel_slots: dict[int, str] = {} # idx → key (lowercase hex)
|
||||||
|
self.key_to_idx: dict[str, int] = {} # key (lowercase) → idx
|
||||||
|
|
||||||
|
# Queued incoming messages for SYNC_NEXT_MESSAGE pull flow.
|
||||||
|
self._msg_queue: list[bytes] = []
|
||||||
|
|
||||||
|
# ── send helper ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def send(self, payload: bytes) -> None:
|
||||||
|
"""Frame and send a response payload."""
|
||||||
|
self.writer.write(frame_response(payload))
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
# ── main loop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
logger.info("Client connected: %s", self.addr)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await self.reader.read(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
for payload in self.parser.feed(data):
|
||||||
|
await self._dispatch(payload)
|
||||||
|
except (asyncio.CancelledError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Session error [%s]", self.addr)
|
||||||
|
finally:
|
||||||
|
self.writer.close()
|
||||||
|
logger.info("Client disconnected: %s", self.addr)
|
||||||
|
|
||||||
|
# ── command dispatch ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DISPATCH_TABLE: dict[int, str] | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_dispatch_table(cls) -> dict[int, str]:
|
||||||
|
if cls._DISPATCH_TABLE is None:
|
||||||
|
cls._DISPATCH_TABLE = {
|
||||||
|
CMD_APP_START: "_cmd_app_start",
|
||||||
|
CMD_DEVICE_QUERY: "_cmd_device_query",
|
||||||
|
CMD_GET_CONTACTS: "_cmd_get_contacts",
|
||||||
|
CMD_GET_CONTACT_BY_KEY: "_cmd_get_contact_by_key",
|
||||||
|
CMD_GET_CHANNEL: "_cmd_get_channel",
|
||||||
|
CMD_SET_CHANNEL: "_cmd_set_channel",
|
||||||
|
CMD_SEND_TXT_MSG: "_cmd_send_dm",
|
||||||
|
CMD_SEND_CHANNEL_TXT_MSG: "_cmd_send_channel",
|
||||||
|
CMD_GET_DEVICE_TIME: "_cmd_get_time",
|
||||||
|
CMD_SET_DEVICE_TIME: "_cmd_ok_stub",
|
||||||
|
CMD_SEND_SELF_ADVERT: "_cmd_advertise",
|
||||||
|
CMD_GET_BATT_AND_STORAGE: "_cmd_battery",
|
||||||
|
CMD_HAS_CONNECTION: "_cmd_has_connection",
|
||||||
|
CMD_SYNC_NEXT_MESSAGE: "_cmd_sync_next",
|
||||||
|
CMD_ADD_UPDATE_CONTACT: "_cmd_ok_stub",
|
||||||
|
CMD_REMOVE_CONTACT: "_cmd_remove_contact",
|
||||||
|
CMD_RESET_PATH: "_cmd_ok_stub",
|
||||||
|
CMD_SET_ADVERT_NAME: "_cmd_set_name",
|
||||||
|
CMD_SET_ADVERT_LATLON: "_cmd_set_latlon",
|
||||||
|
CMD_SET_FLOOD_SCOPE: "_cmd_ok_stub",
|
||||||
|
CMD_EXPORT_PRIVATE_KEY: "_cmd_disabled",
|
||||||
|
}
|
||||||
|
return cls._DISPATCH_TABLE
|
||||||
|
|
||||||
|
async def _dispatch(self, data: bytes) -> None:
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
cmd = data[0]
|
||||||
|
name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
|
||||||
|
logger.debug("[%s] ← %s (%dB)", self.addr, name, len(data))
|
||||||
|
|
||||||
|
table = self._build_dispatch_table()
|
||||||
|
method_name = table.get(cmd)
|
||||||
|
if method_name:
|
||||||
|
handler = getattr(self, method_name)
|
||||||
|
try:
|
||||||
|
await handler(data)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[%s] Error in %s", self.addr, name)
|
||||||
|
await self.send(build_error())
|
||||||
|
else:
|
||||||
|
logger.warning("[%s] Unsupported: %s", self.addr, name)
|
||||||
|
await self.send(build_error(ERR_UNSUPPORTED))
|
||||||
|
|
||||||
|
# ── stubs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_ok_stub(self, data: bytes) -> None:
|
||||||
|
await self.send(build_ok())
|
||||||
|
|
||||||
|
async def _cmd_disabled(self, data: bytes) -> None:
|
||||||
|
await self.send(bytes([RESP_DISABLED]))
|
||||||
|
|
||||||
|
# ── APP_START → SELF_INFO ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_app_start(self, data: bytes) -> None:
|
||||||
|
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
self.contacts = [c.model_dump() for c in await ContactRepository.get_favorites()]
|
||||||
|
self.channels = [c.model_dump() for c in await ChannelRepository.get_all()]
|
||||||
|
|
||||||
|
settings = await AppSettingsRepository.get()
|
||||||
|
lmt = settings.last_message_times or {}
|
||||||
|
self._sort_channels(lmt)
|
||||||
|
self._rebuild_slots()
|
||||||
|
|
||||||
|
mc = radio_runtime.meshcore
|
||||||
|
self_info = mc.self_info if mc else {}
|
||||||
|
await self.send(build_self_info_from_runtime(self_info or {}))
|
||||||
|
|
||||||
|
name = (self_info or {}).get("name", "?")
|
||||||
|
pubkey = (self_info or {}).get("public_key", "?" * 12)
|
||||||
|
logger.info(
|
||||||
|
"[%s] Session started — %s (%s...) | %d contacts, %d channel slots",
|
||||||
|
self.addr,
|
||||||
|
name,
|
||||||
|
pubkey[:12],
|
||||||
|
len(self.contacts),
|
||||||
|
len(self.channel_slots),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── DEVICE_QUERY → DEVICE_INFO ──────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_device_query(self, data: bytes) -> None:
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
mc = radio_runtime.meshcore
|
||||||
|
self_info = mc.self_info if mc else {}
|
||||||
|
# Fall back to radio_runtime.path_hash_mode which radio_lifecycle
|
||||||
|
# recovers from the raw device-info frame when self_info is missing it.
|
||||||
|
phm = (self_info or {}).get("path_hash_mode")
|
||||||
|
if phm is None:
|
||||||
|
phm = getattr(radio_runtime, "path_hash_mode", 0) or 0
|
||||||
|
await self.send(build_device_info(path_hash_mode=phm))
|
||||||
|
|
||||||
|
# ── GET_CONTACTS ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_get_contacts(self, data: bytes) -> None:
|
||||||
|
from app.repository import ContactRepository
|
||||||
|
|
||||||
|
self.contacts = [c.model_dump() for c in await ContactRepository.get_favorites()]
|
||||||
|
|
||||||
|
count = len(self.contacts)
|
||||||
|
await self.send(bytes([RESP_CONTACT_START]) + count.to_bytes(4, "little"))
|
||||||
|
|
||||||
|
for c in self.contacts:
|
||||||
|
await self.send(build_contact_from_dict(c))
|
||||||
|
|
||||||
|
await self.send(bytes([RESP_CONTACT_END]) + int(time.time()).to_bytes(4, "little"))
|
||||||
|
logger.info("[%s] Sent %d contacts", self.addr, count)
|
||||||
|
|
||||||
|
# ── GET_CONTACT_BY_KEY ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_get_contact_by_key(self, data: bytes) -> None:
|
||||||
|
if len(data) < 33:
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
pubkey = data[1:33].hex()
|
||||||
|
contact = next((c for c in self.contacts if c["public_key"] == pubkey), None)
|
||||||
|
if contact is None:
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.send(build_contact_from_dict(contact))
|
||||||
|
|
||||||
|
# ── GET_CHANNEL → CHANNEL_INFO ───────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_get_channel(self, data: bytes) -> None:
|
||||||
|
if len(data) < 2:
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
idx = data[1]
|
||||||
|
key_hex = self.channel_slots.get(idx)
|
||||||
|
if key_hex is None:
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
ch = next((c for c in self.channels if c["key"].lower() == key_hex), None)
|
||||||
|
name = (ch.get("name") or "") if ch else ""
|
||||||
|
|
||||||
|
out = bytearray()
|
||||||
|
out.append(RESP_CHANNEL_INFO)
|
||||||
|
out.append(idx)
|
||||||
|
out.extend(pad(name.encode("utf-8"), 32))
|
||||||
|
out.extend(pad(bytes.fromhex(key_hex), 16))
|
||||||
|
await self.send(bytes(out))
|
||||||
|
|
||||||
|
# ── SET_CHANNEL ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_set_channel(self, data: bytes) -> None:
|
||||||
|
if len(data) < 50:
|
||||||
|
await self.send(build_error())
|
||||||
|
return
|
||||||
|
|
||||||
|
idx = data[1]
|
||||||
|
key_hex = data[34:50].hex()
|
||||||
|
|
||||||
|
# Clean up stale bidirectional mappings
|
||||||
|
old_key = self.channel_slots.get(idx)
|
||||||
|
if old_key is not None and old_key != key_hex:
|
||||||
|
self.key_to_idx.pop(old_key, None)
|
||||||
|
|
||||||
|
old_idx = self.key_to_idx.get(key_hex)
|
||||||
|
if old_idx is not None and old_idx != idx:
|
||||||
|
self.channel_slots.pop(old_idx, None)
|
||||||
|
|
||||||
|
self.channel_slots[idx] = key_hex
|
||||||
|
self.key_to_idx[key_hex] = idx
|
||||||
|
await self.send(build_ok())
|
||||||
|
|
||||||
|
# ── SEND_TXT_MSG (DM) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_send_dm(self, data: bytes) -> None:
|
||||||
|
buf = io.BytesIO(data)
|
||||||
|
buf.read(1) # cmd
|
||||||
|
buf.read(1) # txt_type
|
||||||
|
buf.read(1) # attempt
|
||||||
|
buf.read(4) # timestamp
|
||||||
|
remaining = buf.read()
|
||||||
|
|
||||||
|
full_key, text = self._parse_destination_and_text(remaining)
|
||||||
|
if not full_key or text is None:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] Cannot resolve DM destination (remaining %dB)",
|
||||||
|
self.addr,
|
||||||
|
len(remaining),
|
||||||
|
)
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send immediate MSG_SENT + fake ACK — RemoteTerm handles retries.
|
||||||
|
ack_code = random.randbytes(4)
|
||||||
|
out = bytearray([RESP_MSG_SENT, 1]) # type=flood
|
||||||
|
out.extend(ack_code)
|
||||||
|
out.extend(struct.pack("<I", 5_000))
|
||||||
|
await self.send(bytes(out))
|
||||||
|
|
||||||
|
ack_frame = bytearray([PUSH_ACK])
|
||||||
|
ack_frame.extend(ack_code)
|
||||||
|
ack_frame.extend(struct.pack("<I", 100)) # fake trip_time
|
||||||
|
await self.send(bytes(ack_frame))
|
||||||
|
|
||||||
|
# Fire-and-forget the actual send
|
||||||
|
asyncio.create_task(self._do_send_dm(full_key, text))
|
||||||
|
logger.info("[%s] DM → %s...: %s", self.addr, full_key[:12], text[:40])
|
||||||
|
|
||||||
|
async def _do_send_dm(self, public_key: str, text: str) -> None:
|
||||||
|
"""Background task: send a DM through the radio via the service layer."""
|
||||||
|
try:
|
||||||
|
from app.event_handlers import track_pending_ack
|
||||||
|
from app.repository import ContactRepository, MessageRepository
|
||||||
|
from app.services.message_send import send_direct_message_to_contact
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
from app.websocket import broadcast_event
|
||||||
|
|
||||||
|
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||||
|
if not contact:
|
||||||
|
logger.warning("DM send: contact %s not found", public_key[:12])
|
||||||
|
return
|
||||||
|
|
||||||
|
await send_direct_message_to_contact(
|
||||||
|
contact=contact,
|
||||||
|
text=text,
|
||||||
|
radio_manager=radio_runtime,
|
||||||
|
broadcast_fn=broadcast_event,
|
||||||
|
track_pending_ack_fn=track_pending_ack,
|
||||||
|
now_fn=time.time,
|
||||||
|
message_repository=MessageRepository,
|
||||||
|
contact_repository=ContactRepository,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[%s] DM send failed for %s", self.addr, public_key[:12])
|
||||||
|
|
||||||
|
def _parse_destination_and_text(self, remaining: bytes) -> tuple[str | None, str | None]:
|
||||||
|
"""Resolve destination key + text from the combined buffer.
|
||||||
|
|
||||||
|
The standard companion protocol sends a 6-byte pubkey prefix at the
|
||||||
|
start of ``remaining``, so we try prefix resolution first. Only when
|
||||||
|
prefix lookup fails do we attempt a 32-byte full-key parse (used by
|
||||||
|
``meshcore_py`` ``send_msg_with_retry``).
|
||||||
|
"""
|
||||||
|
# Standard path: 6-byte prefix — resolve against cached contacts.
|
||||||
|
if len(remaining) > 6:
|
||||||
|
prefix = remaining[:6].hex()
|
||||||
|
matches = [c["public_key"] for c in self.contacts if c["public_key"].startswith(prefix)]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0], remaining[6:].decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
# Extended path: 32-byte full key (send_msg_with_retry sends full
|
||||||
|
# keys). _do_send_dm resolves from the repository, not just our
|
||||||
|
# favorites cache.
|
||||||
|
if len(remaining) > 32:
|
||||||
|
candidate = remaining[:32].hex()
|
||||||
|
return candidate, remaining[32:].decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# ── SEND_CHANNEL_TXT_MSG ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_send_channel(self, data: bytes) -> None:
|
||||||
|
buf = io.BytesIO(data)
|
||||||
|
buf.read(1) # cmd
|
||||||
|
buf.read(1) # txt_type
|
||||||
|
channel_idx = buf.read(1)[0]
|
||||||
|
buf.read(4) # timestamp
|
||||||
|
text = buf.read().rstrip(b"\x00").decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
key_hex = self.channel_slots.get(channel_idx)
|
||||||
|
if not key_hex:
|
||||||
|
logger.warning("[%s] No channel at slot %d", self.addr, channel_idx)
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify the channel exists in RemoteTerm's DB before confirming.
|
||||||
|
# SET_CHANNEL is local-only, so client-loaded channels that aren't in
|
||||||
|
# the DB can't be sent on — return ERR_NOT_FOUND instead of false OK.
|
||||||
|
from app.repository import ChannelRepository
|
||||||
|
|
||||||
|
channel = await ChannelRepository.get_by_key(key_hex)
|
||||||
|
if not channel:
|
||||||
|
logger.warning("[%s] Channel %s not in DB", self.addr, key_hex[:12])
|
||||||
|
await self.send(build_error(ERR_NOT_FOUND))
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.send(build_ok())
|
||||||
|
asyncio.create_task(self._do_send_channel(key_hex, text))
|
||||||
|
|
||||||
|
label = channel.name or key_hex[:8]
|
||||||
|
logger.info("[%s] Chan [%s]: %s", self.addr, label, text[:40])
|
||||||
|
|
||||||
|
async def _do_send_channel(self, channel_key: str, text: str) -> None:
|
||||||
|
"""Background task: send a channel message through the radio."""
|
||||||
|
try:
|
||||||
|
from app.repository import ChannelRepository, MessageRepository
|
||||||
|
from app.services.message_send import send_channel_message_to_channel
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
from app.websocket import broadcast_error, broadcast_event
|
||||||
|
|
||||||
|
channel = await ChannelRepository.get_by_key(channel_key)
|
||||||
|
if not channel:
|
||||||
|
logger.warning("Channel send: key %s not found", channel_key[:12])
|
||||||
|
return
|
||||||
|
|
||||||
|
key_bytes = bytes.fromhex(channel_key)
|
||||||
|
await send_channel_message_to_channel(
|
||||||
|
channel=channel,
|
||||||
|
channel_key_upper=channel_key.upper(),
|
||||||
|
key_bytes=key_bytes,
|
||||||
|
text=text,
|
||||||
|
radio_manager=radio_runtime,
|
||||||
|
broadcast_fn=broadcast_event,
|
||||||
|
error_broadcast_fn=broadcast_error,
|
||||||
|
now_fn=time.time,
|
||||||
|
temp_radio_slot=0,
|
||||||
|
message_repository=MessageRepository,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[%s] Channel send failed for %s", self.addr, channel_key[:12])
|
||||||
|
|
||||||
|
# ── Simple command handlers ──────────────────────────────────────
|
||||||
|
|
||||||
|
async def _cmd_get_time(self, data: bytes) -> None:
|
||||||
|
t = int(time.time())
|
||||||
|
await self.send(bytes([RESP_CURRENT_TIME]) + t.to_bytes(4, "little"))
|
||||||
|
|
||||||
|
async def _cmd_advertise(self, data: bytes) -> None:
|
||||||
|
try:
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
async with radio_runtime.radio_operation("proxy_advertise") as mc:
|
||||||
|
await mc.commands.send_advert(flood=True)
|
||||||
|
await self.send(build_ok())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Advertise failed")
|
||||||
|
await self.send(build_error())
|
||||||
|
|
||||||
|
async def _cmd_battery(self, data: bytes) -> None:
|
||||||
|
out = bytearray([RESP_BATTERY])
|
||||||
|
out.extend(struct.pack("<H", 0)) # no battery
|
||||||
|
await self.send(bytes(out))
|
||||||
|
|
||||||
|
async def _cmd_has_connection(self, data: bytes) -> None:
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
val = 1 if radio_runtime.is_connected else 0
|
||||||
|
await self.send(build_ok(val))
|
||||||
|
|
||||||
|
async def _cmd_sync_next(self, data: bytes) -> None:
|
||||||
|
if self._msg_queue:
|
||||||
|
frame = self._msg_queue.pop(0)
|
||||||
|
await self.send(frame)
|
||||||
|
logger.debug(
|
||||||
|
"[%s] Delivered queued msg (%d remaining)",
|
||||||
|
self.addr,
|
||||||
|
len(self._msg_queue),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.send(bytes([RESP_NO_MORE_MSGS]))
|
||||||
|
|
||||||
|
async def _cmd_remove_contact(self, data: bytes) -> None:
|
||||||
|
if len(data) < 33:
|
||||||
|
await self.send(build_error())
|
||||||
|
return
|
||||||
|
pubkey = data[1:33].hex()
|
||||||
|
self.contacts = [c for c in self.contacts if c["public_key"] != pubkey]
|
||||||
|
await self.send(build_ok())
|
||||||
|
|
||||||
|
async def _cmd_set_name(self, data: bytes) -> None:
|
||||||
|
name = data[1:].decode("utf-8", "ignore").rstrip("\x00")
|
||||||
|
try:
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
async with radio_runtime.radio_operation("proxy_set_name") as mc:
|
||||||
|
await mc.commands.set_name(name)
|
||||||
|
await self.send(build_ok())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Set name failed")
|
||||||
|
await self.send(build_error())
|
||||||
|
|
||||||
|
async def _cmd_set_latlon(self, data: bytes) -> None:
|
||||||
|
if len(data) < 9:
|
||||||
|
await self.send(build_error())
|
||||||
|
return
|
||||||
|
lat = struct.unpack_from("<i", data, 1)[0] / 1e6
|
||||||
|
lon = struct.unpack_from("<i", data, 5)[0] / 1e6
|
||||||
|
try:
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
async with radio_runtime.radio_operation("proxy_set_latlon") as mc:
|
||||||
|
await mc.commands.set_coords(lat, lon)
|
||||||
|
await self.send(build_ok())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Set lat/lon failed")
|
||||||
|
await self.send(build_error())
|
||||||
|
|
||||||
|
# ── Channel slot management ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _sort_channels(self, last_message_times: dict[str, Any]) -> None:
|
||||||
|
"""Sort channels: favorites first, then most recently active."""
|
||||||
|
lmt = last_message_times
|
||||||
|
|
||||||
|
def key(ch: dict) -> tuple:
|
||||||
|
is_fav = 1 if ch.get("favorite") else 0
|
||||||
|
state_key = f"channel-{ch['key']}"
|
||||||
|
last_activity = lmt.get(state_key) or 0
|
||||||
|
return (-is_fav, -last_activity)
|
||||||
|
|
||||||
|
self.channels.sort(key=key)
|
||||||
|
|
||||||
|
def _rebuild_slots(self) -> None:
|
||||||
|
"""Pre-load only favorite channels into slots."""
|
||||||
|
self.channel_slots.clear()
|
||||||
|
self.key_to_idx.clear()
|
||||||
|
favorites = [ch for ch in self.channels if ch.get("favorite")]
|
||||||
|
for i, ch in enumerate(favorites[:PROXY_MAX_CHANNELS]):
|
||||||
|
k = ch["key"].lower()
|
||||||
|
self.channel_slots[i] = k
|
||||||
|
self.key_to_idx[k] = i
|
||||||
|
logger.debug("Pre-loaded %d favorite channel(s)", len(self.channel_slots))
|
||||||
|
|
||||||
|
# ── Broadcast event helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_path_meta(data: dict[str, Any]) -> tuple[int, int]:
|
||||||
|
"""Extract (snr_byte, path_len_byte) from a broadcast message dict.
|
||||||
|
|
||||||
|
Returns the SNR as ``int8(snr * 4)`` and path_len as the companion-
|
||||||
|
protocol packed byte ``(hash_mode << 6) | hop_count``. When no path
|
||||||
|
data is available, returns ``(0, 0)`` — 0 hops at 1-byte hash mode,
|
||||||
|
which is the safest "we don't know" default for flood messages.
|
||||||
|
"""
|
||||||
|
paths = data.get("paths") or []
|
||||||
|
first = paths[0] if paths else None
|
||||||
|
|
||||||
|
# SNR — V3 field, signed int8 encoded as snr * 4
|
||||||
|
snr_raw = (first.get("snr") if first else None) or 0.0
|
||||||
|
snr_byte = max(-128, min(127, int(snr_raw * 4))) & 0xFF
|
||||||
|
|
||||||
|
if first is None:
|
||||||
|
return snr_byte, 0 # no path info → 0 hops
|
||||||
|
|
||||||
|
hop_count = first.get("path_len")
|
||||||
|
path_hex: str = first.get("path") or ""
|
||||||
|
if hop_count is None:
|
||||||
|
# Legacy: infer 1-byte hops from hex length
|
||||||
|
hop_count = len(path_hex) // 2
|
||||||
|
|
||||||
|
# Determine hash mode from path hex length and hop count
|
||||||
|
if hop_count > 0 and path_hex:
|
||||||
|
path_byte_len = len(path_hex) // 2
|
||||||
|
hash_size = path_byte_len // hop_count if hop_count else 1
|
||||||
|
hash_mode = max(0, hash_size - 1) # 1-byte → 0, 2 → 1, 3 → 2
|
||||||
|
else:
|
||||||
|
hash_mode = 0
|
||||||
|
|
||||||
|
return snr_byte, encode_path_byte(hop_count, hash_mode)
|
||||||
|
|
||||||
|
# ── Broadcast event handlers (called by server.dispatch_event) ──
|
||||||
|
|
||||||
|
async def _push_contact_from_db(self, public_key: str) -> None:
|
||||||
|
"""Fetch a contact from the DB and push it to the client so it can
|
||||||
|
display messages from senders not in the favorites cache."""
|
||||||
|
try:
|
||||||
|
from app.repository import ContactRepository
|
||||||
|
|
||||||
|
contact = await ContactRepository.get_by_key(public_key)
|
||||||
|
if not contact:
|
||||||
|
return
|
||||||
|
contact_dict = contact.model_dump()
|
||||||
|
await self.send(build_contact_from_dict(contact_dict, push=True))
|
||||||
|
self.contacts.append(contact_dict)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to push contact %s from DB", public_key[:12])
|
||||||
|
|
||||||
|
async def on_event_message(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Translate a broadcast ``message`` event into a queued push frame."""
|
||||||
|
if data.get("outgoing"):
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_type = data.get("type")
|
||||||
|
|
||||||
|
if msg_type == "PRIV":
|
||||||
|
sender_key = data.get("conversation_key", "")
|
||||||
|
if len(sender_key) < 12:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If sender isn't in our cache, fetch from DB and push to client
|
||||||
|
# so it knows who the message is from.
|
||||||
|
if not any(c["public_key"] == sender_key for c in self.contacts):
|
||||||
|
await self._push_contact_from_db(sender_key)
|
||||||
|
|
||||||
|
text = data.get("text") or ""
|
||||||
|
ts = int(data.get("sender_timestamp") or time.time())
|
||||||
|
snr_byte, path_byte = self._extract_path_meta(data)
|
||||||
|
|
||||||
|
frame = bytearray([RESP_CONTACT_MSG_RECV_V3])
|
||||||
|
frame.append(snr_byte)
|
||||||
|
frame.extend(b"\x00\x00") # reserved
|
||||||
|
frame.extend(bytes.fromhex(sender_key[:12])) # 6-byte prefix
|
||||||
|
frame.append(path_byte)
|
||||||
|
frame.append(0) # txt_type
|
||||||
|
frame.extend(struct.pack("<I", ts))
|
||||||
|
frame.extend(text.encode("utf-8"))
|
||||||
|
|
||||||
|
self._msg_queue.append(bytes(frame))
|
||||||
|
await self.send(bytes([PUSH_MSG_WAITING]))
|
||||||
|
|
||||||
|
elif msg_type == "CHAN":
|
||||||
|
conv_key = data.get("conversation_key", "").lower()
|
||||||
|
idx = self.key_to_idx.get(conv_key)
|
||||||
|
if idx is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
text = data.get("text") or ""
|
||||||
|
ts = int(data.get("sender_timestamp") or time.time())
|
||||||
|
snr_byte, path_byte = self._extract_path_meta(data)
|
||||||
|
|
||||||
|
frame = bytearray([RESP_CHANNEL_MSG_RECV_V3])
|
||||||
|
frame.append(snr_byte)
|
||||||
|
frame.extend(b"\x00\x00") # reserved
|
||||||
|
frame.append(idx)
|
||||||
|
frame.append(path_byte)
|
||||||
|
frame.append(0) # txt_type
|
||||||
|
frame.extend(struct.pack("<I", ts))
|
||||||
|
frame.extend(text.encode("utf-8"))
|
||||||
|
|
||||||
|
self._msg_queue.append(bytes(frame))
|
||||||
|
await self.send(bytes([PUSH_MSG_WAITING]))
|
||||||
|
|
||||||
|
async def on_event_contact(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Translate a broadcast ``contact`` event into a PUSH_NEW_ADVERT."""
|
||||||
|
pubkey = data.get("public_key", "")
|
||||||
|
if len(pubkey) < 64:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only push contacts that are already in our favorites cache.
|
||||||
|
# Without this filter, a long-lived session would gradually sync
|
||||||
|
# every contact on the mesh, defeating the favorites-only policy.
|
||||||
|
existing = next((c for c in self.contacts if c["public_key"] == pubkey), None)
|
||||||
|
if existing is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.send(build_contact_from_dict(data, push=True))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to build contact push for %s", pubkey[:12])
|
||||||
|
|
||||||
|
existing.update(data)
|
||||||
@@ -117,6 +117,15 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
|
|||||||
elif event_type == "contact":
|
elif event_type == "contact":
|
||||||
asyncio.create_task(fanout_manager.broadcast_contact(data))
|
asyncio.create_task(fanout_manager.broadcast_contact(data))
|
||||||
|
|
||||||
|
# TCP proxy dispatch
|
||||||
|
if event_type in ("message", "message_acked", "contact"):
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if settings.tcp_proxy_enabled:
|
||||||
|
from app.tcp_proxy.server import dispatch_event
|
||||||
|
|
||||||
|
asyncio.create_task(dispatch_event(event_type, data))
|
||||||
|
|
||||||
|
|
||||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||||
"""Broadcast an error notification to all connected clients.
|
"""Broadcast an error notification to all connected clients.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
# MESHCORE_DISABLE_BOTS: "true"
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
# MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# MESHCORE_LOG_LEVEL: INFO
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
|||||||
+3
-6
@@ -75,7 +75,6 @@ frontend/src/
|
|||||||
├── utils/
|
├── utils/
|
||||||
│ ├── urlHash.ts # Hash parsing and encoding
|
│ ├── urlHash.ts # Hash parsing and encoding
|
||||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
|
||||||
│ ├── messageParser.ts # Message text → rendered segments
|
│ ├── messageParser.ts # Message text → rendered segments
|
||||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
@@ -132,6 +131,9 @@ frontend/src/
|
|||||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
|
│ ├── ChannelPathHashModeOverrideModal.tsx # Per-channel path hash mode override editor
|
||||||
|
│ ├── BulkAddChannelResultModal.tsx # Results dialog for bulk channel creation
|
||||||
|
│ ├── CommandPalette.tsx # Command palette overlay
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
@@ -178,7 +180,6 @@ frontend/src/
|
|||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
├── rawPacketDetailModal.test.tsx
|
├── rawPacketDetailModal.test.tsx
|
||||||
├── rawPacketFeedView.test.tsx
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
@@ -350,10 +351,6 @@ It falls back to a 12-char prefix when `name` is missing.
|
|||||||
|
|
||||||
Distance/validation helpers used by path + map UI.
|
Distance/validation helpers used by path + map UI.
|
||||||
|
|
||||||
### `utils/favorites.ts`
|
|
||||||
|
|
||||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
|
||||||
|
|
||||||
## Types and Contracts (`types.ts`)
|
## Types and Contracts (`types.ts`)
|
||||||
|
|
||||||
`AppSettings` currently includes:
|
`AppSettings` currently includes:
|
||||||
|
|||||||
Generated
+186
-1194
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.0",
|
"version": "3.12.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.19.0",
|
"typescript-eslint": "^8.19.0",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.4.2",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -25,7 +25,13 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
|||||||
import { usePush } from './contexts/PushSubscriptionContext';
|
import { usePush } from './contexts/PushSubscriptionContext';
|
||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
import { getStateKey } from './utils/conversationState';
|
||||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
import type {
|
||||||
|
BulkCreateHashtagChannelsResult,
|
||||||
|
Channel,
|
||||||
|
Conversation,
|
||||||
|
Message,
|
||||||
|
RawPacket,
|
||||||
|
} from './types';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||||
|
|
||||||
@@ -207,6 +213,12 @@ export function App() {
|
|||||||
removeConversationMessagesRef.current(conversationId),
|
removeConversationMessagesRef.current(conversationId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep channels in a ref for WS callback mute filtering
|
||||||
|
const channelsRef = useRef<Channel[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
channelsRef.current = channels;
|
||||||
|
}, [channels]);
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
async (type: 'channel' | 'contact', id: string) => {
|
async (type: 'channel' | 'contact', id: string) => {
|
||||||
// Optimistically toggle the favorite flag
|
// Optimistically toggle the favorite flag
|
||||||
@@ -343,6 +355,20 @@ export function App() {
|
|||||||
useFaviconBadge(unreadCounts, mentions, channels);
|
useFaviconBadge(unreadCounts, mentions, channels);
|
||||||
useUnreadTitle(unreadCounts, contacts, channels);
|
useUnreadTitle(unreadCounts, contacts, channels);
|
||||||
|
|
||||||
|
const handleToggleMute = useCallback(
|
||||||
|
async (key: string) => {
|
||||||
|
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
|
||||||
|
try {
|
||||||
|
await api.toggleChannelMute(key);
|
||||||
|
await refreshUnreads();
|
||||||
|
} catch {
|
||||||
|
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
|
||||||
|
toast.error('Failed to update mute');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setChannels, refreshUnreads]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeConversation?.type !== 'channel') {
|
if (activeConversation?.type !== 'channel') {
|
||||||
setChannelUnreadMarker(null);
|
setChannelUnreadMarker(null);
|
||||||
@@ -408,6 +434,7 @@ export function App() {
|
|||||||
setContacts,
|
setContacts,
|
||||||
blockedKeysRef,
|
blockedKeysRef,
|
||||||
blockedNamesRef,
|
blockedNamesRef,
|
||||||
|
channelsRef,
|
||||||
activeConversationRef,
|
activeConversationRef,
|
||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
@@ -586,6 +613,7 @@ export function App() {
|
|||||||
onRunTracePath: api.requestRadioTrace,
|
onRunTracePath: api.requestRadioTrace,
|
||||||
onPathDiscovery: handlePathDiscovery,
|
onPathDiscovery: handlePathDiscovery,
|
||||||
onToggleFavorite: handleToggleFavorite,
|
onToggleFavorite: handleToggleFavorite,
|
||||||
|
onToggleMute: handleToggleMute,
|
||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
onDeleteChannel: handleDeleteChannel,
|
onDeleteChannel: handleDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||||
|
|||||||
+9
-2
@@ -96,6 +96,7 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
|
||||||
setPrivateKey: (privateKey: string) =>
|
setPrivateKey: (privateKey: string) =>
|
||||||
fetchJson<{ status: string }>('/radio/private-key', {
|
fetchJson<{ status: string }>('/radio/private-key', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -157,10 +158,10 @@ export const api = {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ public_keys: publicKeys }),
|
body: JSON.stringify({ public_keys: publicKeys }),
|
||||||
}),
|
}),
|
||||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean, type?: number) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
|
body: JSON.stringify({ public_key: publicKey, name, type, try_historical: tryHistorical }),
|
||||||
}),
|
}),
|
||||||
markContactRead: (publicKey: string) =>
|
markContactRead: (publicKey: string) =>
|
||||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||||
@@ -343,6 +344,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ type, id }),
|
body: JSON.stringify({ type, id }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
toggleChannelMute: (key: string) =>
|
||||||
|
fetchJson<{ key: string; muted: boolean }>('/settings/muted-channels/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Fanout
|
// Fanout
|
||||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||||
createFanoutConfig: (config: {
|
createFanoutConfig: (config: {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
} from 'react';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
import { useSwipeable } from 'react-swipeable';
|
||||||
|
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
@@ -140,6 +148,26 @@ export function AppShell({
|
|||||||
crackerMounted.current = true;
|
crackerMounted.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position toasts below the conversation header when in chat, otherwise below the status bar
|
||||||
|
const TOAST_TOP_PADDING = 10;
|
||||||
|
const [toastTopOffset, setToastTopOffset] = useState<number | undefined>(undefined);
|
||||||
|
const hasLocalLabel = !!localLabel.text;
|
||||||
|
const activeType = conversationPaneProps.activeConversation?.type;
|
||||||
|
const activeId = conversationPaneProps.activeConversation?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
const measure = () => {
|
||||||
|
const anchor =
|
||||||
|
document.querySelector('[data-toast-anchor="conversation"]') ??
|
||||||
|
document.querySelector('[data-toast-anchor="statusbar"]');
|
||||||
|
setToastTopOffset(
|
||||||
|
anchor ? anchor.getBoundingClientRect().top + TOAST_TOP_PADDING : undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => window.removeEventListener('resize', measure);
|
||||||
|
}, [hasLocalLabel, activeType, activeId, showSettings]);
|
||||||
|
|
||||||
const settingsSidebarContent = (
|
const settingsSidebarContent = (
|
||||||
<nav
|
<nav
|
||||||
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
||||||
@@ -220,6 +248,7 @@ export function AppShell({
|
|||||||
onSettingsClick={onToggleSettingsView}
|
onSettingsClick={onToggleSettingsView}
|
||||||
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
||||||
/>
|
/>
|
||||||
|
<div data-toast-anchor="statusbar" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||||
@@ -344,7 +373,11 @@ export function AppShell({
|
|||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||||
<Toaster position="top-right" />
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
offset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
mobileOffset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, BellOff, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
@@ -32,6 +32,7 @@ interface ChatHeaderProps {
|
|||||||
onTogglePush?: () => void;
|
onTogglePush?: () => void;
|
||||||
onOpenPushSettings?: () => void;
|
onOpenPushSettings?: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
|
onToggleMute?: (key: string) => void;
|
||||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||||
onDeleteChannel: (key: string) => void;
|
onDeleteChannel: (key: string) => void;
|
||||||
@@ -57,6 +58,7 @@ export function ChatHeader({
|
|||||||
onTogglePush,
|
onTogglePush,
|
||||||
onOpenPushSettings,
|
onOpenPushSettings,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
|
onToggleMute,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
onSetChannelPathHashModeOverride,
|
onSetChannelPathHashModeOverride,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
@@ -313,95 +315,125 @@ export function ChatHeader({
|
|||||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
|
{(notificationsSupported ||
|
||||||
<div className="relative" ref={notifDropdownRef}>
|
pushSupported ||
|
||||||
<button
|
(conversation.type === 'channel' && onToggleMute)) &&
|
||||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
!activeContactIsRoomServer && (
|
||||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
<div className="relative" ref={notifDropdownRef}>
|
||||||
title="Notification settings"
|
<button
|
||||||
aria-label="Notification settings"
|
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
aria-expanded={notifDropdownOpen}
|
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||||
>
|
title="Notification settings"
|
||||||
<Bell
|
aria-label="Notification settings"
|
||||||
className={cn(
|
aria-expanded={notifDropdownOpen}
|
||||||
'h-4 w-4',
|
>
|
||||||
notificationsEnabled || pushEnabledForConversation
|
{activeChannel?.muted ? (
|
||||||
? 'text-primary'
|
<BellOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
: 'text-muted-foreground'
|
) : (
|
||||||
|
<Bell
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
notificationsEnabled || pushEnabledForConversation
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
fill={
|
||||||
|
notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
|
</button>
|
||||||
aria-hidden="true"
|
{notifDropdownOpen && (
|
||||||
/>
|
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
||||||
</button>
|
{notificationsSupported && (
|
||||||
{notifDropdownOpen && (
|
|
||||||
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
|
||||||
{notificationsSupported && (
|
|
||||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
|
||||||
checked={notificationsEnabled}
|
|
||||||
disabled={notificationsPermission === 'denied'}
|
|
||||||
onChange={onToggleNotifications}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
|
||||||
Desktop notifications (legacy)
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
|
||||||
{notificationsPermission === 'denied'
|
|
||||||
? 'Blocked by browser — check site permissions'
|
|
||||||
: 'Alerts while this tab is open'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{pushSupported && onTogglePush && (
|
|
||||||
<>
|
|
||||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||||
checked={!!pushEnabledForConversation}
|
checked={notificationsEnabled}
|
||||||
onChange={onTogglePush}
|
disabled={notificationsPermission === 'denied'}
|
||||||
|
onChange={onToggleNotifications}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||||
Web Push (beta testing)
|
Desktop notifications (legacy)
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
{pushSubscribed
|
{notificationsPermission === 'denied'
|
||||||
? 'Alerts even when the browser is closed'
|
? 'Blocked by browser — check site permissions'
|
||||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
: 'Alerts while this tab is open'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
)}
|
||||||
All notification types require a trusted HTTPS context. Depending on your
|
{pushSupported && onTogglePush && (
|
||||||
browser, a snakeoil certificate may not be sufficient.
|
<>
|
||||||
</span>
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
{onOpenPushSettings && (
|
<input
|
||||||
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
type="checkbox"
|
||||||
Manage Web Push enabled devices in{' '}
|
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||||
<button
|
checked={!!pushEnabledForConversation}
|
||||||
type="button"
|
onChange={onTogglePush}
|
||||||
onClick={() => {
|
/>
|
||||||
setNotifDropdownOpen(false);
|
<div className="min-w-0">
|
||||||
onOpenPushSettings();
|
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||||
}}
|
Web Push (beta testing)
|
||||||
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
</span>
|
||||||
>
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
Settings → Local
|
{pushSubscribed
|
||||||
</button>
|
? 'Alerts even when the browser is closed'
|
||||||
.
|
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||||
</p>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</>
|
</label>
|
||||||
)}
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
</div>
|
All notification types require a trusted HTTPS context. Depending on your
|
||||||
)}
|
browser, a snakeoil certificate may not be sufficient.
|
||||||
</div>
|
</span>
|
||||||
)}
|
{onOpenPushSettings && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
||||||
|
Manage Web Push enabled devices in{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNotifDropdownOpen(false);
|
||||||
|
onOpenPushSettings();
|
||||||
|
}}
|
||||||
|
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
Settings → Local
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{conversation.type === 'channel' && onToggleMute && (
|
||||||
|
<>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||||
|
checked={!!activeChannel?.muted}
|
||||||
|
onChange={() => onToggleMute(conversation.id)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||||
|
Mute channel
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
|
Hide unread counts and suppress all notifications
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||||
<button
|
<button
|
||||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface ConversationPaneProps {
|
|||||||
) => Promise<RadioTraceResponse>;
|
) => Promise<RadioTraceResponse>;
|
||||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||||
|
onToggleMute: (key: string) => Promise<void>;
|
||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
onDeleteChannel: (key: string) => Promise<void>;
|
onDeleteChannel: (key: string) => Promise<void>;
|
||||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||||
@@ -143,6 +144,7 @@ export function ConversationPane({
|
|||||||
onRunTracePath,
|
onRunTracePath,
|
||||||
onPathDiscovery,
|
onPathDiscovery,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
|
onToggleMute,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
@@ -307,6 +309,7 @@ export function ConversationPane({
|
|||||||
onPathDiscovery={onPathDiscovery}
|
onPathDiscovery={onPathDiscovery}
|
||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onToggleMute={onToggleMute}
|
||||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||||
onDeleteChannel={onDeleteChannel}
|
onDeleteChannel={onDeleteChannel}
|
||||||
@@ -323,6 +326,7 @@ export function ConversationPane({
|
|||||||
{activeContactIsRoom && activeContact && (
|
{activeContactIsRoom && activeContact && (
|
||||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||||
)}
|
)}
|
||||||
|
{showRoomChat && <div data-toast-anchor="conversation" aria-hidden="true" />}
|
||||||
{showRoomChat && (
|
{showRoomChat && (
|
||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ interface NewMessageModalProps {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
} | null;
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (
|
||||||
|
name: string,
|
||||||
|
publicKey: string,
|
||||||
|
tryHistorical: boolean,
|
||||||
|
type?: number
|
||||||
|
) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -91,6 +96,7 @@ export function NewMessageModal({
|
|||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [contactType, setContactType] = useState(1);
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
@@ -103,6 +109,7 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
|
setContactType(1);
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
setBulkChannelText('');
|
setBulkChannelText('');
|
||||||
@@ -161,7 +168,7 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical, contactType);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Channel name and key are required');
|
setError('Channel name and key are required');
|
||||||
@@ -293,6 +300,19 @@ export function NewMessageModal({
|
|||||||
placeholder="64-character hex public key"
|
placeholder="64-character hex public key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contact-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="contact-type"
|
||||||
|
value={contactType}
|
||||||
|
onChange={(e) => setContactType(Number(e.target.value))}
|
||||||
|
className="block h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value={1}>Client</option>
|
||||||
|
<option value={2}>Repeater</option>
|
||||||
|
<option value={3}>Room Server</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<div data-toast-anchor="conversation" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
BellOff,
|
||||||
Cable,
|
Cable,
|
||||||
ChartNetwork,
|
ChartNetwork,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
@@ -49,6 +50,7 @@ type ConversationRow = {
|
|||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
isMention: boolean;
|
isMention: boolean;
|
||||||
notificationsEnabled: boolean;
|
notificationsEnabled: boolean;
|
||||||
|
muted?: boolean;
|
||||||
contact?: Contact;
|
contact?: Contact;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,6 +252,10 @@ export function Sidebar({
|
|||||||
if (isPublicChannelKey(a.key)) return -1;
|
if (isPublicChannelKey(a.key)) return -1;
|
||||||
if (isPublicChannelKey(b.key)) return 1;
|
if (isPublicChannelKey(b.key)) return 1;
|
||||||
|
|
||||||
|
// Muted channels always sort to the bottom
|
||||||
|
if (a.muted && !b.muted) return 1;
|
||||||
|
if (!a.muted && b.muted) return -1;
|
||||||
|
|
||||||
if (sectionSortOrders.channels === 'recent') {
|
if (sectionSortOrders.channels === 'recent') {
|
||||||
const timeA = getLastMessageTime('channel', a.key);
|
const timeA = getLastMessageTime('channel', a.key);
|
||||||
const timeB = getLastMessageTime('channel', b.key);
|
const timeB = getLastMessageTime('channel', b.key);
|
||||||
@@ -530,9 +536,10 @@ export function Sidebar({
|
|||||||
type: 'channel',
|
type: 'channel',
|
||||||
id: channel.key,
|
id: channel.key,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
unreadCount: getUnreadCount('channel', channel.key),
|
unreadCount: channel.muted ? 0 : getUnreadCount('channel', channel.key),
|
||||||
isMention: hasMention('channel', channel.key),
|
isMention: channel.muted ? false : hasMention('channel', channel.key),
|
||||||
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
||||||
|
muted: channel.muted,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
||||||
@@ -584,23 +591,31 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
||||||
<span className="ml-auto flex items-center gap-1">
|
<span className="ml-auto flex items-center gap-1">
|
||||||
{row.notificationsEnabled && (
|
{row.muted ? (
|
||||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
<span aria-label="Channel muted" title="Channel muted">
|
||||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : (
|
||||||
{row.unreadCount > 0 && (
|
<>
|
||||||
<span
|
{row.notificationsEnabled && (
|
||||||
className={cn(
|
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||||
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
highlightUnread
|
</span>
|
||||||
? 'bg-badge-mention text-badge-mention-foreground'
|
|
||||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
|
||||||
)}
|
)}
|
||||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
{row.unreadCount > 0 && (
|
||||||
>
|
<span
|
||||||
{row.unreadCount}
|
className={cn(
|
||||||
</span>
|
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||||
|
highlightUnread
|
||||||
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
|
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||||
|
)}
|
||||||
|
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
{row.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
|
import { useMemo } from 'react';
|
||||||
|
import { RepeaterPane, NotFetched, LppSensorRow, formatLppLabel } from './repeaterPaneShared';
|
||||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||||
|
|
||||||
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { distanceUnit } = useDistanceUnit();
|
const { distanceUnit } = useDistanceUnit();
|
||||||
|
|
||||||
|
// Build disambiguated labels matching the telemetry history chart names
|
||||||
|
const labels = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return data.sensors.map((s) => {
|
||||||
|
const base = `${s.type_name}_${s.channel}`;
|
||||||
|
const n = (counts.get(base) ?? 0) + 1;
|
||||||
|
counts.set(base, n);
|
||||||
|
return formatLppLabel(s.type_name) + ` Ch${s.channel}` + (n > 1 ? ` (${n})` : '');
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{data.sensors.map((sensor, i) => (
|
{data.sensors.map((sensor, i) => (
|
||||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
|
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,9 +37,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
|
|||||||
// Stable color rotation for dynamic LPP sensors
|
// Stable color rotation for dynamic LPP sensors
|
||||||
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
||||||
|
|
||||||
/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */
|
/** Assign disambiguated flat keys to an array of LPP sensors.
|
||||||
function lppKey(s: TelemetryLppSensor): string {
|
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
|
||||||
return `lpp_${s.type_name}_ch${s.channel}`;
|
function assignLppKeys(
|
||||||
|
sensors: TelemetryLppSensor[]
|
||||||
|
): { sensor: TelemetryLppSensor; key: string; occurrence: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return sensors.map((s) => {
|
||||||
|
const base = `lpp_${s.type_name}_ch${s.channel}`;
|
||||||
|
const n = (counts.get(base) ?? 0) + 1;
|
||||||
|
counts.set(base, n);
|
||||||
|
return { sensor: s, key: n === 1 ? base : `${base}_${n}`, occurrence: n };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLTIP_STYLE = {
|
const TOOLTIP_STYLE = {
|
||||||
@@ -93,11 +102,10 @@ export function TelemetryHistoryPane({
|
|||||||
|
|
||||||
// Discover unique LPP sensors across all history entries
|
// Discover unique LPP sensors across all history entries
|
||||||
const lppMetrics = useMemo(() => {
|
const lppMetrics = useMemo(() => {
|
||||||
const seen = new Map<string, { type_name: string; channel: number }>();
|
const seen = new Map<string, { type_name: string; channel: number; occurrence: number }>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
for (const s of e.data.lpp_sensors ?? []) {
|
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
|
||||||
const k = lppKey(s);
|
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
|
||||||
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
||||||
@@ -106,7 +114,8 @@ export function TelemetryHistoryPane({
|
|||||||
const label =
|
const label =
|
||||||
info.type_name.charAt(0).toUpperCase() +
|
info.type_name.charAt(0).toUpperCase() +
|
||||||
info.type_name.slice(1).replace(/_/g, ' ') +
|
info.type_name.slice(1).replace(/_/g, ' ') +
|
||||||
` Ch${info.channel}`;
|
` Ch${info.channel}` +
|
||||||
|
(info.occurrence > 1 ? ` (${info.occurrence})` : '');
|
||||||
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
||||||
result.push({
|
result.push({
|
||||||
key: k,
|
key: k,
|
||||||
@@ -148,9 +157,9 @@ export function TelemetryHistoryPane({
|
|||||||
uptime_seconds: d.uptime_seconds,
|
uptime_seconds: d.uptime_seconds,
|
||||||
};
|
};
|
||||||
// Flatten LPP sensors into the point, converting units as needed
|
// Flatten LPP sensors into the point, converting units as needed
|
||||||
for (const s of d.lpp_sensors ?? []) {
|
for (const { sensor: s, key } of assignLppKeys(d.lpp_sensors ?? [])) {
|
||||||
if (typeof s.value === 'number') {
|
if (typeof s.value === 'number') {
|
||||||
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return point;
|
return point;
|
||||||
|
|||||||
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
|
|||||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
|
export function LppSensorRow({
|
||||||
const label = formatLppLabel(sensor.type_name);
|
sensor,
|
||||||
|
unitPref,
|
||||||
|
label: labelOverride,
|
||||||
|
}: {
|
||||||
|
sensor: LppSensor;
|
||||||
|
unitPref?: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const label = labelOverride ?? formatLppLabel(sensor.type_name);
|
||||||
|
|
||||||
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
||||||
// Multi-value sensor (GPS, accelerometer, etc.)
|
// Multi-value sensor (GPS, accelerometer, etc.)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
|
|||||||
4: 'Sensor',
|
4: 'Sensor',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleDateString([], {
|
return new Date(ts * 1000).toLocaleDateString([], {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number {
|
|||||||
return Math.floor(d.getTime() / 1000);
|
return Math.floor(d.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
sortField: SortField;
|
||||||
|
sortDir: SortDir;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sortField === field;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`px-3 py-1.5 cursor-pointer select-none hover:text-foreground transition-colors ${className ?? ''}`}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BulkDeleteContactsModalProps {
|
interface BulkDeleteContactsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({
|
|||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [lastHeardAfter, setLastHeardAfter] = useState('');
|
||||||
|
const [lastHeardBefore, setLastHeardBefore] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('first_seen');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const lastClickedKeyRef = useRef<string | null>(null);
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(field === 'name' || field === 'key' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sortField]
|
||||||
|
);
|
||||||
|
|
||||||
const resetAndClose = useCallback(() => {
|
const resetAndClose = useCallback(() => {
|
||||||
setStep('select');
|
setStep('select');
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
setEndDate('');
|
setEndDate('');
|
||||||
|
setLastHeardAfter('');
|
||||||
|
setLastHeardBefore('');
|
||||||
setTypeFilter('all');
|
setTypeFilter('all');
|
||||||
|
setSortField('first_seen');
|
||||||
|
setSortDir('desc');
|
||||||
lastClickedKeyRef.current = null;
|
lastClickedKeyRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
let list = [...contacts];
|
||||||
if (typeFilter !== 'all') {
|
if (typeFilter !== 'all') {
|
||||||
list = list.filter((c) => c.type === typeFilter);
|
list = list.filter((c) => c.type === typeFilter);
|
||||||
}
|
}
|
||||||
@@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({
|
|||||||
const end = datetimeToUnix(endDate);
|
const end = datetimeToUnix(endDate);
|
||||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||||
}
|
}
|
||||||
|
if (lastHeardAfter) {
|
||||||
|
const after = datetimeToUnix(lastHeardAfter);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) >= after);
|
||||||
|
}
|
||||||
|
if (lastHeardBefore) {
|
||||||
|
const before = datetimeToUnix(lastHeardBefore);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) <= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name': {
|
||||||
|
const an = getContactDisplayName(a.name, a.public_key, a.last_advert).toLowerCase();
|
||||||
|
const bn = getContactDisplayName(b.name, b.public_key, b.last_advert).toLowerCase();
|
||||||
|
return an < bn ? -dir : an > bn ? dir : 0;
|
||||||
|
}
|
||||||
|
case 'type':
|
||||||
|
return (a.type - b.type) * dir;
|
||||||
|
case 'key':
|
||||||
|
return a.public_key < b.public_key ? -dir : a.public_key > b.public_key ? dir : 0;
|
||||||
|
case 'first_seen':
|
||||||
|
return ((a.first_seen ?? 0) - (b.first_seen ?? 0)) * dir;
|
||||||
|
case 'last_seen':
|
||||||
|
return ((a.last_seen ?? 0) - (b.last_seen ?? 0)) * dir;
|
||||||
|
}
|
||||||
|
});
|
||||||
return list;
|
return list;
|
||||||
}, [contacts, typeFilter, startDate, endDate]);
|
}, [
|
||||||
|
contacts,
|
||||||
|
typeFilter,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
lastHeardAfter,
|
||||||
|
lastHeardBefore,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||||
@@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||||
@@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
{step === 'select' && (
|
{step === 'select' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Show</label>
|
<div className="space-y-1">
|
||||||
<select
|
<label className="text-xs text-muted-foreground">Show</label>
|
||||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
<select
|
||||||
onChange={(e) =>
|
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
onChange={(e) =>
|
||||||
}
|
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
}
|
||||||
>
|
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
<option value="all">All</option>
|
>
|
||||||
<option value="1">Clients</option>
|
<option value="all">All</option>
|
||||||
<option value="2">Repeaters</option>
|
<option value="1">Clients</option>
|
||||||
<option value="3">Room Servers</option>
|
<option value="2">Repeaters</option>
|
||||||
<option value="4">Sensors</option>
|
<option value="3">Room Servers</option>
|
||||||
</select>
|
<option value="4">Sensors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created after</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Created after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={startDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
value={startDate}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created before</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Last heard after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={endDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
value={lastHeardAfter}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setLastHeardAfter(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Last heard before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={lastHeardBefore}
|
||||||
|
onChange={(e) => setLastHeardBefore(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
@@ -211,7 +322,7 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||||
{(startDate || endDate) && ' (filtered)'}
|
{hasFilters && ' (filtered)'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{selectedKeys.size} selected
|
{selectedKeys.size} selected
|
||||||
</div>
|
</div>
|
||||||
@@ -219,17 +330,51 @@ export function BulkDeleteContactsModal({
|
|||||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
{filteredContacts.length === 0 ? (
|
{filteredContacts.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
No contacts match the selected date range.
|
No contacts match the selected filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
<tr className="text-left text-xs text-muted-foreground">
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
<th className="px-3 py-1.5 w-8" />
|
<th className="px-3 py-1.5 w-8" />
|
||||||
<th className="px-3 py-1.5">Name</th>
|
<SortableHeader
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
label="Name"
|
||||||
<th className="px-3 py-1.5">Key</th>
|
field="name"
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Type"
|
||||||
|
field="type"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Key"
|
||||||
|
field="key"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Created"
|
||||||
|
field="first_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Last heard"
|
||||||
|
field="last_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -265,6 +410,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -298,6 +446,7 @@ export function BulkDeleteContactsModal({
|
|||||||
<th className="px-3 py-1.5">Type</th>
|
<th className="px-3 py-1.5">Type</th>
|
||||||
<th className="px-3 py-1.5">Key</th>
|
<th className="px-3 py-1.5">Key</th>
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Last heard</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -315,6 +464,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import { ChevronDown, Info } from 'lucide-react';
|
import { ChevronDown, Info } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
@@ -278,7 +287,9 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
config: {
|
config: {
|
||||||
urls: '',
|
urls: '',
|
||||||
preserve_identity: true,
|
preserve_identity: true,
|
||||||
include_path: true,
|
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
|
body_format_channel:
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
},
|
},
|
||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
@@ -1194,7 +1205,7 @@ function MqttHaConfigEditor({
|
|||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
Published Topic Summary
|
Published topic summary
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -2376,6 +2387,91 @@ function ScopeSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||||
|
const APPRISE_DEFAULT_CHANNEL =
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS: Record<string, string> = {
|
||||||
|
type: 'CHAN',
|
||||||
|
text: 'hello world',
|
||||||
|
sender_name: 'Alice',
|
||||||
|
sender_key: 'a1b2c3d4e5f6',
|
||||||
|
channel_name: '#general',
|
||||||
|
conversation_key: 'abcdef1234567890',
|
||||||
|
hops: '2a, 3b',
|
||||||
|
hops_backticked: '`2a`, `3b`',
|
||||||
|
hop_count: '2',
|
||||||
|
rssi: '-95',
|
||||||
|
snr: '6.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
|
||||||
|
...APPRISE_SAMPLE_VARS,
|
||||||
|
type: 'PRIV',
|
||||||
|
channel_name: '',
|
||||||
|
conversation_key: 'a1b2c3d4e5f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
|
||||||
|
let result = fmt;
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
result = result.split(`{${key}}`).join(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a markdown-ish string into inline React elements (bold + code spans). */
|
||||||
|
function appriseRenderMarkdown(s: string): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
// Split on **bold** and `code` spans
|
||||||
|
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={key++} className="font-bold">
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
nodes.push(
|
||||||
|
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else if (part) {
|
||||||
|
nodes.push(<span key={key++}>{part}</span>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
|
||||||
|
const raw = appriseApplyFormat(format, vars);
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Rendered (Discord, Slack)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Raw (Telegram, email)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
|
||||||
|
if (value == null) return true;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === '' || s === defaultStr;
|
||||||
|
}
|
||||||
|
|
||||||
function AppriseConfigEditor({
|
function AppriseConfigEditor({
|
||||||
config,
|
config,
|
||||||
scope,
|
scope,
|
||||||
@@ -2387,6 +2483,10 @@ function AppriseConfigEditor({
|
|||||||
onChange: (config: Record<string, unknown>) => void;
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const dmFormat = ((config.body_format_dm as string) || '').trim() || APPRISE_DEFAULT_DM;
|
||||||
|
const chanFormat =
|
||||||
|
((config.body_format_channel as string) || '').trim() || APPRISE_DEFAULT_CHANNEL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<Separator />
|
||||||
<input
|
|
||||||
type="checkbox"
|
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||||
checked={config.include_path !== false}
|
|
||||||
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
|
<details className="group">
|
||||||
className="h-4 w-4 rounded border-border"
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
|
Available variables
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
|
||||||
|
<span className="text-muted-foreground">Message body</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender display name</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender public key (hex)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{channel_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Channel name (channel messages only)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{conversation_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Contact pubkey (DM) or channel key (channel)
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
|
||||||
|
<span className="text-muted-foreground">PRIV or CHAN</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Comma-separated hop IDs, or "direct"
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hops_backticked}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hop_count}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop SNR in dB</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
|
||||||
|
{!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset DM format to default"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onChange({ ...config, body_format_dm: APPRISE_DEFAULT_DM })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="fanout-apprise-fmt-dm"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||||
|
placeholder={APPRISE_DEFAULT_DM}
|
||||||
|
value={(config.body_format_dm as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
|
||||||
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Include routing path in notifications</span>
|
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
|
||||||
|
{!appriseIsDefault(config.body_format_channel, APPRISE_DEFAULT_CHANNEL) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset channel format to default"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onChange({ ...config, body_format_channel: APPRISE_DEFAULT_CHANNEL })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="fanout-apprise-fmt-chan"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||||
|
placeholder={APPRISE_DEFAULT_CHANNEL}
|
||||||
|
value={(config.body_format_channel as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { MapPinned } from 'lucide-react';
|
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
import { api } from '../../api';
|
||||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||||
import type {
|
import type {
|
||||||
@@ -17,8 +26,116 @@ import type {
|
|||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
RadioDiscoveryTarget,
|
RadioDiscoveryTarget,
|
||||||
|
RadioStatsSnapshot,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
|
function formatUptime(secs: number): string {
|
||||||
|
const days = Math.floor(secs / 86400);
|
||||||
|
const hours = Math.floor((secs % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((secs % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAirtime(secs: number): string {
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const hours = Math.floor(secs / 3600);
|
||||||
|
const minutes = Math.floor((secs % 3600) / 60);
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 py-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono tabular-nums ${warn ? 'text-warning font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) {
|
||||||
|
const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null;
|
||||||
|
const packets = {
|
||||||
|
recv: stats.packets_recv,
|
||||||
|
sent: stats.packets_sent,
|
||||||
|
flood_tx: stats.flood_tx,
|
||||||
|
direct_tx: stats.direct_tx,
|
||||||
|
flood_rx: stats.flood_rx,
|
||||||
|
direct_rx: stats.direct_rx,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
|
Radio Details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||||
|
{age !== null && (
|
||||||
|
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Updated {age < 5 ? 'just now' : `${age}s ago`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Core */}
|
||||||
|
{stats.uptime_secs != null && (
|
||||||
|
<StatRow label="Uptime" value={formatUptime(stats.uptime_secs)} />
|
||||||
|
)}
|
||||||
|
{stats.battery_mv != null && stats.battery_mv > 0 && (
|
||||||
|
<StatRow label="Battery" value={`${(stats.battery_mv / 1000).toFixed(2)}V`} />
|
||||||
|
)}
|
||||||
|
{stats.queue_len != null && (
|
||||||
|
<StatRow
|
||||||
|
label="TX Queue"
|
||||||
|
value={`${stats.queue_len} / 16`}
|
||||||
|
warn={stats.queue_len >= 14}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stats.errors != null && (
|
||||||
|
<StatRow label="Errors" value={String(stats.errors)} warn={stats.errors > 0} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RF */}
|
||||||
|
{stats.noise_floor != null && (
|
||||||
|
<StatRow label="Noise Floor" value={`${stats.noise_floor} dBm`} />
|
||||||
|
)}
|
||||||
|
{stats.last_rssi != null && <StatRow label="Last RSSI" value={`${stats.last_rssi} dBm`} />}
|
||||||
|
{stats.last_snr != null && <StatRow label="Last SNR" value={`${stats.last_snr} dB`} />}
|
||||||
|
|
||||||
|
{/* Airtime */}
|
||||||
|
{(stats.tx_air_secs != null || stats.rx_air_secs != null) && (
|
||||||
|
<>
|
||||||
|
{stats.tx_air_secs != null && (
|
||||||
|
<StatRow label="TX Airtime" value={formatAirtime(stats.tx_air_secs)} />
|
||||||
|
)}
|
||||||
|
{stats.rx_air_secs != null && (
|
||||||
|
<StatRow label="RX Airtime" value={formatAirtime(stats.rx_air_secs)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Packets */}
|
||||||
|
{packets.recv != null && <StatRow label="Packets Received" value={String(packets.recv)} />}
|
||||||
|
{packets.sent != null && <StatRow label="Packets Sent" value={String(packets.sent)} />}
|
||||||
|
{packets.flood_tx != null && <StatRow label="Flood TX" value={String(packets.flood_tx)} />}
|
||||||
|
{packets.flood_rx != null && <StatRow label="Flood RX" value={String(packets.flood_rx)} />}
|
||||||
|
{packets.direct_tx != null && (
|
||||||
|
<StatRow label="Direct TX" value={String(packets.direct_tx)} />
|
||||||
|
)}
|
||||||
|
{packets.direct_rx != null && (
|
||||||
|
<StatRow label="Direct RX" value={String(packets.direct_rx)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsRadioSection({
|
export function SettingsRadioSection({
|
||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
@@ -279,11 +396,6 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const update: AppSettingsUpdate = {};
|
const update: AppSettingsUpdate = {};
|
||||||
const hours = parseInt(advertIntervalHours, 10);
|
|
||||||
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
|
||||||
if (newAdvertInterval !== appSettings.advert_interval) {
|
|
||||||
update.advert_interval = newAdvertInterval;
|
|
||||||
}
|
|
||||||
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
||||||
update.flood_scope = floodScope;
|
update.flood_scope = floodScope;
|
||||||
}
|
}
|
||||||
@@ -302,6 +414,27 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [advertIntervalBusy, setAdvertIntervalBusy] = useState(false);
|
||||||
|
const [advertIntervalError, setAdvertIntervalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSaveAdvertInterval = async () => {
|
||||||
|
setAdvertIntervalError(null);
|
||||||
|
setAdvertIntervalBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hours = parseInt(advertIntervalHours, 10);
|
||||||
|
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
||||||
|
if (newAdvertInterval !== appSettings.advert_interval) {
|
||||||
|
await onSaveAppSettings({ advert_interval: newAdvertInterval });
|
||||||
|
}
|
||||||
|
toast.success('Advertising interval saved');
|
||||||
|
} catch (err) {
|
||||||
|
setAdvertIntervalError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setAdvertIntervalBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
||||||
setAdvertisingMode(mode);
|
setAdvertisingMode(mode);
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +453,169 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
|
||||||
|
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
const buildConfigProfile = () => ({
|
||||||
|
version: 1,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
name: config.name,
|
||||||
|
lat: config.lat,
|
||||||
|
lon: config.lon,
|
||||||
|
tx_power: config.tx_power,
|
||||||
|
radio: { ...config.radio },
|
||||||
|
path_hash_mode: config.path_hash_mode,
|
||||||
|
advert_location_source: config.advert_location_source ?? 'current',
|
||||||
|
multi_acks_enabled: config.multi_acks_enabled ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadJson = (profile: object, suffix: string) => {
|
||||||
|
const blob = new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeName = (config.name || 'radio').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.replace(/[/:, ]+/g, '-');
|
||||||
|
a.download = `${safeName}-${suffix}-${timestamp}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportConfig = async () => {
|
||||||
|
const profile = buildConfigProfile();
|
||||||
|
try {
|
||||||
|
const { private_key } = await api.getPrivateKey();
|
||||||
|
downloadJson({ ...profile, private_key }, 'config');
|
||||||
|
toast.success('Export generated with private key');
|
||||||
|
} catch {
|
||||||
|
downloadJson(profile, 'config');
|
||||||
|
toast.info('Export generated without private key', {
|
||||||
|
description: 'See README_ADVANCED.md for private key export enable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateImportData = (
|
||||||
|
data: unknown
|
||||||
|
): data is {
|
||||||
|
name: string;
|
||||||
|
radio: { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
[k: string]: unknown;
|
||||||
|
} =>
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'name' in data &&
|
||||||
|
typeof (data as Record<string, unknown>).name === 'string' &&
|
||||||
|
'radio' in data &&
|
||||||
|
typeof (data as Record<string, unknown>).radio === 'object' &&
|
||||||
|
(data as Record<string, unknown>).radio !== null &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.freq === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.bw === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.sf === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.cr === 'number';
|
||||||
|
|
||||||
|
const populateFormFromImport = (data: Record<string, unknown>) => {
|
||||||
|
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
setName(data.name as string);
|
||||||
|
if (typeof data.lat === 'number') setLat(String(data.lat));
|
||||||
|
if (typeof data.lon === 'number') setLon(String(data.lon));
|
||||||
|
if (typeof data.tx_power === 'number') setTxPower(String(data.tx_power));
|
||||||
|
setFreq(String(radio.freq));
|
||||||
|
setBw(String(radio.bw));
|
||||||
|
setSf(String(radio.sf));
|
||||||
|
setCr(String(radio.cr));
|
||||||
|
if (typeof data.path_hash_mode === 'number') setPathHashMode(String(data.path_hash_mode));
|
||||||
|
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||||
|
setAdvertLocationSource(data.advert_location_source);
|
||||||
|
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
|
||||||
|
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
const update: RadioConfigUpdate = {
|
||||||
|
name: data.name as string,
|
||||||
|
lat: typeof data.lat === 'number' ? data.lat : config.lat,
|
||||||
|
lon: typeof data.lon === 'number' ? data.lon : config.lon,
|
||||||
|
tx_power: typeof data.tx_power === 'number' ? (data.tx_power as number) : config.tx_power,
|
||||||
|
radio,
|
||||||
|
};
|
||||||
|
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||||
|
update.advert_location_source = data.advert_location_source;
|
||||||
|
if (typeof data.multi_acks_enabled === 'boolean')
|
||||||
|
update.multi_acks_enabled = data.multi_acks_enabled;
|
||||||
|
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
|
||||||
|
update.path_hash_mode = data.path_hash_mode as number;
|
||||||
|
return update;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyImport = async (data: Record<string, unknown>) => {
|
||||||
|
populateFormFromImport(data);
|
||||||
|
const update = buildUpdateFromImport(data);
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setRebooting(true);
|
||||||
|
try {
|
||||||
|
if (typeof data.private_key === 'string' && data.private_key) {
|
||||||
|
await onSetPrivateKey(data.private_key);
|
||||||
|
toast.success('Config + private key imported, saving & rebooting...');
|
||||||
|
} else {
|
||||||
|
toast.success('Config imported, saving & rebooting...');
|
||||||
|
}
|
||||||
|
await onSave(update);
|
||||||
|
await onReboot();
|
||||||
|
if (!pageMode) onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||||
|
} finally {
|
||||||
|
setRebooting(false);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!validateImportData(data)) {
|
||||||
|
toast.error('Invalid config file', {
|
||||||
|
description: 'File must contain name and radio parameters (freq, bw, sf, cr)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.private_key === 'string' && data.private_key) {
|
||||||
|
// Private key present — show warning dialog before applying
|
||||||
|
pendingImportRef.current = data;
|
||||||
|
setKeyImportDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
await applyImport(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||||
|
} finally {
|
||||||
|
if (importInputRef.current) importInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmKeyImport = async () => {
|
||||||
|
setKeyImportDialogOpen(false);
|
||||||
|
const data = pendingImportRef.current;
|
||||||
|
pendingImportRef.current = null;
|
||||||
|
if (data) await applyImport(data);
|
||||||
|
};
|
||||||
|
|
||||||
const radioState =
|
const radioState =
|
||||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||||
const connectionActionLabel =
|
const connectionActionLabel =
|
||||||
@@ -414,6 +710,9 @@ export function SettingsRadioSection({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
||||||
|
|
||||||
|
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -678,6 +977,37 @@ export function SettingsRadioSection({
|
|||||||
Some settings may require a reboot to take effect on some radios.
|
Some settings may require a reboot to take effect on some radios.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportConfig} className="flex-1">
|
||||||
|
<Download className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
Export Config
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
disabled={busy || rebooting}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Upload className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
Import & Reboot
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImportConfig(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
Export saves the current server config to a JSON file. Import loads a config file, applies
|
||||||
|
it, and reboots the radio.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* ── Messaging ── */}
|
{/* ── Messaging ── */}
|
||||||
@@ -733,9 +1063,9 @@ export function SettingsRadioSection({
|
|||||||
placeholder="MyRegion"
|
placeholder="MyRegion"
|
||||||
/>
|
/>
|
||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
|
||||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
region can forward the traffic, while repeaters configured to deny other regions may drop
|
||||||
drop it. Leave empty to disable.
|
it. Leave empty to disable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -795,6 +1125,18 @@ export function SettingsRadioSection({
|
|||||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||||
Recommended: 24 hours or higher.
|
Recommended: 24 hours or higher.
|
||||||
</p>
|
</p>
|
||||||
|
{advertIntervalError && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{advertIntervalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAdvertInterval}
|
||||||
|
disabled={advertIntervalBusy}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{advertIntervalBusy ? 'Saving...' : 'Save Advertising Interval'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -907,6 +1249,44 @@ export function SettingsRadioSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Private Key Import Warning ── */}
|
||||||
|
<Dialog
|
||||||
|
open={keyImportDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setKeyImportDialogOpen(open);
|
||||||
|
if (!open) pendingImportRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import includes Private Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This config file contains a private key. Importing it will change your radio's
|
||||||
|
identity — your radio will have a new public key and other nodes will see it as
|
||||||
|
a different device. This cannot be undone without the original key.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setKeyImportDialogOpen(false);
|
||||||
|
pendingImportRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmKeyImport}
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Import Config & Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function useContactsAndChannels({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateContact = useCallback(
|
const handleCreateContact = useCallback(
|
||||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
async (name: string, publicKey: string, tryHistorical: boolean, type?: number) => {
|
||||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
const created = await api.createContact(publicKey, name || undefined, tryHistorical, type);
|
||||||
const data = await fetchAllContacts();
|
const data = await fetchAllContacts();
|
||||||
setContacts(data);
|
setContacts(data);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${label} timed out — the service worker may have failed to install. ` +
|
||||||
|
'Mobile browsers require a trusted TLS certificate for service workers, ' +
|
||||||
|
'even if the page itself loads with a self-signed cert.'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
|
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
|
||||||
if (!a || a.length !== b.length) return false;
|
if (!a || a.length !== b.length) return false;
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
||||||
|
|
||||||
// Check if THIS browser has an active push subscription and match it
|
// Check if THIS browser has an active push subscription and match it
|
||||||
// to a backend record.
|
// to a backend record. Use a timeout so we don't hang forever when the
|
||||||
navigator.serviceWorker.ready
|
// service worker failed to install (e.g. mobile + self-signed cert).
|
||||||
|
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
|
||||||
.then((reg) => reg.pushManager.getSubscription())
|
.then((reg) => reg.pushManager.getSubscription())
|
||||||
.then(async (sub) => {
|
.then(async (sub) => {
|
||||||
const existing = await subsPromise;
|
const existing = await subsPromise;
|
||||||
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
const refreshSubscriptions = useCallback(async () => {
|
const refreshSubscriptions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const subs = await api.getPushSubscriptions();
|
const subs = await api.getPushSubscriptions();
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await withTimeout(
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
10_000,
|
||||||
|
'Service worker activation'
|
||||||
|
);
|
||||||
const sub = await reg.pushManager.getSubscription();
|
const sub = await reg.pushManager.getSubscription();
|
||||||
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
||||||
return subs;
|
return subs;
|
||||||
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
vapidKeyRef.current = resp.public_key;
|
vapidKeyRef.current = resp.public_key;
|
||||||
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
|
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
|
||||||
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await withTimeout(
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
3_000,
|
||||||
|
'Service worker activation'
|
||||||
|
);
|
||||||
let pushSub = await reg.pushManager.getSubscription();
|
let pushSub = await reg.pushManager.getSubscription();
|
||||||
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
||||||
const requiresRecreate =
|
const requiresRecreate =
|
||||||
@@ -188,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
console.error('Push subscribe failed:', err);
|
console.error('Push subscribe failed:', err);
|
||||||
toast.error('Failed to enable push notifications', {
|
toast.error('Failed to enable push notifications', {
|
||||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||||
|
duration: 8_000,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface UseRealtimeAppStateArgs {
|
|||||||
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
||||||
blockedKeysRef: MutableRefObject<string[]>;
|
blockedKeysRef: MutableRefObject<string[]>;
|
||||||
blockedNamesRef: MutableRefObject<string[]>;
|
blockedNamesRef: MutableRefObject<string[]>;
|
||||||
|
channelsRef: MutableRefObject<Channel[]>;
|
||||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||||
recordMessageEvent: (args: {
|
recordMessageEvent: (args: {
|
||||||
@@ -94,6 +95,7 @@ export function useRealtimeAppState({
|
|||||||
setContacts,
|
setContacts,
|
||||||
blockedKeysRef,
|
blockedKeysRef,
|
||||||
blockedNamesRef,
|
blockedNamesRef,
|
||||||
|
channelsRef,
|
||||||
activeConversationRef,
|
activeConversationRef,
|
||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
@@ -191,16 +193,24 @@ export function useRealtimeAppState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMutedChannel =
|
||||||
|
msg.type === 'CHAN' &&
|
||||||
|
!!msg.conversation_key &&
|
||||||
|
channelsRef.current.some((c) => c.key === msg.conversation_key && c.muted);
|
||||||
|
|
||||||
const { added: isNewMessage, activeConversation: isForActiveConversation } =
|
const { added: isNewMessage, activeConversation: isForActiveConversation } =
|
||||||
observeMessage(msg);
|
observeMessage(msg);
|
||||||
recordMessageEvent({
|
|
||||||
msg,
|
|
||||||
activeConversation: isForActiveConversation,
|
|
||||||
isNewMessage,
|
|
||||||
hasMention: checkMention(msg.text),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!msg.outgoing && isNewMessage) {
|
if (!isMutedChannel) {
|
||||||
|
recordMessageEvent({
|
||||||
|
msg,
|
||||||
|
activeConversation: isForActiveConversation,
|
||||||
|
isNewMessage,
|
||||||
|
hasMention: checkMention(msg.text),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg.outgoing && isNewMessage && !isMutedChannel) {
|
||||||
notifyIncomingMessage?.(msg);
|
notifyIncomingMessage?.(msg);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
|
|
||||||
// Register service worker for Web Push (requires secure context)
|
// Register service worker for Web Push (requires secure context)
|
||||||
if ('serviceWorker' in navigator && window.isSecureContext) {
|
if ('serviceWorker' in navigator && window.isSecureContext) {
|
||||||
navigator.serviceWorker.register('./sw.js').catch(() => {});
|
navigator.serviceWorker.register('./sw.js').catch((err) => {
|
||||||
|
console.warn('Service worker registration failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ describe('fetchJson (via api methods)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function installMockFetch() {
|
function installMockFetch() {
|
||||||
|
mockFetch.mockReset();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('BulkAddChannelResultModal', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'BB'.repeat(16),
|
key: 'BB'.repeat(16),
|
||||||
@@ -26,6 +27,7 @@ describe('BulkAddChannelResultModal', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
existing_count: 3,
|
existing_count: 3,
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ import { api } from '../api';
|
|||||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||||
|
|
||||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
is_hashtag: isHashtag,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDetail(channel: Channel): ChannelDetail {
|
function makeDetail(channel: Channel): ChannelDetail {
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { CONTACT_TYPE_ROOM } from '../types';
|
|||||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||||
|
|
||||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
is_hashtag: isHashtag,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const channel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
@@ -142,6 +143,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
|||||||
throw new Error('unused');
|
throw new Error('unused');
|
||||||
}),
|
}),
|
||||||
onToggleFavorite: vi.fn(async () => {}),
|
onToggleFavorite: vi.fn(async () => {}),
|
||||||
|
onToggleMute: vi.fn(async () => {}),
|
||||||
onDeleteContact: vi.fn(async () => {}),
|
onDeleteContact: vi.fn(async () => {}),
|
||||||
onDeleteChannel: vi.fn(async () => {}),
|
onDeleteChannel: vi.fn(async () => {}),
|
||||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||||
|
|||||||
@@ -1057,7 +1057,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
selectCreateIntegration('Home Assistant MQTT Discovery');
|
selectCreateIntegration('Home Assistant MQTT Discovery');
|
||||||
confirmCreateIntegration();
|
confirmCreateIntegration();
|
||||||
|
|
||||||
expect(await screen.findByText('Published Topic Summary')).toBeInTheDocument();
|
expect(await screen.findByText('Published topic summary')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByLabelText(/Alice/));
|
fireEvent.click(await screen.findByLabelText(/Alice/));
|
||||||
fireEvent.click(await screen.findByLabelText(/Repeater One/));
|
fireEvent.click(await screen.findByLabelText(/Repeater One/));
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
|
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false, 1);
|
||||||
});
|
});
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const BOT_CHANNEL: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOT_PACKET: RawPacket = {
|
const BOT_PACKET: RawPacket = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const TEST_CHANNEL: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const defaultProps = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onNavigateToMessage: vi.fn(),
|
onNavigateToMessage: vi.fn(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function makeChannel(key: string, name: string): Channel {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ describe('resolveChannelFromHashToken', () => {
|
|||||||
on_radio: true,
|
on_radio: true,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '11111111111111111111111111111111',
|
key: '11111111111111111111111111111111',
|
||||||
@@ -202,6 +203,7 @@ describe('resolveChannelFromHashToken', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '22222222222222222222222222222222',
|
key: '22222222222222222222222222222222',
|
||||||
@@ -210,6 +212,7 @@ describe('resolveChannelFromHashToken', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ describe('useContactsAndChannels', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
existing_count: 1,
|
existing_count: 1,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const publicChannel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentMessage: Message = {
|
const sentMessage: Message = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const publicChannel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ import type { Channel, Contact } from '../types';
|
|||||||
import { getStateKey } from '../utils/conversationState';
|
import { getStateKey } from '../utils/conversationState';
|
||||||
|
|
||||||
function makeChannel(key: string, favorite = false): Channel {
|
function makeChannel(key: string, favorite = false): Channel {
|
||||||
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
|
return {
|
||||||
|
key,
|
||||||
|
name: key,
|
||||||
|
is_hashtag: false,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
favorite,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeContact(publicKey: string, favorite = false): Contact {
|
function makeContact(publicKey: string, favorite = false): Contact {
|
||||||
|
|||||||
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
|
|||||||
expect(result.current.allSubscriptions).toEqual([]);
|
expect(result.current.allSubscriptions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('times out and shows a toast when service worker never activates', async () => {
|
||||||
|
// Replace serviceWorker.ready with a promise that never resolves
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ready: new Promise(() => {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePushSubscription());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isSupported).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.subscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(mocks.toast.error).toHaveBeenCalledWith(
|
||||||
|
'Failed to enable push notifications',
|
||||||
|
expect.objectContaining({
|
||||||
|
description: expect.stringContaining('trusted TLS certificate for service workers'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
||||||
const oldSubscription = activeSubscription;
|
const oldSubscription = activeSubscription;
|
||||||
mocks.api.getPushSubscriptions
|
mocks.api.getPushSubscriptions
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const publicChannel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const incomingDm: Message = {
|
const incomingDm: Message = {
|
||||||
@@ -65,6 +66,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
|||||||
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
||||||
setContacts,
|
setContacts,
|
||||||
blockedKeysRef: { current: [] as string[] },
|
blockedKeysRef: { current: [] as string[] },
|
||||||
|
channelsRef: { current: [publicChannel] },
|
||||||
blockedNamesRef: { current: [] as string[] },
|
blockedNamesRef: { current: [] as string[] },
|
||||||
activeConversationRef: { current: null as Conversation | null },
|
activeConversationRef: { current: null as Conversation | null },
|
||||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function makeChannel(key: string, name: string): Channel {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
|
|||||||
timestamp: number | null;
|
timestamp: number | null;
|
||||||
battery_mv: number | null;
|
battery_mv: number | null;
|
||||||
uptime_secs: number | null;
|
uptime_secs: number | null;
|
||||||
|
queue_len: number | null;
|
||||||
|
errors: number | null;
|
||||||
noise_floor: number | null;
|
noise_floor: number | null;
|
||||||
last_rssi: number | null;
|
last_rssi: number | null;
|
||||||
last_snr: number | null;
|
last_snr: number | null;
|
||||||
@@ -223,6 +225,7 @@ export interface Channel {
|
|||||||
path_hash_mode_override?: number | null;
|
path_hash_mode_override?: number | null;
|
||||||
last_read_at: number | null;
|
last_read_at: number | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
|
muted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelMessageCounts {
|
export interface ChannelMessageCounts {
|
||||||
|
|||||||
@@ -29,3 +29,7 @@ MESHCORE_DISABLE_BOTS=true
|
|||||||
# HTTP Basic Auth (recommended when bots are enabled)
|
# HTTP Basic Auth (recommended when bots are enabled)
|
||||||
#MESHCORE_BASIC_AUTH_USERNAME=
|
#MESHCORE_BASIC_AUTH_USERNAME=
|
||||||
#MESHCORE_BASIC_AUTH_PASSWORD=
|
#MESHCORE_BASIC_AUTH_PASSWORD=
|
||||||
|
|
||||||
|
# Enable GET /api/radio/private-key to return the in-memory private key as hex
|
||||||
|
# for backup or migration. Only enable on a trusted network.
|
||||||
|
#MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=false
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.0"
|
version = "3.12.3"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
|
|||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"pip-licenses>=5.0.0",
|
"pip-licenses>=5.0.0",
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-xdist>=3.0",
|
"pytest-xdist>=3.0",
|
||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ echo -e "${GREEN}Passed!${NC}"
|
|||||||
|
|
||||||
echo -ne "${BLUE}[build]${NC} "
|
echo -ne "${BLUE}[build]${NC} "
|
||||||
cd "$REPO_ROOT/frontend"
|
cd "$REPO_ROOT/frontend"
|
||||||
npx --quiet tsc 2>&1 && npx --quiet vite build --logLevel error 2>&1
|
npx --quiet tsc 2>&1
|
||||||
|
npx --quiet vite build --logLevel error 2>&1
|
||||||
echo -e "${GREEN}Passed!${NC}"
|
echo -e "${GREEN}Passed!${NC}"
|
||||||
|
|
||||||
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
|
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ test.describe('Apprise integration settings', () => {
|
|||||||
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
||||||
await expect(preserveIdentity).toBeVisible();
|
await expect(preserveIdentity).toBeVisible();
|
||||||
|
|
||||||
// Verify include routing path checkbox is checked by default
|
// Verify format textareas are present under Message Format heading
|
||||||
const includePath = page.getByText('Include routing path in notifications');
|
await expect(page.getByText('Message Format')).toBeVisible();
|
||||||
await expect(includePath).toBeVisible();
|
await expect(page.locator('#fanout-apprise-fmt-dm')).toBeVisible();
|
||||||
|
await expect(page.locator('#fanout-apprise-fmt-chan')).toBeVisible();
|
||||||
|
|
||||||
// Rename it
|
// Rename it
|
||||||
const nameInput = page.locator('#fanout-edit-name');
|
const nameInput = page.locator('#fanout-edit-name');
|
||||||
@@ -94,7 +95,8 @@ test.describe('Apprise integration settings', () => {
|
|||||||
config: {
|
config: {
|
||||||
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
||||||
preserve_identity: false,
|
preserve_identity: false,
|
||||||
include_path: false,
|
body_format_dm: '{sender_name}: {text}',
|
||||||
|
body_format_channel: '{channel_name} | {sender_name}: {text}',
|
||||||
},
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
@@ -113,18 +115,18 @@ test.describe('Apprise integration settings', () => {
|
|||||||
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
|
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
|
||||||
|
|
||||||
// Verify checkboxes reflect our config (both unchecked)
|
// Verify preserve identity checkbox reflects our config (unchecked)
|
||||||
const preserveCheckbox = page
|
const preserveCheckbox = page
|
||||||
.getByText('Preserve identity on Discord')
|
.getByText('Preserve identity on Discord')
|
||||||
.locator('xpath=ancestor::label[1]')
|
.locator('xpath=ancestor::label[1]')
|
||||||
.locator('input[type="checkbox"]');
|
.locator('input[type="checkbox"]');
|
||||||
await expect(preserveCheckbox).not.toBeChecked();
|
await expect(preserveCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
const pathCheckbox = page
|
// Verify format textareas reflect our custom formats
|
||||||
.getByText('Include routing path in notifications')
|
const dmFormat = page.locator('#fanout-apprise-fmt-dm');
|
||||||
.locator('xpath=ancestor::label[1]')
|
await expect(dmFormat).toHaveValue('{sender_name}: {text}');
|
||||||
.locator('input[type="checkbox"]');
|
const chanFormat = page.locator('#fanout-apprise-fmt-chan');
|
||||||
await expect(pathCheckbox).not.toBeChecked();
|
await expect(chanFormat).toHaveValue('{channel_name} | {sender_name}: {text}');
|
||||||
|
|
||||||
// Go back
|
// Go back
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ test.describe('Favorites persistence', () => {
|
|||||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||||
})
|
})
|
||||||
.toBe(false);
|
.toBe(false);
|
||||||
await expect(page.getByText('Favorites')).not.toBeVisible();
|
// The test channel should no longer appear under the Favorites header —
|
||||||
|
// but the Favorites section itself may remain if radio-synced contacts are favorited.
|
||||||
|
const channelsSectionHeader = page.getByText('Channels');
|
||||||
|
await expect(channelsSectionHeader).toBeVisible();
|
||||||
|
// Verify the channel now appears in the non-favorites Channels section
|
||||||
|
const channelEntry = page.getByText(channelName, { exact: true }).first();
|
||||||
|
await expect(channelEntry).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -203,6 +203,30 @@ class TestHealthEndpoint:
|
|||||||
class TestDebugEndpoint:
|
class TestDebugEndpoint:
|
||||||
"""Test the debug support snapshot endpoint."""
|
"""Test the debug support snapshot endpoint."""
|
||||||
|
|
||||||
|
def test_build_environment_exposes_env_settings(self):
|
||||||
|
"""_build_environment should expose env config without secrets."""
|
||||||
|
from app.config import Settings
|
||||||
|
from app.routers.debug import _build_environment
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.routers.debug.settings",
|
||||||
|
Settings(
|
||||||
|
serial_port="/dev/ttyUSB0",
|
||||||
|
serial_baudrate=115200,
|
||||||
|
log_level="DEBUG",
|
||||||
|
database_path="data/test.db",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
env = _build_environment()
|
||||||
|
|
||||||
|
assert env.connection_type == "serial"
|
||||||
|
assert env.serial_port == "/dev/ttyUSB0"
|
||||||
|
assert env.log_level == "DEBUG"
|
||||||
|
assert env.database_path == "data/test.db"
|
||||||
|
assert not hasattr(env, "ble_pin")
|
||||||
|
assert not hasattr(env, "basic_auth_password")
|
||||||
|
assert not hasattr(env, "basic_auth_username")
|
||||||
|
|
||||||
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
||||||
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
||||||
from app.routers.debug import _sanitize_radio_probe_self_info
|
from app.routers.debug import _sanitize_radio_probe_self_info
|
||||||
@@ -300,6 +324,8 @@ class TestDebugEndpoint:
|
|||||||
assert "multi_acks_enabled" not in payload["radio_probe"]
|
assert "multi_acks_enabled" not in payload["radio_probe"]
|
||||||
assert "max_channels" not in payload["runtime"]
|
assert "max_channels" not in payload["runtime"]
|
||||||
assert "path_hash_mode" not in payload["runtime"]
|
assert "path_hash_mode" not in payload["runtime"]
|
||||||
|
assert "environment" in payload
|
||||||
|
assert payload["environment"]["connection_type"] in ("serial", "tcp", "ble")
|
||||||
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
||||||
assert payload["database"]["total_dms"] == 0
|
assert payload["database"]["total_dms"] == 0
|
||||||
assert payload["database"]["total_channel_messages"] == 0
|
assert payload["database"]["total_channel_messages"] == 0
|
||||||
|
|||||||
@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
|
|||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = MagicMock()
|
mock_radio.meshcore = MagicMock()
|
||||||
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
||||||
|
mock_radio.device_info_loaded = True
|
||||||
|
mock_radio.device_model = "T-Deck"
|
||||||
|
mock_radio.firmware_version = "v2.2.2"
|
||||||
|
mock_radio.firmware_build = "2025-01-15"
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
patch("app.radio.radio_manager", mock_radio),
|
patch("app.radio.radio_manager", mock_radio),
|
||||||
patch.object(
|
|
||||||
pub,
|
|
||||||
"_fetch_device_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
|
||||||
),
|
|
||||||
patch.object(
|
patch.object(
|
||||||
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
|
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
|
||||||
),
|
),
|
||||||
@@ -852,6 +850,82 @@ class TestLwtAndStatusPublish:
|
|||||||
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
||||||
assert payload["stats"] == {"battery_mv": 4200}
|
assert payload["stats"] == {"battery_mv": 4200}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_status_uses_fallback_fetch_when_device_info_not_loaded(self):
|
||||||
|
"""When device_info_loaded is False, _fetch_device_info() should be called as fallback."""
|
||||||
|
pub = CommunityMqttPublisher()
|
||||||
|
private_key, public_key = _make_test_keys()
|
||||||
|
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
|
||||||
|
|
||||||
|
mock_radio = MagicMock()
|
||||||
|
mock_radio.meshcore = MagicMock()
|
||||||
|
mock_radio.meshcore.self_info = {"name": "OldNode"}
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
|
patch("app.radio.radio_manager", mock_radio),
|
||||||
|
patch.object(
|
||||||
|
pub,
|
||||||
|
"_fetch_device_info",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={"model": "LegacyBoard", "firmware_version": "v2"},
|
||||||
|
) as mock_fetch,
|
||||||
|
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||||
|
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||||
|
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm/0-x"),
|
||||||
|
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||||
|
):
|
||||||
|
await pub._publish_status(settings)
|
||||||
|
|
||||||
|
mock_fetch.assert_awaited_once()
|
||||||
|
payload = mock_publish.call_args[0][1]
|
||||||
|
assert payload["model"] == "LegacyBoard"
|
||||||
|
assert payload["firmware_version"] == "v2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_status_reflects_updated_firmware_version_after_reconnect(self):
|
||||||
|
"""After firmware update + radio reconnect, the published firmware_version must be fresh.
|
||||||
|
|
||||||
|
This is a regression test for the stale-cache bug: previously _cached_device_info
|
||||||
|
was never cleared between reconnects, so a radio firmware update was invisible to
|
||||||
|
the Community MQTT status payload until the fanout module itself restarted.
|
||||||
|
"""
|
||||||
|
pub = CommunityMqttPublisher()
|
||||||
|
private_key, public_key = _make_test_keys()
|
||||||
|
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
|
||||||
|
|
||||||
|
mock_radio = MagicMock()
|
||||||
|
mock_radio.meshcore = MagicMock()
|
||||||
|
mock_radio.meshcore.self_info = {"name": "MyNode"}
|
||||||
|
mock_radio.device_info_loaded = True
|
||||||
|
mock_radio.device_model = "T-Deck"
|
||||||
|
mock_radio.firmware_version = "1.14.1"
|
||||||
|
mock_radio.firmware_build = ""
|
||||||
|
|
||||||
|
async def _publish_once(radio_mock):
|
||||||
|
with (
|
||||||
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
|
patch("app.radio.radio_manager", radio_mock),
|
||||||
|
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||||
|
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||||
|
patch("app.fanout.community_mqtt._get_client_version", return_value="RT/0-x"),
|
||||||
|
patch.object(pub, "publish", new_callable=AsyncMock) as mock_pub,
|
||||||
|
):
|
||||||
|
await pub._publish_status(settings)
|
||||||
|
return mock_pub.call_args[0][1]
|
||||||
|
|
||||||
|
first_payload = await _publish_once(mock_radio)
|
||||||
|
assert first_payload["firmware_version"] == "1.14.1"
|
||||||
|
|
||||||
|
# Simulate firmware update: radio reboots, radio_lifecycle refreshes the manager fields
|
||||||
|
mock_radio.firmware_version = "1.15.0"
|
||||||
|
|
||||||
|
second_payload = await _publish_once(mock_radio)
|
||||||
|
assert second_payload["firmware_version"] == "1.15.0", (
|
||||||
|
"Expected updated firmware version after reconnect; stale cache bug would return v1.14.1"
|
||||||
|
)
|
||||||
|
|
||||||
def test_lwt_and_online_share_same_topic(self):
|
def test_lwt_and_online_share_same_topic(self):
|
||||||
"""LWT and on-connect status should use the same topic path."""
|
"""LWT and on-connect status should use the same topic path."""
|
||||||
pub = CommunityMqttPublisher()
|
pub = CommunityMqttPublisher()
|
||||||
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
|
|||||||
|
|
||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = None
|
mock_radio.meshcore = None
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
|
|||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = MagicMock()
|
mock_radio.meshcore = MagicMock()
|
||||||
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
||||||
|
mock_radio.device_info_loaded = True
|
||||||
|
mock_radio.device_model = "T-Deck"
|
||||||
|
mock_radio.firmware_version = "v2.2.2"
|
||||||
|
mock_radio.firmware_build = "2025-01-15"
|
||||||
|
|
||||||
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
|
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
patch("app.radio.radio_manager", mock_radio),
|
patch("app.radio.radio_manager", mock_radio),
|
||||||
patch.object(
|
|
||||||
pub,
|
|
||||||
"_fetch_device_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
|
||||||
),
|
|
||||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
|
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
|
||||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
||||||
patch(
|
patch(
|
||||||
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
|
|||||||
|
|
||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = None
|
mock_radio.meshcore = None
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
|
|||||||
|
|
||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = None
|
mock_radio.meshcore = None
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
before = time.monotonic()
|
before = time.monotonic()
|
||||||
|
|
||||||
|
|||||||
+136
-9
@@ -1049,7 +1049,8 @@ class TestAppriseFormatBody:
|
|||||||
from app.fanout.apprise_mod import _format_body
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"}, include_path=False
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="**DM:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**DM:** Alice: hi"
|
assert body == "**DM:** Alice: hi"
|
||||||
|
|
||||||
@@ -1058,7 +1059,7 @@ class TestAppriseFormatBody:
|
|||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
|
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
|
||||||
include_path=False,
|
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**#general:** Bob: hi"
|
assert body == "**#general:** Bob: hi"
|
||||||
|
|
||||||
@@ -1072,7 +1073,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Bob",
|
"sender_name": "Bob",
|
||||||
"channel_name": "#general",
|
"channel_name": "#general",
|
||||||
},
|
},
|
||||||
include_path=False,
|
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**#general:** Bob: hi"
|
assert body == "**#general:** Bob: hi"
|
||||||
|
|
||||||
@@ -1086,7 +1087,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "2027"}],
|
"paths": [{"path": "2027"}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`20`" in body
|
assert "`20`" in body
|
||||||
@@ -1097,7 +1098,7 @@ class TestAppriseFormatBody:
|
|||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "`direct`" in body
|
assert "`direct`" in body
|
||||||
|
|
||||||
@@ -1112,7 +1113,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aabb`" in body
|
assert "`aabb`" in body
|
||||||
@@ -1129,7 +1130,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aabbcc`" in body
|
assert "`aabbcc`" in body
|
||||||
@@ -1147,7 +1148,7 @@ class TestAppriseFormatBody:
|
|||||||
"channel_name": "#general",
|
"channel_name": "#general",
|
||||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_channel="**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**#general:**" in body
|
assert "**#general:**" in body
|
||||||
assert "`aabb`" in body
|
assert "`aabb`" in body
|
||||||
@@ -1164,12 +1165,118 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabb"}],
|
"paths": [{"path": "aabb"}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aa`" in body
|
assert "`aa`" in body
|
||||||
assert "`bb`" in body
|
assert "`bb`" in body
|
||||||
|
|
||||||
|
def test_default_format_strings(self):
|
||||||
|
"""Default format strings produce expected output."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": "2a3b"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert body == "**DM:** Alice: hi **via:** [`2a`, `3b`]"
|
||||||
|
|
||||||
|
def test_custom_format_with_rssi(self):
|
||||||
|
"""Custom format string can include rssi/snr."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": "2a", "rssi": -95, "snr": 6.5}],
|
||||||
|
},
|
||||||
|
body_format_dm="From {sender_name}: {text} (rssi: {rssi}, snr: {snr})",
|
||||||
|
)
|
||||||
|
assert body == "From Alice: hi (rssi: -95, snr: 6.5)"
|
||||||
|
|
||||||
|
def test_unknown_placeholder_left_as_is(self):
|
||||||
|
"""Unknown {placeholders} pass through unchanged."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text} {unknown_var}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hi {unknown_var}"
|
||||||
|
|
||||||
|
def test_none_fields_render_empty(self):
|
||||||
|
"""None optional fields render as empty string, not 'None'."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text} rssi={rssi}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hi rssi="
|
||||||
|
assert "None" not in body
|
||||||
|
|
||||||
|
def test_hops_direct_when_no_paths(self):
|
||||||
|
"""hops is 'direct' when no path data exists."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
|
||||||
|
body_format_channel="{channel_name} {hops}",
|
||||||
|
)
|
||||||
|
assert body == "#gen direct"
|
||||||
|
|
||||||
|
def test_hops_direct_when_empty_path(self):
|
||||||
|
"""hops is 'direct' when path string is empty."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": ""}],
|
||||||
|
},
|
||||||
|
body_format_dm="{hops}",
|
||||||
|
)
|
||||||
|
assert body == "direct"
|
||||||
|
|
||||||
|
def test_no_re_expansion_of_substituted_values(self):
|
||||||
|
"""Placeholders in message text must not be expanded by later passes."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hello {sender_name}", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hello {sender_name}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_format_string_uses_default(self):
|
||||||
|
"""Empty format strings in config should produce default output, not blank."""
|
||||||
|
from unittest.mock import patch as _patch
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import AppriseModule
|
||||||
|
|
||||||
|
mod = AppriseModule(
|
||||||
|
"test",
|
||||||
|
{"urls": "json://localhost", "body_format_dm": "", "body_format_channel": " "},
|
||||||
|
)
|
||||||
|
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
|
||||||
|
await mod.on_message(
|
||||||
|
{"type": "PRIV", "text": "hi", "outgoing": False, "sender_name": "Alice"}
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
body = mock_send.call_args[0][1]
|
||||||
|
assert "Alice" in body
|
||||||
|
assert "hi" in body
|
||||||
|
assert body != ""
|
||||||
|
|
||||||
|
|
||||||
class TestAppriseNormalizeDiscordUrl:
|
class TestAppriseNormalizeDiscordUrl:
|
||||||
def test_discord_scheme(self):
|
def test_discord_scheme(self):
|
||||||
@@ -1233,6 +1340,26 @@ class TestAppriseValidation:
|
|||||||
|
|
||||||
_validate_apprise_config({"urls": "discord://123/abc"})
|
_validate_apprise_config({"urls": "discord://123/abc"})
|
||||||
|
|
||||||
|
def test_validate_apprise_config_accepts_format_strings(self):
|
||||||
|
from app.routers.fanout import _validate_apprise_config
|
||||||
|
|
||||||
|
_validate_apprise_config(
|
||||||
|
{
|
||||||
|
"urls": "discord://123/abc",
|
||||||
|
"body_format_dm": "DM from {sender_name}: {text}",
|
||||||
|
"body_format_channel": "{channel_name}: {text}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_apprise_config_rejects_non_string_format(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.fanout import _validate_apprise_config
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_validate_apprise_config({"urls": "discord://123/abc", "body_format_dm": 123})
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
def test_enforce_scope_apprise_strips_raw_packets(self):
|
def test_enforce_scope_apprise_strips_raw_packets(self):
|
||||||
from app.routers.fanout import _enforce_scope
|
from app.routers.fanout import _enforce_scope
|
||||||
|
|
||||||
|
|||||||
@@ -1171,7 +1171,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"preserve_identity": True,
|
"preserve_identity": True,
|
||||||
"include_path": False,
|
"body_format_dm": "**DM:** {sender_name}: {text}",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -1212,7 +1213,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
name="Channel Apprise",
|
name="Channel Apprise",
|
||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"include_path": False,
|
"body_format_dm": "**DM:** {sender_name}: {text}",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -1541,13 +1543,14 @@ class TestFanoutAppriseIntegration:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
|
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
|
||||||
"""Apprise with include_path=True shows routing hops in the body."""
|
"""Apprise with hops in format string shows routing hops in the body."""
|
||||||
cfg = await FanoutConfigRepository.create(
|
cfg = await FanoutConfigRepository.create(
|
||||||
config_type="apprise",
|
config_type="apprise",
|
||||||
name="Path Apprise",
|
name="Path Apprise",
|
||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"include_path": True,
|
"body_format_dm": "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||||
# change, not every individual assertion.
|
# change, not every individual assertion.
|
||||||
LATEST_SCHEMA_VERSION = 58
|
LATEST_SCHEMA_VERSION = 60
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
|
|
||||||
from app.fanout.mqtt_ha import (
|
from app.fanout.mqtt_ha import (
|
||||||
MqttHaModule,
|
MqttHaModule,
|
||||||
|
_assign_lpp_keys,
|
||||||
_contact_tracker_discovery_config,
|
_contact_tracker_discovery_config,
|
||||||
_device_payload,
|
_device_payload,
|
||||||
_lpp_discovery_configs,
|
_lpp_discovery_configs,
|
||||||
@@ -552,6 +553,45 @@ class TestLppSensorKey:
|
|||||||
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
|
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssignLppKeys:
|
||||||
|
def test_no_duplicates(self):
|
||||||
|
sensors = [
|
||||||
|
{"type_name": "temperature", "channel": 1, "value": 20},
|
||||||
|
{"type_name": "humidity", "channel": 2, "value": 45},
|
||||||
|
]
|
||||||
|
result = _assign_lpp_keys(sensors)
|
||||||
|
assert [(k, n) for _, k, n in result] == [
|
||||||
|
("lpp_temperature_ch1", 1),
|
||||||
|
("lpp_humidity_ch2", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_duplicate_type_and_channel(self):
|
||||||
|
sensors = [
|
||||||
|
{"type_name": "temperature", "channel": 1, "value": 20},
|
||||||
|
{"type_name": "humidity", "channel": 2, "value": 45},
|
||||||
|
{"type_name": "temperature", "channel": 1, "value": 53},
|
||||||
|
]
|
||||||
|
result = _assign_lpp_keys(sensors)
|
||||||
|
assert [(k, n) for _, k, n in result] == [
|
||||||
|
("lpp_temperature_ch1", 1),
|
||||||
|
("lpp_humidity_ch2", 1),
|
||||||
|
("lpp_temperature_ch1_2", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_triple_duplicate(self):
|
||||||
|
sensors = [
|
||||||
|
{"type_name": "voltage", "channel": 0, "value": 3.3},
|
||||||
|
{"type_name": "voltage", "channel": 0, "value": 5.0},
|
||||||
|
{"type_name": "voltage", "channel": 0, "value": 12.0},
|
||||||
|
]
|
||||||
|
result = _assign_lpp_keys(sensors)
|
||||||
|
keys = [k for _, k, _ in result]
|
||||||
|
assert keys == ["lpp_voltage_ch0", "lpp_voltage_ch0_2", "lpp_voltage_ch0_3"]
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
assert _assign_lpp_keys([]) == []
|
||||||
|
|
||||||
|
|
||||||
class TestLppDiscoveryConfigs:
|
class TestLppDiscoveryConfigs:
|
||||||
def test_produces_config_per_sensor(self):
|
def test_produces_config_per_sensor(self):
|
||||||
nid = "ccdd11223344"
|
nid = "ccdd11223344"
|
||||||
@@ -583,6 +623,27 @@ class TestLppDiscoveryConfigs:
|
|||||||
assert cfg["suggested_display_precision"] == 1
|
assert cfg["suggested_display_precision"] == 1
|
||||||
assert "lpp_temperature_ch1" in cfg["value_template"]
|
assert "lpp_temperature_ch1" in cfg["value_template"]
|
||||||
|
|
||||||
|
def test_duplicate_type_channel_gets_indexed_keys(self):
|
||||||
|
nid = "ccdd11223344"
|
||||||
|
device = _device_payload(nid, "Rep1", "Repeater")
|
||||||
|
sensors = [
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 20.0},
|
||||||
|
{"channel": 2, "type_name": "humidity", "value": 45.0},
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 53.0},
|
||||||
|
]
|
||||||
|
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
|
||||||
|
|
||||||
|
assert len(configs) == 3
|
||||||
|
topics = [t for t, _ in configs]
|
||||||
|
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config" in topics
|
||||||
|
assert f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config" in topics
|
||||||
|
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config" in topics
|
||||||
|
|
||||||
|
# First temperature keeps base name, second gets #2 suffix
|
||||||
|
names = {cfg["unique_id"]: cfg["name"] for _, cfg in configs}
|
||||||
|
assert names[f"meshcore_{nid}_lpp_temperature_ch1"] == "Temperature (Ch 1)"
|
||||||
|
assert names[f"meshcore_{nid}_lpp_temperature_ch1_2"] == "Temperature (Ch 1) #2"
|
||||||
|
|
||||||
def test_unknown_sensor_type_no_device_class(self):
|
def test_unknown_sensor_type_no_device_class(self):
|
||||||
nid = "ccdd11223344"
|
nid = "ccdd11223344"
|
||||||
device = _device_payload(nid, "Rep1", "Repeater")
|
device = _device_payload(nid, "Rep1", "Repeater")
|
||||||
@@ -712,6 +773,35 @@ class TestMqttHaTelemetryWithLpp:
|
|||||||
|
|
||||||
mod._publish_discovery.assert_not_awaited()
|
mod._publish_discovery.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telemetry_duplicate_lpp_sensors_not_overwritten(self):
|
||||||
|
"""Two sensors with same (type_name, channel) get distinct keys."""
|
||||||
|
key = "ccdd11223344"
|
||||||
|
nid = _node_id(key)
|
||||||
|
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||||
|
mod._publisher = MagicMock()
|
||||||
|
mod._publisher.connected = True
|
||||||
|
mod._publisher.publish = AsyncMock()
|
||||||
|
mod._discovery_topics = [
|
||||||
|
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config",
|
||||||
|
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config",
|
||||||
|
]
|
||||||
|
|
||||||
|
await mod.on_telemetry(
|
||||||
|
{
|
||||||
|
"public_key": key,
|
||||||
|
"battery_volts": 4.1,
|
||||||
|
"lpp_sensors": [
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 20.0},
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 53.0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mod._publisher.publish.call_args[0][1]
|
||||||
|
assert payload["lpp_temperature_ch1"] == 20.0
|
||||||
|
assert payload["lpp_temperature_ch1_2"] == 53.0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_telemetry_without_lpp_sensors(self):
|
async def test_on_telemetry_without_lpp_sensors(self):
|
||||||
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.routers.radio import (
|
|||||||
RadioSettings,
|
RadioSettings,
|
||||||
disconnect_radio,
|
disconnect_radio,
|
||||||
discover_mesh,
|
discover_mesh,
|
||||||
|
get_private_key,
|
||||||
get_radio_config,
|
get_radio_config,
|
||||||
reboot_radio,
|
reboot_radio,
|
||||||
reconnect_radio,
|
reconnect_radio,
|
||||||
@@ -283,6 +284,38 @@ class TestUpdateRadioConfig:
|
|||||||
mc.commands.send_appstart.assert_not_awaited()
|
mc.commands.send_appstart.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrivateKeyExport:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_403_when_export_disabled(self):
|
||||||
|
with patch("app.config.settings") as mock_settings:
|
||||||
|
mock_settings.enable_local_private_key_export = False
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_private_key()
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_when_no_key_available(self):
|
||||||
|
with (
|
||||||
|
patch("app.config.settings") as mock_settings,
|
||||||
|
patch("app.keystore.get_private_key", return_value=None),
|
||||||
|
):
|
||||||
|
mock_settings.enable_local_private_key_export = True
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_private_key()
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_key_hex_when_enabled_and_available(self):
|
||||||
|
key_bytes = bytes.fromhex("ab" * 64)
|
||||||
|
with (
|
||||||
|
patch("app.config.settings") as mock_settings,
|
||||||
|
patch("app.keystore.get_private_key", return_value=key_bytes),
|
||||||
|
):
|
||||||
|
mock_settings.enable_local_private_key_export = True
|
||||||
|
result = await get_private_key()
|
||||||
|
assert result == {"private_key": "ab" * 64}
|
||||||
|
|
||||||
|
|
||||||
class TestPrivateKeyImport:
|
class TestPrivateKeyImport:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rejects_invalid_hex(self):
|
async def test_rejects_invalid_hex(self):
|
||||||
|
|||||||
+315
-2
@@ -15,6 +15,7 @@ from meshcore.events import Event
|
|||||||
import app.radio_sync as radio_sync
|
import app.radio_sync as radio_sync
|
||||||
from app.radio import RadioManager, radio_manager
|
from app.radio import RadioManager, radio_manager
|
||||||
from app.radio_sync import (
|
from app.radio_sync import (
|
||||||
|
_enable_autoevict_on_radio,
|
||||||
_message_poll_loop,
|
_message_poll_loop,
|
||||||
_periodic_advert_loop,
|
_periodic_advert_loop,
|
||||||
_periodic_sync_loop,
|
_periodic_sync_loop,
|
||||||
@@ -76,6 +77,7 @@ async def _insert_contact(
|
|||||||
name="Alice",
|
name="Alice",
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
contact_type=0,
|
contact_type=0,
|
||||||
|
flags=0,
|
||||||
last_contacted=None,
|
last_contacted=None,
|
||||||
last_advert=None,
|
last_advert=None,
|
||||||
direct_path=None,
|
direct_path=None,
|
||||||
@@ -88,7 +90,7 @@ async def _insert_contact(
|
|||||||
"public_key": public_key,
|
"public_key": public_key,
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": contact_type,
|
"type": contact_type,
|
||||||
"flags": 0,
|
"flags": flags,
|
||||||
"direct_path": direct_path,
|
"direct_path": direct_path,
|
||||||
"direct_path_len": direct_path_len,
|
"direct_path_len": direct_path_len,
|
||||||
"direct_path_hash_mode": direct_path_hash_mode,
|
"direct_path_hash_mode": direct_path_hash_mode,
|
||||||
@@ -516,10 +518,101 @@ class TestSyncAndOffloadAll:
|
|||||||
result = await sync_and_offload_all(mock_mc)
|
result = await sync_and_offload_all(mock_mc)
|
||||||
|
|
||||||
mock_start.assert_called_once_with(
|
mock_start.assert_called_once_with(
|
||||||
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
|
initial_radio_contacts=radio_contacts, expected_mc=mock_mc, autoevict=False
|
||||||
)
|
)
|
||||||
assert result["contact_reconcile_started"] is True
|
assert result["contact_reconcile_started"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_falls_back_to_snapshot_reconcile_when_autoevict_enable_fails(self, test_db):
|
||||||
|
mock_mc = MagicMock()
|
||||||
|
radio_contacts = {KEY_A: {"public_key": KEY_A}}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(radio_sync.settings, "load_with_autoevict", True),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync._enable_autoevict_on_radio",
|
||||||
|
new=AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.sync_contacts_from_radio",
|
||||||
|
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.sync_and_offload_channels",
|
||||||
|
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||||
|
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||||
|
):
|
||||||
|
result = await sync_and_offload_all(mock_mc)
|
||||||
|
|
||||||
|
mock_start.assert_called_once_with(
|
||||||
|
initial_radio_contacts=radio_contacts,
|
||||||
|
expected_mc=mock_mc,
|
||||||
|
autoevict=False,
|
||||||
|
)
|
||||||
|
assert result["contact_reconcile_started"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_autoevict_success_passes_flag_to_reconcile(self, test_db):
|
||||||
|
mock_mc = MagicMock()
|
||||||
|
radio_contacts = {KEY_A: {"public_key": KEY_A}}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(radio_sync.settings, "load_with_autoevict", True),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync._enable_autoevict_on_radio",
|
||||||
|
new=AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.sync_contacts_from_radio",
|
||||||
|
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.sync_and_offload_channels",
|
||||||
|
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||||
|
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||||
|
):
|
||||||
|
result = await sync_and_offload_all(mock_mc)
|
||||||
|
|
||||||
|
mock_start.assert_called_once_with(
|
||||||
|
initial_radio_contacts=radio_contacts,
|
||||||
|
expected_mc=mock_mc,
|
||||||
|
autoevict=True,
|
||||||
|
)
|
||||||
|
assert result["contact_reconcile_started"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_best_effort_reconcile_when_snapshot_fails(self, test_db):
|
||||||
|
"""When sync_contacts_from_radio errors, reconcile still starts with empty snapshot."""
|
||||||
|
mock_mc = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.sync_contacts_from_radio",
|
||||||
|
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}, "error": "timeout"}),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.sync_and_offload_channels",
|
||||||
|
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||||
|
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||||
|
patch("app.radio_sync.broadcast_error") as mock_broadcast,
|
||||||
|
):
|
||||||
|
result = await sync_and_offload_all(mock_mc)
|
||||||
|
|
||||||
|
mock_start.assert_called_once_with(
|
||||||
|
initial_radio_contacts={},
|
||||||
|
expected_mc=mock_mc,
|
||||||
|
autoevict=False,
|
||||||
|
)
|
||||||
|
assert result["contact_reconcile_started"] is True
|
||||||
|
mock_broadcast.assert_called_once()
|
||||||
|
assert "best-effort" in mock_broadcast.call_args.args[1]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_advert_fill_skips_repeaters(self, test_db):
|
async def test_advert_fill_skips_repeaters(self, test_db):
|
||||||
"""Recent advert fallback only considers non-repeaters."""
|
"""Recent advert fallback only considers non-repeaters."""
|
||||||
@@ -798,6 +891,81 @@ class TestSyncAndOffloadAll:
|
|||||||
assert payload["public_key"] == KEY_A
|
assert payload["public_key"] == KEY_A
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnableAutoevictOnRadio:
|
||||||
|
"""Test _enable_autoevict_on_radio read-modify-write flow."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sets_flag_when_not_already_set(self):
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.commands.get_autoadd_config = AsyncMock(
|
||||||
|
return_value=MagicMock(type=EventType.OK, payload={"config": 0x00})
|
||||||
|
)
|
||||||
|
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||||
|
|
||||||
|
result = await _enable_autoevict_on_radio(mc)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mc.commands.set_autoadd_config.assert_awaited_once_with(0x01)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_noop_when_already_enabled(self):
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.commands.get_autoadd_config = AsyncMock(
|
||||||
|
return_value=MagicMock(type=EventType.OK, payload={"config": 0x01})
|
||||||
|
)
|
||||||
|
mc.commands.set_autoadd_config = AsyncMock()
|
||||||
|
|
||||||
|
result = await _enable_autoevict_on_radio(mc)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mc.commands.set_autoadd_config.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preserves_other_flags(self):
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.commands.get_autoadd_config = AsyncMock(
|
||||||
|
return_value=MagicMock(type=EventType.OK, payload={"config": 0x04})
|
||||||
|
)
|
||||||
|
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||||
|
|
||||||
|
result = await _enable_autoevict_on_radio(mc)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mc.commands.set_autoadd_config.assert_awaited_once_with(0x05)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_false_on_get_error(self):
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.commands.get_autoadd_config = AsyncMock(
|
||||||
|
return_value=MagicMock(type=EventType.ERROR, payload=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _enable_autoevict_on_radio(mc)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_false_on_set_failure(self):
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.commands.get_autoadd_config = AsyncMock(
|
||||||
|
return_value=MagicMock(type=EventType.OK, payload={"config": 0x00})
|
||||||
|
)
|
||||||
|
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.ERROR))
|
||||||
|
|
||||||
|
result = await _enable_autoevict_on_radio(mc)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_false_on_exception(self):
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.commands.get_autoadd_config = AsyncMock(side_effect=RuntimeError("timeout"))
|
||||||
|
|
||||||
|
result = await _enable_autoevict_on_radio(mc)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestBackgroundContactReconcile:
|
class TestBackgroundContactReconcile:
|
||||||
"""Test the yielding background contact reconcile loop."""
|
"""Test the yielding background contact reconcile loop."""
|
||||||
|
|
||||||
@@ -844,6 +1012,151 @@ class TestBackgroundContactReconcile:
|
|||||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||||
assert payload["public_key"] == KEY_B
|
assert payload["public_key"] == KEY_B
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_autoevict_blind_fill_readds_full_desired_set(self, test_db):
|
||||||
|
await _insert_contact(KEY_A, "Alice", flags=0x01, last_contacted=2000)
|
||||||
|
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||||
|
alice = await ContactRepository.get_by_key(KEY_A)
|
||||||
|
bob = await ContactRepository.get_by_key(KEY_B)
|
||||||
|
assert alice is not None
|
||||||
|
assert bob is not None
|
||||||
|
|
||||||
|
mock_mc = MagicMock()
|
||||||
|
mock_mc.is_connected = True
|
||||||
|
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||||
|
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||||
|
mock_mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||||
|
radio_manager._meshcore = mock_mc
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _radio_operation(*args, **kwargs):
|
||||||
|
del args, kwargs
|
||||||
|
yield mock_mc
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
radio_sync.radio_manager,
|
||||||
|
"radio_operation",
|
||||||
|
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||||
|
side_effect=[[alice, bob], [alice, bob]],
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||||
|
):
|
||||||
|
await radio_sync._reconcile_radio_contacts_in_background(
|
||||||
|
initial_radio_contacts={KEY_A: {"public_key": KEY_A}},
|
||||||
|
expected_mc=mock_mc,
|
||||||
|
autoevict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_mc.commands.remove_contact.assert_not_called()
|
||||||
|
assert mock_mc.commands.add_contact.await_count == 2
|
||||||
|
loaded_keys = [
|
||||||
|
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
|
||||||
|
]
|
||||||
|
assert loaded_keys == [KEY_A, KEY_B]
|
||||||
|
loaded_flags = [
|
||||||
|
call.args[0]["flags"] for call in mock_mc.commands.add_contact.call_args_list
|
||||||
|
]
|
||||||
|
assert loaded_flags == [0, 0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_autoevict_table_full_breaks_with_error(self, test_db):
|
||||||
|
"""TABLE_FULL during autoevict stops the loop and broadcasts an error."""
|
||||||
|
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||||
|
alice = await ContactRepository.get_by_key(KEY_A)
|
||||||
|
assert alice is not None
|
||||||
|
|
||||||
|
mock_mc = MagicMock()
|
||||||
|
mock_mc.is_connected = True
|
||||||
|
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||||
|
table_full_result = MagicMock(type=EventType.ERROR, payload={"error_code": 3})
|
||||||
|
mock_mc.commands.add_contact = AsyncMock(return_value=table_full_result)
|
||||||
|
radio_manager._meshcore = mock_mc
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _radio_operation(*args, **kwargs):
|
||||||
|
del args, kwargs
|
||||||
|
yield mock_mc
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
radio_sync.radio_manager,
|
||||||
|
"radio_operation",
|
||||||
|
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||||
|
side_effect=[[alice], [alice]],
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||||
|
patch("app.radio_sync.broadcast_error") as mock_broadcast,
|
||||||
|
):
|
||||||
|
await radio_sync._reconcile_radio_contacts_in_background(
|
||||||
|
initial_radio_contacts={},
|
||||||
|
expected_mc=mock_mc,
|
||||||
|
autoevict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_broadcast.assert_called_once()
|
||||||
|
assert "auto-evict" in mock_broadcast.call_args.args[1].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_autoevict_retry_cap_stops_after_max_retries(self, test_db):
|
||||||
|
"""Autoevict gives up after _MAX_AUTOEVICT_RETRIES full passes with failures."""
|
||||||
|
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||||
|
alice = await ContactRepository.get_by_key(KEY_A)
|
||||||
|
assert alice is not None
|
||||||
|
|
||||||
|
mock_mc = MagicMock()
|
||||||
|
mock_mc.is_connected = True
|
||||||
|
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||||
|
# Every add fails with a non-TABLE_FULL error
|
||||||
|
fail_result = MagicMock(type=EventType.ERROR, payload={"error_code": 99})
|
||||||
|
mock_mc.commands.add_contact = AsyncMock(return_value=fail_result)
|
||||||
|
radio_manager._meshcore = mock_mc
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _radio_operation(*args, **kwargs):
|
||||||
|
del args, kwargs
|
||||||
|
yield mock_mc
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def _get_selected():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return [alice]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
radio_sync.radio_manager,
|
||||||
|
"radio_operation",
|
||||||
|
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
|
||||||
|
patch(
|
||||||
|
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||||
|
side_effect=_get_selected,
|
||||||
|
),
|
||||||
|
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||||
|
):
|
||||||
|
await radio_sync._reconcile_radio_contacts_in_background(
|
||||||
|
initial_radio_contacts={},
|
||||||
|
expected_mc=mock_mc,
|
||||||
|
autoevict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2 calls per iteration (pre-lock + in-lock), 3 retries = 6 calls,
|
||||||
|
# plus 1 pre-lock call on the initial iteration = at most 8.
|
||||||
|
# The key assertion: it terminates rather than looping forever.
|
||||||
|
assert mock_mc.commands.add_contact.await_count <= 4
|
||||||
|
assert call_count <= 8
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
|
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
|
||||||
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
|
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Tests for app.tcp_proxy.encoder — binary payload builders."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from app.tcp_proxy.encoder import (
|
||||||
|
build_contact,
|
||||||
|
build_contact_from_dict,
|
||||||
|
build_device_info,
|
||||||
|
build_self_info,
|
||||||
|
build_self_info_from_runtime,
|
||||||
|
)
|
||||||
|
from app.tcp_proxy.protocol import (
|
||||||
|
PROXY_FW_VER,
|
||||||
|
PROXY_MAX_CHANNELS,
|
||||||
|
PROXY_MAX_CONTACTS_RAW,
|
||||||
|
PUSH_NEW_ADVERT,
|
||||||
|
RESP_CONTACT,
|
||||||
|
RESP_DEVICE_INFO,
|
||||||
|
RESP_SELF_INFO,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXAMPLE_KEY = "ab" * 32 # 64-char hex → 32 bytes
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_contact ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildContact:
|
||||||
|
def test_basic_structure(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY, name="Alice")
|
||||||
|
assert payload[0] == RESP_CONTACT
|
||||||
|
# public key at bytes 1-32
|
||||||
|
assert payload[1:33] == bytes.fromhex(EXAMPLE_KEY)
|
||||||
|
# total length: 1 + 32 + 1(type) + 1(flags) + 1(path) + 64(path) + 32(name) + 4(adv) + 4(lat) + 4(lon) + 4(lastmod) = 148
|
||||||
|
assert len(payload) == 148
|
||||||
|
|
||||||
|
def test_push_variant(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY, push=True)
|
||||||
|
assert payload[0] == PUSH_NEW_ADVERT
|
||||||
|
assert len(payload) == 148
|
||||||
|
|
||||||
|
def test_favorite_flag(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY, favorite=True)
|
||||||
|
flags_byte = payload[34] # byte 1+32+1 = 34
|
||||||
|
assert flags_byte & 0x01 == 1
|
||||||
|
|
||||||
|
def test_not_favorite(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY, favorite=False)
|
||||||
|
flags_byte = payload[34]
|
||||||
|
assert flags_byte & 0x01 == 0
|
||||||
|
|
||||||
|
def test_flood_path(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY)
|
||||||
|
path_byte = payload[35] # byte 1+32+1+1 = 35
|
||||||
|
assert path_byte == 0xFF
|
||||||
|
|
||||||
|
def test_direct_path(self):
|
||||||
|
payload = build_contact(
|
||||||
|
EXAMPLE_KEY,
|
||||||
|
direct_path="aabb",
|
||||||
|
direct_path_len=2,
|
||||||
|
direct_path_hash_mode=1,
|
||||||
|
)
|
||||||
|
path_byte = payload[35]
|
||||||
|
# mode=1 → 0x40, hops=2 → 0x02 → packed = 0x42
|
||||||
|
assert path_byte == 0x42
|
||||||
|
|
||||||
|
def test_name_truncated(self):
|
||||||
|
long_name = "A" * 50
|
||||||
|
payload = build_contact(EXAMPLE_KEY, name=long_name)
|
||||||
|
# name field is 32 bytes at offset 100 (1+32+1+1+1+64)
|
||||||
|
name_bytes = payload[100:132]
|
||||||
|
assert name_bytes == b"A" * 32
|
||||||
|
|
||||||
|
def test_lat_lon_encoding(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY, lat=45.123456, lon=-122.654321)
|
||||||
|
lat_offset = 136 # 1+32+1+1+1+64+32+4 = 136
|
||||||
|
lat = struct.unpack_from("<i", payload, lat_offset)[0]
|
||||||
|
lon = struct.unpack_from("<i", payload, lat_offset + 4)[0]
|
||||||
|
assert abs(lat - 45123456) < 2
|
||||||
|
assert abs(lon - (-122654321)) < 2
|
||||||
|
|
||||||
|
def test_contact_type(self):
|
||||||
|
payload = build_contact(EXAMPLE_KEY, contact_type=2)
|
||||||
|
assert payload[33] == 2 # type byte at offset 1+32
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_contact_from_dict ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildContactFromDict:
|
||||||
|
def test_minimal_dict(self):
|
||||||
|
data = {"public_key": EXAMPLE_KEY}
|
||||||
|
payload = build_contact_from_dict(data)
|
||||||
|
assert payload[0] == RESP_CONTACT
|
||||||
|
assert len(payload) == 148
|
||||||
|
|
||||||
|
def test_full_dict(self):
|
||||||
|
data = {
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"type": 1,
|
||||||
|
"favorite": True,
|
||||||
|
"name": "Bob",
|
||||||
|
"direct_path": "ff",
|
||||||
|
"direct_path_len": 1,
|
||||||
|
"direct_path_hash_mode": 0,
|
||||||
|
"last_advert": 1700000000,
|
||||||
|
"lat": 37.7749,
|
||||||
|
"lon": -122.4194,
|
||||||
|
"first_seen": 1699000000,
|
||||||
|
}
|
||||||
|
payload = build_contact_from_dict(data)
|
||||||
|
assert payload[33] == 1 # type
|
||||||
|
assert payload[34] & 0x01 == 1 # favorite
|
||||||
|
|
||||||
|
def test_push_flag(self):
|
||||||
|
data = {"public_key": EXAMPLE_KEY}
|
||||||
|
payload = build_contact_from_dict(data, push=True)
|
||||||
|
assert payload[0] == PUSH_NEW_ADVERT
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_self_info ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSelfInfo:
|
||||||
|
def test_basic_structure(self):
|
||||||
|
payload = build_self_info()
|
||||||
|
assert payload[0] == RESP_SELF_INFO
|
||||||
|
assert payload[1] == 1 # adv_type = CHAT
|
||||||
|
# minimum length: 1+1+1+1+32+4+4+1+1+1+1+4+4+1+1 + len("RemoteTerm") = 68
|
||||||
|
assert len(payload) >= 58
|
||||||
|
|
||||||
|
def test_name_appended(self):
|
||||||
|
payload = build_self_info(name="TestNode")
|
||||||
|
# name starts at offset 58
|
||||||
|
name_bytes = payload[58:]
|
||||||
|
assert name_bytes == b"TestNode"
|
||||||
|
|
||||||
|
def test_public_key_encoded(self):
|
||||||
|
payload = build_self_info(public_key=EXAMPLE_KEY)
|
||||||
|
assert payload[4:36] == bytes.fromhex(EXAMPLE_KEY)
|
||||||
|
|
||||||
|
def test_radio_params(self):
|
||||||
|
payload = build_self_info(radio_freq=868.0, radio_bw=125.0, radio_sf=12, radio_cr=8)
|
||||||
|
freq = struct.unpack_from("<I", payload, 48)[0]
|
||||||
|
bw = struct.unpack_from("<I", payload, 52)[0]
|
||||||
|
assert freq == 868000
|
||||||
|
assert bw == 125000
|
||||||
|
assert payload[56] == 12 # sf
|
||||||
|
assert payload[57] == 8 # cr
|
||||||
|
|
||||||
|
def test_multi_acks_flag(self):
|
||||||
|
on = build_self_info(multi_acks=True)
|
||||||
|
off = build_self_info(multi_acks=False)
|
||||||
|
assert on[44] == 1
|
||||||
|
assert off[44] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSelfInfoFromRuntime:
|
||||||
|
def test_from_self_info_dict(self):
|
||||||
|
info = {
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"name": "MyRadio",
|
||||||
|
"tx_power": 18,
|
||||||
|
"max_tx_power": 22,
|
||||||
|
"adv_lat": 40.0,
|
||||||
|
"adv_lon": -74.0,
|
||||||
|
"multi_acks": 1,
|
||||||
|
"adv_loc_policy": 1,
|
||||||
|
"radio_freq": 915.0,
|
||||||
|
"radio_bw": 250.0,
|
||||||
|
"radio_sf": 10,
|
||||||
|
"radio_cr": 7,
|
||||||
|
}
|
||||||
|
payload = build_self_info_from_runtime(info)
|
||||||
|
assert payload[0] == RESP_SELF_INFO
|
||||||
|
assert payload[58:] == b"MyRadio"
|
||||||
|
|
||||||
|
def test_missing_fields_use_defaults(self):
|
||||||
|
payload = build_self_info_from_runtime({})
|
||||||
|
assert payload[0] == RESP_SELF_INFO
|
||||||
|
assert payload[58:] == b"RemoteTerm"
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_device_info ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildDeviceInfo:
|
||||||
|
def test_basic_structure(self):
|
||||||
|
payload = build_device_info()
|
||||||
|
assert payload[0] == RESP_DEVICE_INFO
|
||||||
|
assert payload[1] == PROXY_FW_VER
|
||||||
|
assert payload[2] == PROXY_MAX_CONTACTS_RAW
|
||||||
|
assert payload[3] == PROXY_MAX_CHANNELS
|
||||||
|
|
||||||
|
def test_path_hash_mode(self):
|
||||||
|
payload = build_device_info(path_hash_mode=2)
|
||||||
|
# path_hash_mode is at offset 81 (1+1+1+1+4+12+40+20+1 = 81)
|
||||||
|
assert payload[81] == 2
|
||||||
|
|
||||||
|
def test_expected_length(self):
|
||||||
|
# fw_ver=11 → 1+1+1+1+4+12+40+20+1+1 = 82 bytes
|
||||||
|
payload = build_device_info()
|
||||||
|
assert len(payload) == 82
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""Integration tests for the TCP proxy — real asyncio TCP server + client."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tcp_proxy.protocol import (
|
||||||
|
CMD_APP_START,
|
||||||
|
CMD_DEVICE_QUERY,
|
||||||
|
CMD_GET_CHANNEL,
|
||||||
|
CMD_GET_CONTACTS,
|
||||||
|
CMD_GET_DEVICE_TIME,
|
||||||
|
CMD_HAS_CONNECTION,
|
||||||
|
CMD_SET_CHANNEL,
|
||||||
|
CMD_SYNC_NEXT_MESSAGE,
|
||||||
|
FRAME_RX,
|
||||||
|
FRAME_TX,
|
||||||
|
PROXY_FW_VER,
|
||||||
|
PUSH_MSG_WAITING,
|
||||||
|
RESP_CONTACT_END,
|
||||||
|
RESP_CONTACT_START,
|
||||||
|
RESP_CURRENT_TIME,
|
||||||
|
RESP_DEVICE_INFO,
|
||||||
|
RESP_ERR,
|
||||||
|
RESP_NO_MORE_MSGS,
|
||||||
|
RESP_OK,
|
||||||
|
RESP_SELF_INFO,
|
||||||
|
)
|
||||||
|
from app.tcp_proxy.server import dispatch_event, register, unregister
|
||||||
|
from app.tcp_proxy.session import ProxySession
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
EXAMPLE_KEY = "ab" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_cmd(payload: bytes) -> bytes:
|
||||||
|
"""Wrap a command payload in a 0x3C frame."""
|
||||||
|
return bytes([FRAME_TX]) + len(payload).to_bytes(2, "little") + payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_response(reader: asyncio.StreamReader) -> bytes:
|
||||||
|
"""Read one 0x3E-framed response and return the payload."""
|
||||||
|
marker = await reader.readexactly(1)
|
||||||
|
assert marker[0] == FRAME_RX
|
||||||
|
size_bytes = await reader.readexactly(2)
|
||||||
|
size = int.from_bytes(size_bytes, "little")
|
||||||
|
payload = await reader.readexactly(size)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class _ProxyTestHarness:
|
||||||
|
"""Manages a real TCP proxy server for testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._server: asyncio.Server | None = None
|
||||||
|
self.port: int = 0
|
||||||
|
self.sessions: list[ProxySession] = []
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
|
||||||
|
self.port = self._server.sockets[0].getsockname()[1]
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
for s in self.sessions:
|
||||||
|
try:
|
||||||
|
s.writer.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.sessions.clear()
|
||||||
|
if self._server:
|
||||||
|
self._server.close()
|
||||||
|
await self._server.wait_closed()
|
||||||
|
|
||||||
|
async def _handle(self, reader, writer):
|
||||||
|
session = ProxySession(reader, writer)
|
||||||
|
self.sessions.append(session)
|
||||||
|
register(session)
|
||||||
|
try:
|
||||||
|
await session.run()
|
||||||
|
finally:
|
||||||
|
unregister(session)
|
||||||
|
|
||||||
|
async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", self.port)
|
||||||
|
return reader, writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def harness():
|
||||||
|
h = _ProxyTestHarness()
|
||||||
|
await h.start()
|
||||||
|
yield h
|
||||||
|
await h.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_repos_and_runtime():
|
||||||
|
"""Return a context manager that mocks repositories and radio_runtime."""
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
contacts = [
|
||||||
|
MagicMock(
|
||||||
|
model_dump=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"name": "Alice",
|
||||||
|
"type": 1,
|
||||||
|
"favorite": True,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": 0,
|
||||||
|
"lat": 0.0,
|
||||||
|
"lon": 0.0,
|
||||||
|
"first_seen": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
channels = [
|
||||||
|
MagicMock(
|
||||||
|
model_dump=MagicMock(return_value={"key": "cc" * 16, "name": "test", "favorite": True})
|
||||||
|
)
|
||||||
|
]
|
||||||
|
settings_obj = MagicMock(last_message_times={})
|
||||||
|
|
||||||
|
rt = MagicMock()
|
||||||
|
rt.is_connected = True
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.self_info = {
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"name": "TestNode",
|
||||||
|
"tx_power": 20,
|
||||||
|
"max_tx_power": 22,
|
||||||
|
"adv_lat": 0.0,
|
||||||
|
"adv_lon": 0.0,
|
||||||
|
"radio_freq": 915.0,
|
||||||
|
"radio_bw": 250.0,
|
||||||
|
"radio_sf": 10,
|
||||||
|
"radio_cr": 7,
|
||||||
|
}
|
||||||
|
rt.meshcore = mc
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
def __enter__(self_):
|
||||||
|
self_._patches = [
|
||||||
|
patch(
|
||||||
|
"app.repository.ContactRepository.get_favorites",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=contacts,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.repository.ChannelRepository.get_all",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=channels,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.repository.AppSettingsRepository.get",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=settings_obj,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.radio_runtime.radio_runtime",
|
||||||
|
rt,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for p in self_._patches:
|
||||||
|
p.__enter__()
|
||||||
|
return self_
|
||||||
|
|
||||||
|
def __exit__(self_, *args):
|
||||||
|
for p in reversed(self_._patches):
|
||||||
|
p.__exit__(*args)
|
||||||
|
|
||||||
|
return _Ctx()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestTcpProxyIntegration:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_app_start_returns_self_info(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
with _mock_repos_and_runtime():
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_APP_START]) + b"\x03" + b" " * 6 + b"test"))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_SELF_INFO
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_query_returns_device_info(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
with _mock_repos_and_runtime():
|
||||||
|
# First do APP_START to initialize session state
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_APP_START]) + b"\x03" + b" " * 6 + b"test"))
|
||||||
|
await writer.drain()
|
||||||
|
await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_DEVICE_QUERY, 0x03])))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_DEVICE_INFO
|
||||||
|
assert resp[1] == PROXY_FW_VER
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contacts_flow(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
with _mock_repos_and_runtime():
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_GET_CONTACTS])))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# Should get CONTACT_START
|
||||||
|
resp1 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp1[0] == RESP_CONTACT_START
|
||||||
|
count = int.from_bytes(resp1[1:5], "little")
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# One contact
|
||||||
|
resp2 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp2[0] == 0x03 # RESP_CONTACT
|
||||||
|
|
||||||
|
# CONTACT_END
|
||||||
|
resp3 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp3[0] == RESP_CONTACT_END
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_time(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_GET_DEVICE_TIME])))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_CURRENT_TIME
|
||||||
|
assert len(resp) == 5
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_has_connection(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
with _mock_repos_and_runtime():
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_HAS_CONNECTION])))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_OK
|
||||||
|
val = int.from_bytes(resp[1:5], "little")
|
||||||
|
assert val == 1
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_channel_returns_error(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_GET_CHANNEL, 5])))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_ERR
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_then_get_channel(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
# SET_CHANNEL: cmd(1) + idx(1) + name(32) + secret(16) = 50
|
||||||
|
name = b"mychan" + b"\x00" * 26 # 32 bytes
|
||||||
|
secret = b"\xdd" * 16
|
||||||
|
cmd = bytes([CMD_SET_CHANNEL, 2]) + name + secret
|
||||||
|
writer.write(_frame_cmd(cmd))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_OK
|
||||||
|
|
||||||
|
# GET_CHANNEL for slot 2
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_GET_CHANNEL, 2])))
|
||||||
|
await writer.drain()
|
||||||
|
resp2 = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp2[0] == 0x12 # RESP_CHANNEL_INFO
|
||||||
|
assert resp2[1] == 2 # idx
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_next_empty(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_SYNC_NEXT_MESSAGE])))
|
||||||
|
await writer.drain()
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == RESP_NO_MORE_MSGS
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_dispatch_queues_message(self, harness):
|
||||||
|
reader, writer = await harness.connect()
|
||||||
|
try:
|
||||||
|
with _mock_repos_and_runtime():
|
||||||
|
# APP_START to init session
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_APP_START]) + b"\x03" + b" " * 6 + b"test"))
|
||||||
|
await writer.drain()
|
||||||
|
await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
|
||||||
|
# Set a channel so CHAN messages can be routed
|
||||||
|
name = b"\x00" * 32
|
||||||
|
secret = bytes.fromhex("cc" * 16)
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_SET_CHANNEL, 0]) + name + secret))
|
||||||
|
await writer.drain()
|
||||||
|
await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
|
||||||
|
# Simulate a broadcast event
|
||||||
|
await dispatch_event(
|
||||||
|
"message",
|
||||||
|
{
|
||||||
|
"type": "CHAN",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": "cc" * 16,
|
||||||
|
"text": "hello from event",
|
||||||
|
"sender_timestamp": 1700000000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should receive PUSH_MSG_WAITING
|
||||||
|
resp = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert resp[0] == PUSH_MSG_WAITING
|
||||||
|
|
||||||
|
# Pull the message
|
||||||
|
writer.write(_frame_cmd(bytes([CMD_SYNC_NEXT_MESSAGE])))
|
||||||
|
await writer.drain()
|
||||||
|
msg = await asyncio.wait_for(_read_response(reader), timeout=3)
|
||||||
|
assert msg[0] == 0x11 # RESP_CHANNEL_MSG_RECV_V3
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_clients_isolated(self, harness):
|
||||||
|
r1, w1 = await harness.connect()
|
||||||
|
r2, w2 = await harness.connect()
|
||||||
|
try:
|
||||||
|
# Both can get time independently
|
||||||
|
w1.write(_frame_cmd(bytes([CMD_GET_DEVICE_TIME])))
|
||||||
|
w2.write(_frame_cmd(bytes([CMD_GET_DEVICE_TIME])))
|
||||||
|
await w1.drain()
|
||||||
|
await w2.drain()
|
||||||
|
|
||||||
|
resp1 = await asyncio.wait_for(_read_response(r1), timeout=3)
|
||||||
|
resp2 = await asyncio.wait_for(_read_response(r2), timeout=3)
|
||||||
|
assert resp1[0] == RESP_CURRENT_TIME
|
||||||
|
assert resp2[0] == RESP_CURRENT_TIME
|
||||||
|
finally:
|
||||||
|
w1.close()
|
||||||
|
w2.close()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Tests for app.tcp_proxy.protocol — frame parsing, helpers, constants."""
|
||||||
|
|
||||||
|
from app.tcp_proxy.protocol import (
|
||||||
|
ERR_NOT_FOUND,
|
||||||
|
ERR_UNSUPPORTED,
|
||||||
|
FRAME_RX,
|
||||||
|
FRAME_TX,
|
||||||
|
RESP_ERR,
|
||||||
|
RESP_OK,
|
||||||
|
FrameParser,
|
||||||
|
build_error,
|
||||||
|
build_ok,
|
||||||
|
encode_path_byte,
|
||||||
|
frame_response,
|
||||||
|
pad,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── frame_response ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameResponse:
|
||||||
|
def test_empty_payload(self):
|
||||||
|
result = frame_response(b"")
|
||||||
|
assert result == bytes([FRAME_RX, 0x00, 0x00])
|
||||||
|
|
||||||
|
def test_short_payload(self):
|
||||||
|
result = frame_response(b"\x05\x01")
|
||||||
|
assert result[0] == FRAME_RX
|
||||||
|
size = int.from_bytes(result[1:3], "little")
|
||||||
|
assert size == 2
|
||||||
|
assert result[3:] == b"\x05\x01"
|
||||||
|
|
||||||
|
def test_larger_payload(self):
|
||||||
|
payload = b"\xaa" * 200
|
||||||
|
result = frame_response(payload)
|
||||||
|
assert result[0] == FRAME_RX
|
||||||
|
size = int.from_bytes(result[1:3], "little")
|
||||||
|
assert size == 200
|
||||||
|
assert result[3:] == payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_ok / build_error ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildOk:
|
||||||
|
def test_no_value(self):
|
||||||
|
assert build_ok() == bytes([RESP_OK])
|
||||||
|
|
||||||
|
def test_with_value(self):
|
||||||
|
result = build_ok(42)
|
||||||
|
assert result[0] == RESP_OK
|
||||||
|
assert int.from_bytes(result[1:5], "little") == 42
|
||||||
|
|
||||||
|
def test_zero_value(self):
|
||||||
|
result = build_ok(0)
|
||||||
|
assert len(result) == 5
|
||||||
|
assert int.from_bytes(result[1:5], "little") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildError:
|
||||||
|
def test_default_code(self):
|
||||||
|
assert build_error() == bytes([RESP_ERR, ERR_UNSUPPORTED])
|
||||||
|
|
||||||
|
def test_not_found(self):
|
||||||
|
assert build_error(ERR_NOT_FOUND) == bytes([RESP_ERR, ERR_NOT_FOUND])
|
||||||
|
|
||||||
|
|
||||||
|
# ── pad ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPad:
|
||||||
|
def test_shorter_data(self):
|
||||||
|
result = pad(b"AB", 5)
|
||||||
|
assert result == b"AB\x00\x00\x00"
|
||||||
|
assert len(result) == 5
|
||||||
|
|
||||||
|
def test_exact_data(self):
|
||||||
|
assert pad(b"ABCDE", 5) == b"ABCDE"
|
||||||
|
|
||||||
|
def test_longer_data(self):
|
||||||
|
assert pad(b"ABCDEFGH", 5) == b"ABCDE"
|
||||||
|
|
||||||
|
def test_empty_data(self):
|
||||||
|
assert pad(b"", 3) == b"\x00\x00\x00"
|
||||||
|
|
||||||
|
|
||||||
|
# ── encode_path_byte ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncodePathByte:
|
||||||
|
def test_flood_negative_hop(self):
|
||||||
|
assert encode_path_byte(-1, 0) == 0xFF
|
||||||
|
|
||||||
|
def test_flood_negative_mode(self):
|
||||||
|
assert encode_path_byte(0, -1) == 0xFF
|
||||||
|
|
||||||
|
def test_flood_both_negative(self):
|
||||||
|
assert encode_path_byte(-1, -1) == 0xFF
|
||||||
|
|
||||||
|
def test_zero_hops_mode_zero(self):
|
||||||
|
assert encode_path_byte(0, 0) == 0x00
|
||||||
|
|
||||||
|
def test_three_hops_mode_one(self):
|
||||||
|
# mode=1 → bits 6-7 = 01 → 0x40; hops=3 → 0x03
|
||||||
|
assert encode_path_byte(3, 1) == 0x43
|
||||||
|
|
||||||
|
def test_max_hops_mode_two(self):
|
||||||
|
# mode=2 → bits 6-7 = 10 → 0x80; hops=63 → 0x3F
|
||||||
|
assert encode_path_byte(63, 2) == 0xBF
|
||||||
|
|
||||||
|
|
||||||
|
# ── FrameParser ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameParser:
|
||||||
|
def test_single_complete_frame(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
# 0x3C + 2-byte LE size (3) + 3 bytes payload
|
||||||
|
data = bytes([FRAME_TX, 0x03, 0x00, 0xAA, 0xBB, 0xCC])
|
||||||
|
payloads = parser.feed(data)
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert payloads[0] == b"\xaa\xbb\xcc"
|
||||||
|
|
||||||
|
def test_two_frames_in_one_chunk(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
frame1 = bytes([FRAME_TX, 0x02, 0x00, 0x01, 0x02])
|
||||||
|
frame2 = bytes([FRAME_TX, 0x01, 0x00, 0xFF])
|
||||||
|
payloads = parser.feed(frame1 + frame2)
|
||||||
|
assert len(payloads) == 2
|
||||||
|
assert payloads[0] == b"\x01\x02"
|
||||||
|
assert payloads[1] == b"\xff"
|
||||||
|
|
||||||
|
def test_split_across_chunks(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
full = bytes([FRAME_TX, 0x04, 0x00, 0x01, 0x02, 0x03, 0x04])
|
||||||
|
# Split in the middle of the payload
|
||||||
|
p1 = parser.feed(full[:5])
|
||||||
|
assert p1 == []
|
||||||
|
p2 = parser.feed(full[5:])
|
||||||
|
assert len(p2) == 1
|
||||||
|
assert p2[0] == b"\x01\x02\x03\x04"
|
||||||
|
|
||||||
|
def test_split_in_header(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
full = bytes([FRAME_TX, 0x01, 0x00, 0xAA])
|
||||||
|
p1 = parser.feed(full[:2]) # marker + first size byte
|
||||||
|
assert p1 == []
|
||||||
|
p2 = parser.feed(full[2:]) # second size byte + payload
|
||||||
|
assert len(p2) == 1
|
||||||
|
assert p2[0] == b"\xaa"
|
||||||
|
|
||||||
|
def test_bad_marker_skipped(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
junk = b"\x00\x00\x00"
|
||||||
|
good = bytes([FRAME_TX, 0x01, 0x00, 0xBB])
|
||||||
|
payloads = parser.feed(junk + good)
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert payloads[0] == b"\xbb"
|
||||||
|
|
||||||
|
def test_oversized_frame_skipped(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
# Size = 400 (> MAX_FRAME_SIZE=300)
|
||||||
|
bad = bytes([FRAME_TX, 0x90, 0x01])
|
||||||
|
good = bytes([FRAME_TX, 0x01, 0x00, 0xCC])
|
||||||
|
payloads = parser.feed(bad + good)
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert payloads[0] == b"\xcc"
|
||||||
|
|
||||||
|
def test_empty_feed(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
assert parser.feed(b"") == []
|
||||||
|
|
||||||
|
def test_byte_at_a_time(self):
|
||||||
|
parser = FrameParser()
|
||||||
|
full = bytes([FRAME_TX, 0x02, 0x00, 0xDE, 0xAD])
|
||||||
|
payloads = []
|
||||||
|
for b in full:
|
||||||
|
payloads.extend(parser.feed(bytes([b])))
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert payloads[0] == b"\xde\xad"
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
"""Tests for app.tcp_proxy.session — ProxySession command handlers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tcp_proxy.protocol import (
|
||||||
|
CMD_APP_START,
|
||||||
|
CMD_DEVICE_QUERY,
|
||||||
|
CMD_GET_BATT_AND_STORAGE,
|
||||||
|
CMD_GET_CHANNEL,
|
||||||
|
CMD_GET_CONTACT_BY_KEY,
|
||||||
|
CMD_GET_CONTACTS,
|
||||||
|
CMD_GET_DEVICE_TIME,
|
||||||
|
CMD_HAS_CONNECTION,
|
||||||
|
CMD_RESET_PATH,
|
||||||
|
CMD_SEND_CHANNEL_TXT_MSG,
|
||||||
|
CMD_SEND_TXT_MSG,
|
||||||
|
CMD_SET_CHANNEL,
|
||||||
|
CMD_SYNC_NEXT_MESSAGE,
|
||||||
|
ERR_NOT_FOUND,
|
||||||
|
PROXY_FW_VER,
|
||||||
|
PUSH_MSG_WAITING,
|
||||||
|
RESP_BATTERY,
|
||||||
|
RESP_CHANNEL_MSG_RECV_V3,
|
||||||
|
RESP_CONTACT_END,
|
||||||
|
RESP_CONTACT_MSG_RECV_V3,
|
||||||
|
RESP_CONTACT_START,
|
||||||
|
RESP_CURRENT_TIME,
|
||||||
|
RESP_DEVICE_INFO,
|
||||||
|
RESP_ERR,
|
||||||
|
RESP_MSG_SENT,
|
||||||
|
RESP_NO_MORE_MSGS,
|
||||||
|
RESP_OK,
|
||||||
|
RESP_SELF_INFO,
|
||||||
|
encode_path_byte,
|
||||||
|
)
|
||||||
|
from app.tcp_proxy.session import ProxySession
|
||||||
|
|
||||||
|
EXAMPLE_KEY = "ab" * 32
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session() -> tuple[ProxySession, list[bytes]]:
|
||||||
|
"""Create a ProxySession with a capturing writer."""
|
||||||
|
reader = AsyncMock(spec=asyncio.StreamReader)
|
||||||
|
writer = MagicMock(spec=asyncio.StreamWriter)
|
||||||
|
writer.get_extra_info.return_value = ("127.0.0.1", 12345)
|
||||||
|
|
||||||
|
sent: list[bytes] = []
|
||||||
|
|
||||||
|
def capture_write(data: bytes):
|
||||||
|
sent.append(data)
|
||||||
|
|
||||||
|
writer.write = capture_write
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
session = ProxySession(reader, writer)
|
||||||
|
return session, sent
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_payloads(sent: list[bytes]) -> list[bytes]:
|
||||||
|
"""Extract payloads from framed response bytes."""
|
||||||
|
payloads = []
|
||||||
|
for frame in sent:
|
||||||
|
assert frame[0] == 0x3E
|
||||||
|
size = int.from_bytes(frame[1:3], "little")
|
||||||
|
payloads.append(frame[3 : 3 + size])
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
|
||||||
|
def _make_contact(public_key: str = EXAMPLE_KEY, name: str = "Alice", **kw):
|
||||||
|
return MagicMock(
|
||||||
|
model_dump=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"public_key": public_key,
|
||||||
|
"name": name,
|
||||||
|
"type": 1,
|
||||||
|
"favorite": True,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": 0,
|
||||||
|
"lat": 0.0,
|
||||||
|
"lon": 0.0,
|
||||||
|
"first_seen": int(time.time()),
|
||||||
|
**kw,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel(key: str = "cc" * 16, name: str = "test", favorite: bool = True):
|
||||||
|
return MagicMock(
|
||||||
|
model_dump=MagicMock(return_value={"key": key, "name": name, "favorite": favorite})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_settings(last_message_times=None):
|
||||||
|
return MagicMock(last_message_times=last_message_times or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_radio_runtime(connected: bool = True, self_info: dict | None = None):
|
||||||
|
rt = MagicMock()
|
||||||
|
rt.is_connected = connected
|
||||||
|
mc = MagicMock()
|
||||||
|
mc.self_info = self_info or {
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"name": "TestNode",
|
||||||
|
"tx_power": 20,
|
||||||
|
"max_tx_power": 22,
|
||||||
|
"adv_lat": 0.0,
|
||||||
|
"adv_lon": 0.0,
|
||||||
|
"radio_freq": 915.0,
|
||||||
|
"radio_bw": 250.0,
|
||||||
|
"radio_sf": 10,
|
||||||
|
"radio_cr": 7,
|
||||||
|
}
|
||||||
|
rt.meshcore = mc
|
||||||
|
return rt
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppStart:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_self_info(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
contacts = [_make_contact()]
|
||||||
|
channels = [_make_channel()]
|
||||||
|
settings = _make_settings()
|
||||||
|
rt = _mock_radio_runtime()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.repository.ContactRepository") as cr,
|
||||||
|
patch("app.repository.ChannelRepository") as chr_,
|
||||||
|
patch("app.repository.AppSettingsRepository") as sr,
|
||||||
|
patch("app.services.radio_runtime.radio_runtime", rt),
|
||||||
|
):
|
||||||
|
cr.get_favorites = AsyncMock(return_value=contacts)
|
||||||
|
chr_.get_all = AsyncMock(return_value=channels)
|
||||||
|
sr.get = AsyncMock(return_value=settings)
|
||||||
|
|
||||||
|
await session._cmd_app_start(bytes([CMD_APP_START]))
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert payloads[0][0] == RESP_SELF_INFO
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_populates_contacts_and_channels(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
contacts = [_make_contact(), _make_contact(public_key="cd" * 32, name="Bob")]
|
||||||
|
channels = [_make_channel(), _make_channel(key="dd" * 16, name="ch2")]
|
||||||
|
settings = _make_settings()
|
||||||
|
rt = _mock_radio_runtime()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.repository.ContactRepository") as cr,
|
||||||
|
patch("app.repository.ChannelRepository") as chr_,
|
||||||
|
patch("app.repository.AppSettingsRepository") as sr,
|
||||||
|
patch("app.services.radio_runtime.radio_runtime", rt),
|
||||||
|
):
|
||||||
|
cr.get_favorites = AsyncMock(return_value=contacts)
|
||||||
|
chr_.get_all = AsyncMock(return_value=channels)
|
||||||
|
sr.get = AsyncMock(return_value=settings)
|
||||||
|
|
||||||
|
await session._cmd_app_start(bytes([CMD_APP_START]))
|
||||||
|
|
||||||
|
assert len(session.contacts) == 2
|
||||||
|
# Only favorite channels are slotted
|
||||||
|
assert len(session.channel_slots) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceQuery:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_device_info(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
rt = _mock_radio_runtime()
|
||||||
|
|
||||||
|
with patch("app.services.radio_runtime.radio_runtime", rt):
|
||||||
|
await session._cmd_device_query(bytes([CMD_DEVICE_QUERY, 0x03]))
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_DEVICE_INFO
|
||||||
|
assert payloads[0][1] == PROXY_FW_VER
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetContacts:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_start_contacts_end(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
contacts = [_make_contact()]
|
||||||
|
|
||||||
|
with patch("app.repository.ContactRepository") as cr:
|
||||||
|
cr.get_favorites = AsyncMock(return_value=contacts)
|
||||||
|
await session._cmd_get_contacts(bytes([CMD_GET_CONTACTS]))
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_CONTACT_START
|
||||||
|
count = int.from_bytes(payloads[0][1:5], "little")
|
||||||
|
assert count == 1
|
||||||
|
# Middle payload(s) are contacts
|
||||||
|
assert payloads[-1][0] == RESP_CONTACT_END
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetContactByKey:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_found(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.contacts = [
|
||||||
|
{
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"type": 1,
|
||||||
|
"name": "Alice",
|
||||||
|
"favorite": True,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": 0,
|
||||||
|
"lat": 0.0,
|
||||||
|
"lon": 0.0,
|
||||||
|
"first_seen": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
cmd = bytes([CMD_GET_CONTACT_BY_KEY]) + bytes.fromhex(EXAMPLE_KEY)
|
||||||
|
await session._cmd_get_contact_by_key(cmd)
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert len(payloads) == 1
|
||||||
|
assert payloads[0][0] == 0x03 # RESP_CONTACT
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_found(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.contacts = []
|
||||||
|
|
||||||
|
cmd = bytes([CMD_GET_CONTACT_BY_KEY]) + bytes.fromhex(EXAMPLE_KEY)
|
||||||
|
await session._cmd_get_contact_by_key(cmd)
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_ERR
|
||||||
|
assert payloads[0][1] == ERR_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetChannel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_found(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
key = "cc" * 16
|
||||||
|
session.channel_slots = {0: key}
|
||||||
|
session.channels = [{"key": key, "name": "test"}]
|
||||||
|
|
||||||
|
await session._cmd_get_channel(bytes([CMD_GET_CHANNEL, 0]))
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == 0x12 # RESP_CHANNEL_INFO
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_slot_returns_error(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.channel_slots = {}
|
||||||
|
|
||||||
|
await session._cmd_get_channel(bytes([CMD_GET_CHANNEL, 5]))
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_ERR
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetChannel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_slot_mapping(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
|
||||||
|
name = b"test" + b"\x00" * 28 # 32 bytes
|
||||||
|
secret = b"\xaa" * 16
|
||||||
|
cmd = bytes([CMD_SET_CHANNEL, 3]) + name + secret
|
||||||
|
await session._cmd_set_channel(cmd)
|
||||||
|
|
||||||
|
assert session.channel_slots[3] == "aa" * 16
|
||||||
|
assert session.key_to_idx["aa" * 16] == 3
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_OK
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cleans_stale_mapping(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
|
||||||
|
# Pre-load slot 0 with key_a
|
||||||
|
session.channel_slots[0] = "aa" * 16
|
||||||
|
session.key_to_idx["aa" * 16] = 0
|
||||||
|
|
||||||
|
# Overwrite slot 0 with key_b
|
||||||
|
name = b"\x00" * 32
|
||||||
|
secret_b = b"\xbb" * 16
|
||||||
|
cmd = bytes([CMD_SET_CHANNEL, 0]) + name + secret_b
|
||||||
|
await session._cmd_set_channel(cmd)
|
||||||
|
|
||||||
|
assert session.channel_slots[0] == "bb" * 16
|
||||||
|
assert "aa" * 16 not in session.key_to_idx
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendDm:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_msg_sent_and_ack(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.contacts = [{"public_key": EXAMPLE_KEY}]
|
||||||
|
|
||||||
|
# CMD_SEND_TXT_MSG: cmd(1) + txt_type(1) + attempt(1) + ts(4) + prefix(6) + text
|
||||||
|
prefix = bytes.fromhex(EXAMPLE_KEY[:12])
|
||||||
|
cmd = (
|
||||||
|
bytes([CMD_SEND_TXT_MSG, 0, 0])
|
||||||
|
+ int(time.time()).to_bytes(4, "little")
|
||||||
|
+ prefix
|
||||||
|
+ b"Hello"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(session, "_do_send_dm", new_callable=AsyncMock):
|
||||||
|
await session._cmd_send_dm(cmd)
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_MSG_SENT
|
||||||
|
assert payloads[1][0] == 0x82 # PUSH_ACK
|
||||||
|
# ACK code should match
|
||||||
|
ack_from_sent = payloads[0][2:6]
|
||||||
|
ack_from_push = payloads[1][1:5]
|
||||||
|
assert ack_from_sent == ack_from_push
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_long_text_with_prefix(self):
|
||||||
|
"""6-byte prefix + long text (>26 chars) must resolve correctly."""
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.contacts = [{"public_key": EXAMPLE_KEY}]
|
||||||
|
|
||||||
|
prefix = bytes.fromhex(EXAMPLE_KEY[:12])
|
||||||
|
long_text = b"A" * 50 # well over 26 chars
|
||||||
|
cmd = (
|
||||||
|
bytes([CMD_SEND_TXT_MSG, 0, 0])
|
||||||
|
+ int(time.time()).to_bytes(4, "little")
|
||||||
|
+ prefix
|
||||||
|
+ long_text
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(session, "_do_send_dm", new_callable=AsyncMock) as mock_send:
|
||||||
|
await session._cmd_send_dm(cmd)
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_MSG_SENT # not ERR
|
||||||
|
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
call_key, call_text = mock_send.call_args[0]
|
||||||
|
assert call_key == EXAMPLE_KEY
|
||||||
|
assert call_text == "A" * 50
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendChannel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_ok(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
key = "cc" * 16
|
||||||
|
session.channel_slots = {0: key}
|
||||||
|
session.channels = [{"key": key, "name": "test"}]
|
||||||
|
|
||||||
|
cmd = (
|
||||||
|
bytes([CMD_SEND_CHANNEL_TXT_MSG, 0, 0])
|
||||||
|
+ int(time.time()).to_bytes(4, "little")
|
||||||
|
+ b"Hello"
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_channel = MagicMock(name="test")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.repository.ChannelRepository.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=fake_channel,
|
||||||
|
),
|
||||||
|
patch.object(session, "_do_send_channel", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await session._cmd_send_channel(cmd)
|
||||||
|
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_OK
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimpleCommands:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_time(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
await session._cmd_get_time(bytes([CMD_GET_DEVICE_TIME]))
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_CURRENT_TIME
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_battery(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
await session._cmd_battery(bytes([CMD_GET_BATT_AND_STORAGE]))
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_BATTERY
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_has_connection(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
rt = _mock_radio_runtime(connected=True)
|
||||||
|
with patch("app.services.radio_runtime.radio_runtime", rt):
|
||||||
|
await session._cmd_has_connection(bytes([CMD_HAS_CONNECTION]))
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_OK
|
||||||
|
val = int.from_bytes(payloads[0][1:5], "little")
|
||||||
|
assert val == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ok_stub(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
await session._cmd_ok_stub(bytes([CMD_RESET_PATH]))
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_OK
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncNext:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_queue(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
await session._cmd_sync_next(bytes([CMD_SYNC_NEXT_MESSAGE]))
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == RESP_NO_MORE_MSGS
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dequeues_message(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
fake_msg = bytes([0x10, 0x00, 0x00, 0x00]) + b"\xaa" * 10
|
||||||
|
session._msg_queue.append(fake_msg)
|
||||||
|
|
||||||
|
await session._cmd_sync_next(bytes([CMD_SYNC_NEXT_MESSAGE]))
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0] == fake_msg
|
||||||
|
assert len(session._msg_queue) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractPathMeta:
|
||||||
|
"""Tests for _extract_path_meta static helper."""
|
||||||
|
|
||||||
|
def test_no_paths(self):
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta({"paths": None})
|
||||||
|
assert snr == 0
|
||||||
|
assert path_byte == 0 # 0 hops, mode 0
|
||||||
|
|
||||||
|
def test_empty_paths_list(self):
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta({"paths": []})
|
||||||
|
assert snr == 0
|
||||||
|
assert path_byte == 0
|
||||||
|
|
||||||
|
def test_one_byte_hops(self):
|
||||||
|
"""2 hops at 1-byte hash mode → path_byte = (0 << 6) | 2 = 0x02."""
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta(
|
||||||
|
{
|
||||||
|
"paths": [{"path": "aabb", "path_len": 2, "snr": None, "rssi": None}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert path_byte == encode_path_byte(2, 0)
|
||||||
|
assert path_byte == 0x02
|
||||||
|
|
||||||
|
def test_two_byte_hops(self):
|
||||||
|
"""3 hops at 2-byte hash mode → path_byte = (1 << 6) | 3 = 0x43."""
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta(
|
||||||
|
{
|
||||||
|
"paths": [{"path": "aabbccddee11", "path_len": 3, "snr": None, "rssi": None}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert path_byte == encode_path_byte(3, 1)
|
||||||
|
assert path_byte == 0x43
|
||||||
|
|
||||||
|
def test_three_byte_hops(self):
|
||||||
|
"""1 hop at 3-byte hash mode → path_byte = (2 << 6) | 1 = 0x81."""
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta(
|
||||||
|
{
|
||||||
|
"paths": [{"path": "aabbcc", "path_len": 1, "snr": None, "rssi": None}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert path_byte == encode_path_byte(1, 2)
|
||||||
|
assert path_byte == 0x81
|
||||||
|
|
||||||
|
def test_snr_encoded(self):
|
||||||
|
"""SNR is encoded as int8(snr * 4)."""
|
||||||
|
snr, _ = ProxySession._extract_path_meta(
|
||||||
|
{
|
||||||
|
"paths": [{"path": "aa", "path_len": 1, "snr": -5.25, "rssi": -100}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert snr == (-21) & 0xFF # -5.25 * 4 = -21 → unsigned byte
|
||||||
|
|
||||||
|
def test_zero_hops_empty_path(self):
|
||||||
|
"""0 hops, empty path → path_byte 0."""
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta(
|
||||||
|
{
|
||||||
|
"paths": [{"path": "", "path_len": 0, "snr": None, "rssi": None}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert path_byte == 0
|
||||||
|
|
||||||
|
def test_legacy_no_path_len(self):
|
||||||
|
"""path_len=None falls back to inferring from hex length (1-byte hops)."""
|
||||||
|
snr, path_byte = ProxySession._extract_path_meta(
|
||||||
|
{
|
||||||
|
"paths": [{"path": "aabb", "path_len": None, "snr": None, "rssi": None}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Inferred: 2 hops, path is 2 bytes → 1-byte hash → mode 0
|
||||||
|
assert path_byte == encode_path_byte(2, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventHandlers:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_priv_message_queued(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
data = {
|
||||||
|
"type": "PRIV",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": EXAMPLE_KEY,
|
||||||
|
"text": "hello",
|
||||||
|
"sender_timestamp": 1700000000,
|
||||||
|
}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
|
||||||
|
assert len(session._msg_queue) == 1
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == PUSH_MSG_WAITING
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_priv_message_path_encoding(self):
|
||||||
|
"""DM frame encodes path_len byte from message path data."""
|
||||||
|
session, sent = _make_session()
|
||||||
|
data = {
|
||||||
|
"type": "PRIV",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": EXAMPLE_KEY,
|
||||||
|
"text": "hi",
|
||||||
|
"sender_timestamp": 1700000000,
|
||||||
|
"paths": [{"path": "aabb", "path_len": 2, "snr": 3.0, "rssi": -80}],
|
||||||
|
}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
|
||||||
|
frame = session._msg_queue[0]
|
||||||
|
assert frame[0] == RESP_CONTACT_MSG_RECV_V3
|
||||||
|
snr_byte = frame[1]
|
||||||
|
assert snr_byte == 12 # 3.0 * 4
|
||||||
|
# path_len byte is at offset 10 (after: type, snr, 2 reserved, 6 prefix)
|
||||||
|
path_byte = frame[10]
|
||||||
|
assert path_byte == encode_path_byte(2, 0) # 2 hops, 1-byte hash
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chan_message_queued(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
key = "cc" * 16
|
||||||
|
session.key_to_idx = {key: 0}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"type": "CHAN",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": key.upper(), # test case normalization
|
||||||
|
"text": "hello",
|
||||||
|
"sender_timestamp": 1700000000,
|
||||||
|
}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
|
||||||
|
assert len(session._msg_queue) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chan_message_path_encoding(self):
|
||||||
|
"""Channel frame encodes path_len byte correctly instead of 0xFF."""
|
||||||
|
session, sent = _make_session()
|
||||||
|
key = "cc" * 16
|
||||||
|
session.key_to_idx = {key: 0}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"type": "CHAN",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": key,
|
||||||
|
"text": "hello",
|
||||||
|
"sender_timestamp": 1700000000,
|
||||||
|
"paths": [{"path": "aabbccdd", "path_len": 2, "snr": -2.5, "rssi": -90}],
|
||||||
|
}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
|
||||||
|
frame = session._msg_queue[0]
|
||||||
|
assert frame[0] == RESP_CHANNEL_MSG_RECV_V3
|
||||||
|
snr_byte = frame[1]
|
||||||
|
assert snr_byte == (-10) & 0xFF # -2.5 * 4
|
||||||
|
# path_len byte is at offset 5 (after: type, snr, 2 reserved, channel_idx)
|
||||||
|
path_byte = frame[5]
|
||||||
|
assert path_byte == encode_path_byte(2, 1) # 2 hops, 2-byte hash
|
||||||
|
assert path_byte != 0xFF # Must NOT be the old wrong value
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chan_message_no_paths_defaults_zero(self):
|
||||||
|
"""Channel message with no path data uses 0 (not 0xFF)."""
|
||||||
|
session, sent = _make_session()
|
||||||
|
key = "cc" * 16
|
||||||
|
session.key_to_idx = {key: 0}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"type": "CHAN",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": key,
|
||||||
|
"text": "hello",
|
||||||
|
"sender_timestamp": 1700000000,
|
||||||
|
}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
|
||||||
|
frame = session._msg_queue[0]
|
||||||
|
path_byte = frame[5]
|
||||||
|
assert path_byte == 0 # 0 hops, not 0xFF
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outgoing_message_ignored(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
data = {"type": "PRIV", "outgoing": True, "conversation_key": EXAMPLE_KEY}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
assert len(session._msg_queue) == 0
|
||||||
|
assert len(sent) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chan_unmapped_dropped(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.key_to_idx = {}
|
||||||
|
data = {
|
||||||
|
"type": "CHAN",
|
||||||
|
"outgoing": False,
|
||||||
|
"conversation_key": "ff" * 16,
|
||||||
|
"text": "hello",
|
||||||
|
"sender_timestamp": 0,
|
||||||
|
}
|
||||||
|
await session.on_event_message(data)
|
||||||
|
assert len(session._msg_queue) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_contact_event_updates_existing_cache(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
# Contact must already be in favorites cache to receive pushes
|
||||||
|
session.contacts = [
|
||||||
|
{
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"name": "Old",
|
||||||
|
"type": 1,
|
||||||
|
"favorite": True,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": 0,
|
||||||
|
"lat": 0.0,
|
||||||
|
"lon": 0.0,
|
||||||
|
"first_seen": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"type": 1,
|
||||||
|
"name": "Updated",
|
||||||
|
"favorite": True,
|
||||||
|
"direct_path": None,
|
||||||
|
"direct_path_len": -1,
|
||||||
|
"direct_path_hash_mode": -1,
|
||||||
|
"last_advert": 100,
|
||||||
|
"lat": 0.0,
|
||||||
|
"lon": 0.0,
|
||||||
|
"first_seen": 0,
|
||||||
|
}
|
||||||
|
await session.on_event_contact(data)
|
||||||
|
assert len(session.contacts) == 1
|
||||||
|
assert session.contacts[0]["name"] == "Updated"
|
||||||
|
# Should have sent a PUSH_NEW_ADVERT
|
||||||
|
payloads = _extract_payloads(sent)
|
||||||
|
assert payloads[0][0] == 0x8A # PUSH_NEW_ADVERT
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_contact_event_ignored_for_non_favorites(self):
|
||||||
|
session, sent = _make_session()
|
||||||
|
session.contacts = []
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"public_key": EXAMPLE_KEY,
|
||||||
|
"type": 1,
|
||||||
|
"name": "Stranger",
|
||||||
|
"favorite": False,
|
||||||
|
}
|
||||||
|
await session.on_event_contact(data)
|
||||||
|
assert len(session.contacts) == 0
|
||||||
|
assert len(sent) == 0
|
||||||
@@ -1399,7 +1399,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -1408,9 +1408,9 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1453,11 +1453,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1533,7 +1533,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.0"
|
version = "3.12.3"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiomqtt" },
|
{ name = "aiomqtt" },
|
||||||
@@ -1582,7 +1582,7 @@ dev = [
|
|||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "pip-licenses", specifier = ">=5.0.0" },
|
{ name = "pip-licenses", specifier = ">=5.0.0" },
|
||||||
{ name = "pyright", specifier = ">=1.1.390" },
|
{ name = "pyright", specifier = ">=1.1.390" },
|
||||||
{ name = "pytest", specifier = ">=9.0.2" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
{ name = "pytest-xdist", specifier = ">=3.0" },
|
{ name = "pytest-xdist", specifier = ">=3.0" },
|
||||||
{ name = "ruff", specifier = ">=0.8.0" },
|
{ name = "ruff", specifier = ">=0.8.0" },
|
||||||
@@ -1590,7 +1590,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.33.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@@ -1598,9 +1598,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user