mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 20:06:13 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89cee49725 | |||
| b37ce89c96 | |||
| f0b7842c60 | |||
| 4eb29f376e | |||
| 82a6553539 | |||
| 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:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend-checks:
|
||||
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
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-aur:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -321,6 +321,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/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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -379,6 +380,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||
| 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 |
|
||||
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||
@@ -503,6 +505,8 @@ 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_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_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). |
|
||||
|
||||
**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
|
||||
|
||||
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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
|
||||
* 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)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag channel names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* 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
|
||||
* 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).
|
||||
@@ -83,7 +85,7 @@ Access the app at http://localhost:8000.
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
> [!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]
|
||||
> 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -240,6 +242,7 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
|
||||
## Where To Go Next
|
||||
|
||||
- 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)
|
||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||
|
||||
|
||||
+49
-6
@@ -1,24 +1,67 @@
|
||||
# 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 |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
||||
| `__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_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 ([docs](#force-channel-slot-reconfigure)) |
|
||||
| `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:
|
||||
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
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...`
|
||||
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
||||
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
|
||||
- Example runtime topic: `meshcore/ae92577bae6c/gps`
|
||||
- Example MQTT 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:
|
||||
|
||||
- Home Assistant entity IDs
|
||||
- Home Assistant discovery topics under `homeassistant/...`
|
||||
- MQTT discovery topics under `homeassistant/...`
|
||||
- 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`
|
||||
- `Published Topic Summary`
|
||||
- `Published topic summary`
|
||||
|
||||
## What Gets Created
|
||||
|
||||
@@ -49,8 +47,8 @@ Always created. Updates every 60 seconds.
|
||||
|
||||
| Entity | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
|
||||
| `sensor.meshcore_<radio_node_id>_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
||||
| `binary_sensor.<radio_name>_connected` | Connectivity | Radio online/offline |
|
||||
| `sensor.<radio_name>_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
||||
|
||||
### Repeater Devices
|
||||
|
||||
@@ -60,13 +58,13 @@ Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm'
|
||||
|
||||
| Entity | Type | Unit | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
|
||||
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
|
||||
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
|
||||
| `sensor.meshcore_<repeater_node_id>_uptime` | Duration | s | Uptime since last reboot |
|
||||
| `sensor.<repeater_name>_battery_voltage` | Voltage | V | Battery level |
|
||||
| `sensor.<repeater_name>_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||
| `sensor.<repeater_name>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||
| `sensor.<repeater_name>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||
| `sensor.<repeater_name>_packets_received` | -- | count | Total packets received |
|
||||
| `sensor.<repeater_name>_packets_sent` | -- | count | Total packets sent |
|
||||
| `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.
|
||||
|
||||
@@ -76,11 +74,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `device_tracker.meshcore_<contact_node_id>` | GPS position (latitude/longitude) |
|
||||
| `device_tracker.<contact_name>` | GPS position (latitude/longitude) |
|
||||
|
||||
### 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 |
|
||||
|-----------|---------|-------------|
|
||||
@@ -95,9 +93,9 @@ A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, f
|
||||
|
||||
## 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`
|
||||
- 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.
|
||||
|
||||
**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
|
||||
@@ -125,22 +123,22 @@ automation:
|
||||
- alias: "Repeater battery low"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
entity_id: sensor.hilltop_battery_voltage
|
||||
below: 3.8
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "Repeater Battery Low"
|
||||
message: >-
|
||||
{{ state_attr('sensor.meshcore_aabbccddeeff_battery_voltage', 'friendly_name') }}
|
||||
is at {{ states('sensor.meshcore_aabbccddeeff_battery_voltage') }}V
|
||||
{{ state_attr('sensor.hilltop_battery_voltage', 'friendly_name') }}
|
||||
is at {{ states('sensor.hilltop_battery_voltage') }}V
|
||||
```
|
||||
|
||||
### Radio offline alert
|
||||
|
||||
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
|
||||
@@ -148,7 +146,7 @@ automation:
|
||||
- alias: "Radio offline"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.meshcore_aabbccddeeff_connected
|
||||
entity_id: binary_sensor.myradio_connected
|
||||
to: "off"
|
||||
for: "00:05:00"
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -174,7 +172,7 @@ automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
entity_id: event.myradio_messages
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -196,7 +194,7 @@ automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
entity_id: event.myradio_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -218,7 +216,7 @@ automation:
|
||||
- alias: "DM from Alice"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
entity_id: event.myradio_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -239,7 +237,7 @@ automation:
|
||||
- alias: "Keyword alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_aabbccddeeff_messages
|
||||
entity_id: event.myradio_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -264,7 +262,7 @@ Add a sensor card to any dashboard:
|
||||
|
||||
```yaml
|
||||
type: sensor
|
||||
entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
entity: sensor.hilltop_battery_voltage
|
||||
name: "Hilltop Repeater Battery"
|
||||
```
|
||||
|
||||
@@ -274,14 +272,171 @@ Or an entities card for multiple repeaters:
|
||||
type: entities
|
||||
title: "Repeater Status"
|
||||
entities:
|
||||
- entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
- entity: sensor.hilltop_battery_voltage
|
||||
name: "Hilltop"
|
||||
- entity: sensor.meshcore_ccdd11223344_battery_voltage
|
||||
- entity: sensor.valley_battery_voltage
|
||||
name: "Valley"
|
||||
- entity: sensor.meshcore_eeff55667788_battery_voltage
|
||||
- entity: sensor.ridge_battery_voltage
|
||||
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
|
||||
|
||||
### Devices don't appear in HA
|
||||
|
||||
+9
-1
@@ -196,6 +196,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
||||
### Radio
|
||||
- `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`
|
||||
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
|
||||
- `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/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||
@@ -266,6 +267,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
||||
- `POST /settings/blocked-names/toggle`
|
||||
- `POST /settings/tracked-telemetry/toggle`
|
||||
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
||||
- `POST /settings/muted-channels/toggle`
|
||||
|
||||
### Fanout
|
||||
- `GET /fanout` — list all fanout configs
|
||||
@@ -396,7 +398,7 @@ tests/
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── 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_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
@@ -415,7 +417,13 @@ tests/
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_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_telemetry_interval.py # Telemetry interval scheduling math
|
||||
├── test_version_info.py # Version/build metadata resolution
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
|
||||
@@ -26,6 +26,8 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
enable_local_private_key_export: bool = False
|
||||
load_with_autoevict: bool = False
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
+18
-2
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
favorite INTEGER DEFAULT 0,
|
||||
muted INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -112,7 +113,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
discovery_blocked_types TEXT DEFAULT '[]',
|
||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||
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);
|
||||
|
||||
@@ -134,6 +138,18 @@ CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||
data TEXT NOT NULL,
|
||||
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
|
||||
|
||||
+127
-36
@@ -11,6 +11,28 @@ from app.path_utils import split_path_hex
|
||||
|
||||
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]:
|
||||
"""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))
|
||||
|
||||
|
||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
"""Build a human-readable notification body from message data."""
|
||||
def _compute_hops(data: dict) -> tuple[str, str, int]:
|
||||
"""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", "")
|
||||
text = get_fanout_message_text(data)
|
||||
sender_name = data.get("sender_name") or "Unknown"
|
||||
|
||||
via = ""
|
||||
if include_path:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||
path_str = first_path.get("path", "")
|
||||
path_len = first_path.get("path_len")
|
||||
else:
|
||||
path_str = None
|
||||
path_len = None
|
||||
|
||||
if msg_type == "PRIV" and path_str is None:
|
||||
via = " **via:** [`direct`]"
|
||||
elif path_str is not None:
|
||||
path_str = path_str.strip().lower()
|
||||
if path_str == "":
|
||||
via = " **via:** [`direct`]"
|
||||
else:
|
||||
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||
hops = split_path_hex(path_str, hop_count)
|
||||
if hops:
|
||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
||||
via = f" **via:** [{hop_list}]"
|
||||
|
||||
if msg_type == "PRIV":
|
||||
return f"**DM:** {sender_name}: {text}{via}"
|
||||
|
||||
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
|
||||
return f"**{channel_name}:** {sender_name}: {text}{via}"
|
||||
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
|
||||
try:
|
||||
return _apply_format(fmt, variables)
|
||||
except Exception:
|
||||
logger.warning("Apprise format string error, falling back to default")
|
||||
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||
return _apply_format(default, variables)
|
||||
|
||||
|
||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||
@@ -106,8 +178,27 @@ class AppriseModule(FanoutModule):
|
||||
return
|
||||
|
||||
preserve_identity = self.config.get("preserve_identity", True)
|
||||
include_path = self.config.get("include_path", True)
|
||||
body = _format_body(data, include_path=include_path)
|
||||
|
||||
# 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:
|
||||
success = await asyncio.to_thread(
|
||||
|
||||
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
device_info = await self._fetch_device_info()
|
||||
# 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
|
||||
|
||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||
|
||||
+34
-9
@@ -81,6 +81,15 @@ _REPEATER_SENSORS: list[dict[str, Any]] = [
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "recv_errors",
|
||||
"name": "RX Errors",
|
||||
"object_id": "recv_errors",
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "uptime_seconds",
|
||||
"name": "Uptime",
|
||||
@@ -115,6 +124,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
||||
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]:
|
||||
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
||||
payload: dict[str, Any] = {}
|
||||
@@ -123,8 +148,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
if field is not None:
|
||||
payload[field] = data.get(field)
|
||||
|
||||
for sensor in data.get("lpp_sensors", []) or []:
|
||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
|
||||
payload[key] = sensor.get("value")
|
||||
|
||||
return payload
|
||||
@@ -139,16 +163,19 @@ def _lpp_discovery_configs(
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build HA discovery configs for a repeater's LPP sensors."""
|
||||
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")
|
||||
channel = sensor.get("channel", 0)
|
||||
field = _lpp_sensor_key(type_name, channel)
|
||||
meta = _LPP_HA_META.get(type_name, {})
|
||||
|
||||
nid = _node_id(pub_key)
|
||||
object_id = field
|
||||
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] = {
|
||||
"name": name,
|
||||
@@ -450,7 +477,7 @@ def _message_event_discovery_config(
|
||||
device = _device_payload(radio_key, radio_name, "Radio")
|
||||
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
||||
cfg: dict[str, Any] = {
|
||||
"name": "MeshCore Messages",
|
||||
"name": "Messages",
|
||||
"unique_id": f"meshcore_{nid}_messages",
|
||||
"device": device,
|
||||
"state_topic": f"{prefix}/{nid}/events/message",
|
||||
@@ -731,9 +758,7 @@ class MqttHaModule(FanoutModule):
|
||||
payload = _repeater_telemetry_payload(data)
|
||||
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
||||
rediscover = False
|
||||
for sensor in 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))
|
||||
for _, key, _ in _assign_lpp_keys(lpp_sensors):
|
||||
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
||||
if expected_topic not in self._discovery_topics:
|
||||
rediscover = True
|
||||
|
||||
+19
@@ -180,6 +180,25 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
||||
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
|
||||
app.include_router(health.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)")
|
||||
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(
|
||||
default=False,
|
||||
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
|
||||
favorite: bool = False
|
||||
muted: bool = False
|
||||
|
||||
|
||||
class ChannelMessageCounts(BaseModel):
|
||||
@@ -536,6 +540,7 @@ class RepeaterStatusResponse(BaseModel):
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from pywebpush import WebPushException
|
||||
|
||||
from app.push.send import send_push
|
||||
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.settings import AppSettingsRepository
|
||||
|
||||
@@ -102,6 +103,15 @@ class PushManager:
|
||||
if state_key not in push_conversations:
|
||||
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:
|
||||
subs = await PushSubscriptionRepository.get_all()
|
||||
except Exception:
|
||||
|
||||
+301
-74
@@ -43,9 +43,41 @@ from app.websocket import broadcast_error, broadcast_event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CHANNELS = 40
|
||||
_GET_CONTACTS_TIMEOUT = 10
|
||||
|
||||
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]:
|
||||
"""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)
|
||||
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:
|
||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||
return False
|
||||
@@ -430,6 +462,16 @@ async def ensure_default_channels() -> None:
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""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
|
||||
# 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
|
||||
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(
|
||||
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||
expected_mc=mc,
|
||||
autoevict=autoevict,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -1045,7 +1103,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
synced = 0
|
||||
|
||||
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:
|
||||
logger.error(
|
||||
@@ -1108,12 +1166,24 @@ async def _reconcile_radio_contacts_in_background(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
autoevict: bool = False,
|
||||
) -> 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)
|
||||
removed = 0
|
||||
loaded = 0
|
||||
failed = 0
|
||||
table_full = False
|
||||
autoevict_next_index = 0
|
||||
autoevict_full_pass_retries = 0
|
||||
_MAX_AUTOEVICT_RETRIES = 3
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -1121,18 +1191,32 @@ async def _reconcile_radio_contacts_in_background(
|
||||
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||
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()
|
||||
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 = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||
}
|
||||
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 = [
|
||||
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(
|
||||
"Background contact reconcile complete: %d contacts on radio working set",
|
||||
len(radio_contacts),
|
||||
@@ -1140,6 +1224,8 @@ async def _reconcile_radio_contacts_in_background(
|
||||
break
|
||||
|
||||
progressed = False
|
||||
autoevict_pass_complete = False
|
||||
autoevict_pass_failed = False
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"background_contact_reconcile",
|
||||
@@ -1153,100 +1239,237 @@ async def _reconcile_radio_contacts_in_background(
|
||||
|
||||
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||
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 = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||
}
|
||||
|
||||
for public_key in list(radio_contacts):
|
||||
if budget <= 0:
|
||||
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 not autoevict:
|
||||
for public_key in list(radio_contacts):
|
||||
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}
|
||||
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:
|
||||
add_payload = contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(add_payload)
|
||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error adding contact %s during background reconcile: %s",
|
||||
"Error removing 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
|
||||
not_found = (
|
||||
remove_result.type != EventType.OK
|
||||
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
|
||||
else:
|
||||
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(
|
||||
"Failed to add contact %s during background reconcile: %s%s",
|
||||
"Failed to remove contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
remove_result.payload,
|
||||
)
|
||||
|
||||
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:
|
||||
logger.debug("Background contact reconcile yielding: radio busy")
|
||||
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||
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)
|
||||
if not progressed:
|
||||
continue
|
||||
@@ -1269,6 +1492,7 @@ def start_background_contact_reconciliation(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
autoevict: bool = False,
|
||||
) -> None:
|
||||
"""Start or replace the background contact reconcile task for the current radio."""
|
||||
global _contact_reconcile_task
|
||||
@@ -1280,11 +1504,13 @@ def start_background_contact_reconciliation(
|
||||
_reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts=initial_radio_contacts,
|
||||
expected_mc=expected_mc,
|
||||
autoevict=autoevict,
|
||||
)
|
||||
)
|
||||
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),
|
||||
" (autoevict mode)" if autoevict else "",
|
||||
)
|
||||
|
||||
|
||||
@@ -1595,6 +1821,7 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"flood_dups": status.get("flood_dups", 0),
|
||||
"direct_dups": status.get("direct_dups", 0),
|
||||
"full_events": status.get("full_evts", 0),
|
||||
"recv_errors": status.get("recv_errors"),
|
||||
}
|
||||
|
||||
# Best-effort LPP sensor fetch — failure here does not fail the overall
|
||||
|
||||
@@ -28,7 +28,7 @@ class ChannelRepository:
|
||||
async with db.readonly() as conn:
|
||||
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
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -45,6 +45,7 @@ class ChannelRepository:
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
muted=bool(row["muted"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -53,7 +54,7 @@ class ChannelRepository:
|
||||
async with db.readonly() as conn:
|
||||
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
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -69,6 +70,7 @@ class ChannelRepository:
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
muted=bool(row["muted"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -84,6 +86,17 @@ class ChannelRepository:
|
||||
rowcount = cursor.rowcount
|
||||
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
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
|
||||
@@ -701,6 +701,7 @@ class MessageRepository:
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
AND COALESCE(c.muted, 0) = 0
|
||||
{blocked_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
|
||||
@@ -315,6 +315,7 @@ async def create_contact(
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=lower_key,
|
||||
name=request.name,
|
||||
type=request.type,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
|
||||
+34
-5
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
remediation_flags: dict[str, bool]
|
||||
|
||||
|
||||
class DebugContactAudit(BaseModel):
|
||||
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
|
||||
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):
|
||||
max_radio_contacts: int
|
||||
auto_decrypt_dm_on_advert: bool
|
||||
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
system: DebugSystemInfo
|
||||
application: DebugApplicationInfo
|
||||
environment: DebugEnvironment
|
||||
health: DebugHealthSummary
|
||||
settings: DebugAppSettings
|
||||
runtime: DebugRuntimeInfo
|
||||
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | 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:
|
||||
return DebugAppSettings(
|
||||
max_radio_contacts=app_settings.max_radio_contacts,
|
||||
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
captured_at=datetime.now(UTC).isoformat(),
|
||||
system=_build_system_info(),
|
||||
application=_build_application_info(),
|
||||
environment=_build_environment(),
|
||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
settings=_build_debug_app_settings(app_settings),
|
||||
runtime=DebugRuntimeInfo(
|
||||
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
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(
|
||||
total_dms=message_totals["total_dms"],
|
||||
|
||||
@@ -259,6 +259,21 @@ def _validate_apprise_config(config: dict) -> None:
|
||||
if not urls or not urls.strip():
|
||||
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:
|
||||
"""Validate webhook config blob."""
|
||||
|
||||
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
|
||||
# Core stats
|
||||
battery_mv: int | None = None
|
||||
uptime_secs: int | None = None
|
||||
queue_len: int | None = None
|
||||
errors: int | None = None
|
||||
# Radio stats
|
||||
noise_floor: 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"),
|
||||
"battery_mv": raw_stats.get("battery_mv"),
|
||||
"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"),
|
||||
"last_rssi": raw_stats.get("last_rssi"),
|
||||
"last_snr": raw_stats.get("last_snr"),
|
||||
|
||||
@@ -385,6 +385,30 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
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")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
|
||||
@@ -133,6 +133,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
recv_errors=status.get("recv_errors"),
|
||||
)
|
||||
|
||||
# Record to telemetry history as a JSON blob (best-effort)
|
||||
|
||||
@@ -78,6 +78,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
recv_errors=status.get("recv_errors"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,15 @@ class FavoriteToggleResponse(BaseModel):
|
||||
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):
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||
"""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)
|
||||
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)
|
||||
else:
|
||||
radio_manager.note_channel_slot_used(channel_key)
|
||||
@@ -856,7 +862,7 @@ async def send_channel_message_to_channel(
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass # Never let watchdog setup failure break the send
|
||||
logger.error("Echo watchdog setup failed", exc_info=True)
|
||||
|
||||
return outgoing_message
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
# MESHCORE_DISABLE_BOTS: "true"
|
||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||
# MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false"
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
|
||||
+3
-6
@@ -75,7 +75,6 @@ frontend/src/
|
||||
├── utils/
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
||||
│ ├── messageParser.ts # Message text → rendered segments
|
||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
@@ -132,6 +131,9 @@ frontend/src/
|
||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── 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
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
@@ -178,7 +180,6 @@ frontend/src/
|
||||
├── prefetch.test.ts
|
||||
├── rawPacketDetailModal.test.tsx
|
||||
├── rawPacketFeedView.test.tsx
|
||||
├── radioPresets.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterDashboard.test.tsx
|
||||
├── 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.
|
||||
|
||||
### `utils/favorites.ts`
|
||||
|
||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
||||
|
||||
## Types and Contracts (`types.ts`)
|
||||
|
||||
`AppSettings` currently includes:
|
||||
|
||||
Generated
+190
-1198
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.12.0",
|
||||
"version": "3.12.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -61,12 +61,12 @@
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.10",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.19.0",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.0"
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -25,7 +25,13 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
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 { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
@@ -207,6 +213,12 @@ export function App() {
|
||||
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(
|
||||
async (type: 'channel' | 'contact', id: string) => {
|
||||
// Optimistically toggle the favorite flag
|
||||
@@ -343,6 +355,20 @@ export function App() {
|
||||
useFaviconBadge(unreadCounts, mentions, 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(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
setChannelUnreadMarker(null);
|
||||
@@ -408,6 +434,7 @@ export function App() {
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
channelsRef,
|
||||
activeConversationRef,
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
@@ -586,6 +613,7 @@ export function App() {
|
||||
onRunTracePath: api.requestRadioTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onToggleMute: handleToggleMute,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||
|
||||
+9
-2
@@ -96,6 +96,7 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
|
||||
setPrivateKey: (privateKey: string) =>
|
||||
fetchJson<{ status: string }>('/radio/private-key', {
|
||||
method: 'PUT',
|
||||
@@ -157,10 +158,10 @@ export const api = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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', {
|
||||
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) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||
@@ -343,6 +344,12 @@ export const api = {
|
||||
body: JSON.stringify({ type, id }),
|
||||
}),
|
||||
|
||||
toggleChannelMute: (key: string) =>
|
||||
fetchJson<{ key: string; muted: boolean }>('/settings/muted-channels/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
|
||||
// Fanout
|
||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||
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 { StatusBar } from './StatusBar';
|
||||
@@ -140,6 +148,26 @@ export function AppShell({
|
||||
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 = (
|
||||
<nav
|
||||
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}
|
||||
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
||||
/>
|
||||
<div data-toast-anchor="statusbar" aria-hidden="true" />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||
@@ -344,7 +373,11 @@ export function AppShell({
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
<Toaster position="top-right" />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
offset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||
mobileOffset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
@@ -32,6 +32,7 @@ interface ChatHeaderProps {
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onToggleMute?: (key: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
@@ -57,6 +58,7 @@ export function ChatHeader({
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
onToggleFavorite,
|
||||
onToggleMute,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onDeleteChannel,
|
||||
@@ -313,95 +315,125 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
|
||||
<div className="relative" ref={notifDropdownRef}>
|
||||
<button
|
||||
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"
|
||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||
title="Notification settings"
|
||||
aria-label="Notification settings"
|
||||
aria-expanded={notifDropdownOpen}
|
||||
>
|
||||
<Bell
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
notificationsEnabled || pushEnabledForConversation
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
{(notificationsSupported ||
|
||||
pushSupported ||
|
||||
(conversation.type === 'channel' && onToggleMute)) &&
|
||||
!activeContactIsRoomServer && (
|
||||
<div className="relative" ref={notifDropdownRef}>
|
||||
<button
|
||||
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"
|
||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||
title="Notification settings"
|
||||
aria-label="Notification settings"
|
||||
aria-expanded={notifDropdownOpen}
|
||||
>
|
||||
{activeChannel?.muted ? (
|
||||
<BellOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
) : (
|
||||
<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'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{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 && (
|
||||
<>
|
||||
</button>
|
||||
{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={!!pushEnabledForConversation}
|
||||
onChange={onTogglePush}
|
||||
checked={notificationsEnabled}
|
||||
disabled={notificationsPermission === 'denied'}
|
||||
onChange={onToggleNotifications}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Web Push (beta testing)
|
||||
Desktop notifications (legacy)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{pushSubscribed
|
||||
? 'Alerts even when the browser is closed'
|
||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||
{notificationsPermission === 'denied'
|
||||
? 'Blocked by browser — check site permissions'
|
||||
: 'Alerts while this tab is open'}
|
||||
</span>
|
||||
</div>
|
||||
</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
|
||||
browser, a snakeoil certificate may not be sufficient.
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{pushSupported && onTogglePush && (
|
||||
<>
|
||||
<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={!!pushEnabledForConversation}
|
||||
onChange={onTogglePush}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Web Push (beta testing)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{pushSubscribed
|
||||
? 'Alerts even when the browser is closed'
|
||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||
</span>
|
||||
</div>
|
||||
</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
|
||||
browser, a snakeoil certificate may not be sufficient.
|
||||
</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 && (
|
||||
<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"
|
||||
|
||||
@@ -62,6 +62,7 @@ interface ConversationPaneProps {
|
||||
) => Promise<RadioTraceResponse>;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onToggleMute: (key: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
@@ -143,6 +144,7 @@ export function ConversationPane({
|
||||
onRunTracePath,
|
||||
onPathDiscovery,
|
||||
onToggleFavorite,
|
||||
onToggleMute,
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
@@ -307,6 +309,7 @@ export function ConversationPane({
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleMute={onToggleMute}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
@@ -323,6 +326,7 @@ export function ConversationPane({
|
||||
{activeContactIsRoom && activeContact && (
|
||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||
)}
|
||||
{showRoomChat && <div data-toast-anchor="conversation" aria-hidden="true" />}
|
||||
{showRoomChat && (
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
|
||||
@@ -32,7 +32,12 @@ interface NewMessageModalProps {
|
||||
nonce: number;
|
||||
} | null;
|
||||
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>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||
@@ -91,6 +96,7 @@ export function NewMessageModal({
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactType, setContactType] = useState(1);
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||
@@ -103,6 +109,7 @@ export function NewMessageModal({
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactType(1);
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
@@ -161,7 +168,7 @@ export function NewMessageModal({
|
||||
setError('Name and public key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical, contactType);
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
setError('Channel name and key are required');
|
||||
@@ -293,6 +300,19 @@ export function NewMessageModal({
|
||||
placeholder="64-character hex public key"
|
||||
/>
|
||||
</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 value="new-channel" className="mt-4 space-y-4">
|
||||
|
||||
@@ -300,6 +300,7 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
<div data-toast-anchor="conversation" aria-hidden="true" />
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Cable,
|
||||
ChartNetwork,
|
||||
CheckCheck,
|
||||
@@ -49,6 +50,7 @@ type ConversationRow = {
|
||||
unreadCount: number;
|
||||
isMention: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
muted?: boolean;
|
||||
contact?: Contact;
|
||||
};
|
||||
|
||||
@@ -250,6 +252,10 @@ export function Sidebar({
|
||||
if (isPublicChannelKey(a.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') {
|
||||
const timeA = getLastMessageTime('channel', a.key);
|
||||
const timeB = getLastMessageTime('channel', b.key);
|
||||
@@ -530,9 +536,10 @@ export function Sidebar({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
unreadCount: getUnreadCount('channel', channel.key),
|
||||
isMention: hasMention('channel', channel.key),
|
||||
unreadCount: channel.muted ? 0 : getUnreadCount('channel', channel.key),
|
||||
isMention: channel.muted ? false : hasMention('channel', channel.key),
|
||||
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
||||
muted: channel.muted,
|
||||
});
|
||||
|
||||
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="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{row.muted ? (
|
||||
<span aria-label="Channel muted" title="Channel muted">
|
||||
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'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'
|
||||
) : (
|
||||
<>
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'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>
|
||||
</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 type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||
|
||||
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
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 (
|
||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{data.sensors.map((sensor, i) => (
|
||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
|
||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,12 @@ import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../t
|
||||
|
||||
const MAX_TRACKED = 8;
|
||||
|
||||
type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||
type BuiltinMetric =
|
||||
| 'battery_volts'
|
||||
| 'noise_floor_dbm'
|
||||
| 'packets'
|
||||
| 'recv_errors'
|
||||
| 'uptime_seconds';
|
||||
|
||||
interface MetricConfig {
|
||||
label: string;
|
||||
@@ -29,6 +34,7 @@ const BUILTIN_METRIC_CONFIG: Record<BuiltinMetric, MetricConfig> = {
|
||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||
recv_errors: { label: 'RX Errors', unit: '', color: '#ef4444' },
|
||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||
};
|
||||
|
||||
@@ -37,9 +43,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
|
||||
// Stable color rotation for dynamic LPP sensors
|
||||
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
||||
|
||||
/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */
|
||||
function lppKey(s: TelemetryLppSensor): string {
|
||||
return `lpp_${s.type_name}_ch${s.channel}`;
|
||||
/** Assign disambiguated flat keys to an array of LPP sensors.
|
||||
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
|
||||
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 = {
|
||||
@@ -93,11 +108,10 @@ export function TelemetryHistoryPane({
|
||||
|
||||
// Discover unique LPP sensors across all history entries
|
||||
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 s of e.data.lpp_sensors ?? []) {
|
||||
const k = lppKey(s);
|
||||
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel });
|
||||
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
|
||||
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
|
||||
}
|
||||
}
|
||||
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
||||
@@ -106,7 +120,8 @@ export function TelemetryHistoryPane({
|
||||
const label =
|
||||
info.type_name.charAt(0).toUpperCase() +
|
||||
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);
|
||||
result.push({
|
||||
key: k,
|
||||
@@ -145,12 +160,13 @@ export function TelemetryHistoryPane({
|
||||
noise_floor_dbm: d.noise_floor_dbm,
|
||||
packets_received: d.packets_received,
|
||||
packets_sent: d.packets_sent,
|
||||
recv_errors: d.recv_errors ?? undefined,
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
// 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') {
|
||||
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||
}
|
||||
}
|
||||
return point;
|
||||
|
||||
@@ -91,6 +91,9 @@ export function TelemetryPane({
|
||||
label="Duplicates"
|
||||
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
||||
/>
|
||||
{data.recv_errors != null && (
|
||||
<KvRow label="RX Errors" value={data.recv_errors.toLocaleString()} />
|
||||
)}
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
||||
<KvRow label="Debug Flags" value={data.full_events} />
|
||||
|
||||
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
|
||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
|
||||
const label = formatLppLabel(sensor.type_name);
|
||||
export function LppSensorRow({
|
||||
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) {
|
||||
// Multi-value sensor (GPS, accelerometer, etc.)
|
||||
|
||||
@@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
4: 'Sensor',
|
||||
};
|
||||
|
||||
type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString([], {
|
||||
year: 'numeric',
|
||||
@@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number {
|
||||
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 {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [lastHeardAfter, setLastHeardAfter] = useState('');
|
||||
const [lastHeardBefore, setLastHeardBefore] = useState('');
|
||||
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 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(() => {
|
||||
setStep('select');
|
||||
setSelectedKeys(new Set());
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setLastHeardAfter('');
|
||||
setLastHeardBefore('');
|
||||
setTypeFilter('all');
|
||||
setSortField('first_seen');
|
||||
setSortDir('desc');
|
||||
lastClickedKeyRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
||||
let list = [...contacts];
|
||||
if (typeFilter !== 'all') {
|
||||
list = list.filter((c) => c.type === typeFilter);
|
||||
}
|
||||
@@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({
|
||||
const end = datetimeToUnix(endDate);
|
||||
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;
|
||||
}, [contacts, typeFilter, startDate, endDate]);
|
||||
}, [
|
||||
contacts,
|
||||
typeFilter,
|
||||
startDate,
|
||||
endDate,
|
||||
lastHeardAfter,
|
||||
lastHeardBefore,
|
||||
sortField,
|
||||
sortDir,
|
||||
]);
|
||||
|
||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||
@@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||
@@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({
|
||||
|
||||
{step === 'select' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Show</label>
|
||||
<select
|
||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||
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"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="1">Clients</option>
|
||||
<option value="2">Repeaters</option>
|
||||
<option value="3">Room Servers</option>
|
||||
<option value="4">Sensors</option>
|
||||
</select>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Show</label>
|
||||
<select
|
||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||
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"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="1">Clients</option>
|
||||
<option value="2">Repeaters</option>
|
||||
<option value="3">Room Servers</option>
|
||||
<option value="4">Sensors</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created after</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created after</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
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 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 className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Last heard after</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={lastHeardAfter}
|
||||
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 className="flex gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
@@ -211,7 +322,7 @@ export function BulkDeleteContactsModal({
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||
{(startDate || endDate) && ' (filtered)'}
|
||||
{hasFilters && ' (filtered)'}
|
||||
{' · '}
|
||||
{selectedKeys.size} selected
|
||||
</div>
|
||||
@@ -219,17 +330,51 @@ export function BulkDeleteContactsModal({
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<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">Name</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
<SortableHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -265,6 +410,9 @@ export function BulkDeleteContactsModal({
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -298,6 +446,7 @@ export function BulkDeleteContactsModal({
|
||||
<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 hidden sm:table-cell">Created</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Last heard</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -315,6 +464,9 @@ export function BulkDeleteContactsModal({
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</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>
|
||||
))}
|
||||
</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 { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
@@ -278,7 +287,9 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
config: {
|
||||
urls: '',
|
||||
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' },
|
||||
},
|
||||
@@ -1194,7 +1205,7 @@ function MqttHaConfigEditor({
|
||||
<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" />
|
||||
Published Topic Summary
|
||||
Published topic summary
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
||||
<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({
|
||||
config,
|
||||
scope,
|
||||
@@ -2387,6 +2483,10 @@ function AppriseConfigEditor({
|
||||
onChange: (config: 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 (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.include_path !== false}
|
||||
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
<Separator />
|
||||
|
||||
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||
|
||||
<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" />
|
||||
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>
|
||||
</label>
|
||||
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
|
||||
</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 />
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { MapPinned } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { 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 { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||
import type {
|
||||
@@ -17,8 +26,116 @@ import type {
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryTarget,
|
||||
RadioStatsSnapshot,
|
||||
} 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({
|
||||
config,
|
||||
health,
|
||||
@@ -279,11 +396,6 @@ export function SettingsRadioSection({
|
||||
|
||||
try {
|
||||
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)) {
|
||||
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) => {
|
||||
setAdvertisingMode(mode);
|
||||
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 =
|
||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||
const connectionActionLabel =
|
||||
@@ -414,6 +710,9 @@ export function SettingsRadioSection({
|
||||
</span>
|
||||
</div>
|
||||
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
||||
|
||||
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -678,6 +977,37 @@ export function SettingsRadioSection({
|
||||
Some settings may require a reboot to take effect on some radios.
|
||||
</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 />
|
||||
|
||||
{/* ── Messaging ── */}
|
||||
@@ -733,9 +1063,9 @@ export function SettingsRadioSection({
|
||||
placeholder="MyRegion"
|
||||
/>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
||||
drop it. Leave empty to disable.
|
||||
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
|
||||
region can forward the traffic, while repeaters configured to deny other regions may drop
|
||||
it. Leave empty to disable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -795,6 +1125,18 @@ export function SettingsRadioSection({
|
||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||
Recommended: 24 hours or higher.
|
||||
</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 className="space-y-2">
|
||||
@@ -907,6 +1249,44 @@ export function SettingsRadioSection({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ export function useContactsAndChannels({
|
||||
}, []);
|
||||
|
||||
const handleCreateContact = useCallback(
|
||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
||||
async (name: string, publicKey: string, tryHistorical: boolean, type?: number) => {
|
||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical, type);
|
||||
const data = await fetchAllContacts();
|
||||
setContacts(data);
|
||||
|
||||
|
||||
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
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 {
|
||||
if (!a || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
||||
|
||||
// Check if THIS browser has an active push subscription and match it
|
||||
// to a backend record.
|
||||
navigator.serviceWorker.ready
|
||||
// to a backend record. Use a timeout so we don't hang forever when the
|
||||
// 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(async (sub) => {
|
||||
const existing = await subsPromise;
|
||||
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
const refreshSubscriptions = useCallback(async () => {
|
||||
try {
|
||||
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();
|
||||
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
||||
return subs;
|
||||
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
vapidKeyRef.current = 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();
|
||||
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
||||
const requiresRecreate =
|
||||
@@ -188,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
|
||||
console.error('Push subscribe failed:', err);
|
||||
toast.error('Failed to enable push notifications', {
|
||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||
duration: 8_000,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -35,6 +35,7 @@ interface UseRealtimeAppStateArgs {
|
||||
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
||||
blockedKeysRef: MutableRefObject<string[]>;
|
||||
blockedNamesRef: MutableRefObject<string[]>;
|
||||
channelsRef: MutableRefObject<Channel[]>;
|
||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||
recordMessageEvent: (args: {
|
||||
@@ -94,6 +95,7 @@ export function useRealtimeAppState({
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
channelsRef,
|
||||
activeConversationRef,
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
@@ -191,16 +193,24 @@ export function useRealtimeAppState({
|
||||
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 } =
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
|
||||
// Register service worker for Web Push (requires secure context)
|
||||
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() {
|
||||
mockFetch.mockReset();
|
||||
global.fetch = mockFetch;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
@@ -26,6 +27,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
|
||||
@@ -15,7 +15,15 @@ import { api } from '../api';
|
||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||
|
||||
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 {
|
||||
|
||||
@@ -7,7 +7,15 @@ import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
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 = () => {};
|
||||
|
||||
@@ -90,6 +90,7 @@ const channel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
@@ -142,6 +143,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
throw new Error('unused');
|
||||
}),
|
||||
onToggleFavorite: vi.fn(async () => {}),
|
||||
onToggleMute: vi.fn(async () => {}),
|
||||
onDeleteContact: vi.fn(async () => {}),
|
||||
onDeleteChannel: vi.fn(async () => {}),
|
||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||
|
||||
@@ -1057,7 +1057,7 @@ describe('SettingsFanoutSection', () => {
|
||||
selectCreateIntegration('Home Assistant MQTT Discovery');
|
||||
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(/Repeater One/));
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('NewMessageModal form reset', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
|
||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false, 1);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const BOT_CHANNEL: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
|
||||
@@ -15,6 +15,7 @@ const TEST_CHANNEL: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
|
||||
@@ -438,6 +438,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
recv_errors: 5,
|
||||
telemetry_history: [],
|
||||
};
|
||||
|
||||
@@ -707,6 +708,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
recv_errors: null,
|
||||
telemetry_history: [liveEntry],
|
||||
};
|
||||
|
||||
@@ -742,6 +744,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
recv_errors: null,
|
||||
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const defaultProps = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
on_radio: true,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
key: '11111111111111111111111111111111',
|
||||
@@ -202,6 +203,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
key: '22222222222222222222222222222222',
|
||||
@@ -210,6 +212,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ describe('useContactsAndChannels', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
existing_count: 1,
|
||||
|
||||
@@ -34,6 +34,7 @@ const publicChannel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const sentMessage: Message = {
|
||||
|
||||
@@ -11,6 +11,7 @@ const publicChannel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
||||
|
||||
@@ -14,7 +14,15 @@ import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
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 {
|
||||
|
||||
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
|
||||
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 () => {
|
||||
const oldSubscription = activeSubscription;
|
||||
mocks.api.getPushSubscriptions
|
||||
|
||||
@@ -29,6 +29,7 @@ const publicChannel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const incomingDm: Message = {
|
||||
@@ -65,6 +66,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
||||
setContacts,
|
||||
blockedKeysRef: { current: [] as string[] },
|
||||
channelsRef: { current: [publicChannel] },
|
||||
blockedNamesRef: { current: [] as string[] },
|
||||
activeConversationRef: { current: null as Conversation | null },
|
||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||
|
||||
@@ -36,6 +36,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
|
||||
timestamp: number | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
queue_len: number | null;
|
||||
errors: number | null;
|
||||
noise_floor: number | null;
|
||||
last_rssi: number | null;
|
||||
last_snr: number | null;
|
||||
@@ -223,6 +225,7 @@ export interface Channel {
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
favorite: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelMessageCounts {
|
||||
@@ -435,6 +438,7 @@ export interface RepeaterStatusResponse {
|
||||
flood_dups: number;
|
||||
direct_dups: number;
|
||||
full_events: number;
|
||||
recv_errors: number | null;
|
||||
telemetry_history: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
|
||||
@@ -29,3 +29,7 @@ MESHCORE_DISABLE_BOTS=true
|
||||
# HTTP Basic Auth (recommended when bots are enabled)
|
||||
#MESHCORE_BASIC_AUTH_USERNAME=
|
||||
#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
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.12.0"
|
||||
version = "3.12.3"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore==2.3.2",
|
||||
"meshcore==2.3.7",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.8",
|
||||
"boto3>=1.38.0",
|
||||
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pip-licenses>=5.0.0",
|
||||
"pytest>=9.0.2",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-xdist>=3.0",
|
||||
"ruff>=0.8.0",
|
||||
|
||||
@@ -81,7 +81,8 @@ echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -ne "${BLUE}[build]${NC} "
|
||||
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}=== Phase 2 complete ===${NC}"
|
||||
|
||||
@@ -63,9 +63,10 @@ test.describe('Apprise integration settings', () => {
|
||||
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
||||
await expect(preserveIdentity).toBeVisible();
|
||||
|
||||
// Verify include routing path checkbox is checked by default
|
||||
const includePath = page.getByText('Include routing path in notifications');
|
||||
await expect(includePath).toBeVisible();
|
||||
// Verify format textareas are present under Message Format heading
|
||||
await expect(page.getByText('Message Format')).toBeVisible();
|
||||
await expect(page.locator('#fanout-apprise-fmt-dm')).toBeVisible();
|
||||
await expect(page.locator('#fanout-apprise-fmt-chan')).toBeVisible();
|
||||
|
||||
// Rename it
|
||||
const nameInput = page.locator('#fanout-edit-name');
|
||||
@@ -94,7 +95,8 @@ test.describe('Apprise integration settings', () => {
|
||||
config: {
|
||||
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
||||
preserve_identity: false,
|
||||
include_path: false,
|
||||
body_format_dm: '{sender_name}: {text}',
|
||||
body_format_channel: '{channel_name} | {sender_name}: {text}',
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
@@ -113,18 +115,18 @@ test.describe('Apprise integration settings', () => {
|
||||
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
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
|
||||
.getByText('Preserve identity on Discord')
|
||||
.locator('xpath=ancestor::label[1]')
|
||||
.locator('input[type="checkbox"]');
|
||||
await expect(preserveCheckbox).not.toBeChecked();
|
||||
|
||||
const pathCheckbox = page
|
||||
.getByText('Include routing path in notifications')
|
||||
.locator('xpath=ancestor::label[1]')
|
||||
.locator('input[type="checkbox"]');
|
||||
await expect(pathCheckbox).not.toBeChecked();
|
||||
// Verify format textareas reflect our custom formats
|
||||
const dmFormat = page.locator('#fanout-apprise-fmt-dm');
|
||||
await expect(dmFormat).toHaveValue('{sender_name}: {text}');
|
||||
const chanFormat = page.locator('#fanout-apprise-fmt-chan');
|
||||
await expect(chanFormat).toHaveValue('{channel_name} | {sender_name}: {text}');
|
||||
|
||||
// Go back
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
|
||||
@@ -52,6 +52,12 @@ test.describe('Favorites persistence', () => {
|
||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||
})
|
||||
.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:
|
||||
"""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):
|
||||
"""Debug radio probe should redact advertised lat/lon from 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 "max_channels" 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["database"]["total_dms"] == 0
|
||||
assert payload["database"]["total_channel_messages"] == 0
|
||||
|
||||
@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = MagicMock()
|
||||
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 (
|
||||
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": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
||||
),
|
||||
patch.object(
|
||||
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["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):
|
||||
"""LWT and on-connect status should use the same topic path."""
|
||||
pub = CommunityMqttPublisher()
|
||||
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = None
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = MagicMock()
|
||||
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}
|
||||
|
||||
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": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
||||
),
|
||||
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(
|
||||
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = None
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = None
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
before = time.monotonic()
|
||||
|
||||
|
||||
+136
-9
@@ -1049,7 +1049,8 @@ class TestAppriseFormatBody:
|
||||
from app.fanout.apprise_mod import _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"
|
||||
|
||||
@@ -1058,7 +1059,7 @@ class TestAppriseFormatBody:
|
||||
|
||||
body = _format_body(
|
||||
{"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"
|
||||
|
||||
@@ -1072,7 +1073,7 @@ class TestAppriseFormatBody:
|
||||
"sender_name": "Bob",
|
||||
"channel_name": "#general",
|
||||
},
|
||||
include_path=False,
|
||||
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||
)
|
||||
assert body == "**#general:** Bob: hi"
|
||||
|
||||
@@ -1086,7 +1087,7 @@ class TestAppriseFormatBody:
|
||||
"sender_name": "Alice",
|
||||
"paths": [{"path": "2027"}],
|
||||
},
|
||||
include_path=True,
|
||||
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||
)
|
||||
assert "**via:**" in body
|
||||
assert "`20`" in body
|
||||
@@ -1097,7 +1098,7 @@ class TestAppriseFormatBody:
|
||||
|
||||
body = _format_body(
|
||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||
include_path=True,
|
||||
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||
)
|
||||
assert "`direct`" in body
|
||||
|
||||
@@ -1112,7 +1113,7 @@ class TestAppriseFormatBody:
|
||||
"sender_name": "Alice",
|
||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||
},
|
||||
include_path=True,
|
||||
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||
)
|
||||
assert "**via:**" in body
|
||||
assert "`aabb`" in body
|
||||
@@ -1129,7 +1130,7 @@ class TestAppriseFormatBody:
|
||||
"sender_name": "Alice",
|
||||
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
||||
},
|
||||
include_path=True,
|
||||
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||
)
|
||||
assert "**via:**" in body
|
||||
assert "`aabbcc`" in body
|
||||
@@ -1147,7 +1148,7 @@ class TestAppriseFormatBody:
|
||||
"channel_name": "#general",
|
||||
"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 "`aabb`" in body
|
||||
@@ -1164,12 +1165,118 @@ class TestAppriseFormatBody:
|
||||
"sender_name": "Alice",
|
||||
"paths": [{"path": "aabb"}],
|
||||
},
|
||||
include_path=True,
|
||||
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||
)
|
||||
assert "**via:**" in body
|
||||
assert "`aa`" 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:
|
||||
def test_discord_scheme(self):
|
||||
@@ -1233,6 +1340,26 @@ class TestAppriseValidation:
|
||||
|
||||
_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):
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
|
||||
@@ -1171,7 +1171,8 @@ class TestFanoutAppriseIntegration:
|
||||
config={
|
||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||
"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"},
|
||||
enabled=True,
|
||||
@@ -1212,7 +1213,8 @@ class TestFanoutAppriseIntegration:
|
||||
name="Channel Apprise",
|
||||
config={
|
||||
"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"},
|
||||
enabled=True,
|
||||
@@ -1541,13 +1543,14 @@ class TestFanoutAppriseIntegration:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
config_type="apprise",
|
||||
name="Path Apprise",
|
||||
config={
|
||||
"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"},
|
||||
enabled=True,
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||
# change, not every individual assertion.
|
||||
LATEST_SCHEMA_VERSION = 58
|
||||
LATEST_SCHEMA_VERSION = 60
|
||||
|
||||
+91
-1
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from app.fanout.mqtt_ha import (
|
||||
MqttHaModule,
|
||||
_assign_lpp_keys,
|
||||
_contact_tracker_discovery_config,
|
||||
_device_payload,
|
||||
_lpp_discovery_configs,
|
||||
@@ -124,7 +125,7 @@ class TestRadioDiscovery:
|
||||
class TestRepeaterDiscovery:
|
||||
def test_produces_sensor_per_field(self):
|
||||
configs = _repeater_discovery_configs("mc", "ccdd11223344", "Rep1", "aabb")
|
||||
assert len(configs) == 7 # matches _REPEATER_SENSORS length
|
||||
assert len(configs) == 8 # matches _REPEATER_SENSORS length
|
||||
|
||||
topics = [t for t, _ in configs]
|
||||
assert "homeassistant/sensor/meshcore_ccdd11223344/battery_voltage/config" in topics
|
||||
@@ -552,6 +553,45 @@ class TestLppSensorKey:
|
||||
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:
|
||||
def test_produces_config_per_sensor(self):
|
||||
nid = "ccdd11223344"
|
||||
@@ -583,6 +623,27 @@ class TestLppDiscoveryConfigs:
|
||||
assert cfg["suggested_display_precision"] == 1
|
||||
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):
|
||||
nid = "ccdd11223344"
|
||||
device = _device_payload(nid, "Rep1", "Repeater")
|
||||
@@ -712,6 +773,35 @@ class TestMqttHaTelemetryWithLpp:
|
||||
|
||||
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
|
||||
async def test_on_telemetry_without_lpp_sensors(self):
|
||||
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.routers.radio import (
|
||||
RadioSettings,
|
||||
disconnect_radio,
|
||||
discover_mesh,
|
||||
get_private_key,
|
||||
get_radio_config,
|
||||
reboot_radio,
|
||||
reconnect_radio,
|
||||
@@ -283,6 +284,38 @@ class TestUpdateRadioConfig:
|
||||
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:
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.radio_sync import (
|
||||
_enable_autoevict_on_radio,
|
||||
_message_poll_loop,
|
||||
_periodic_advert_loop,
|
||||
_periodic_sync_loop,
|
||||
@@ -76,6 +77,7 @@ async def _insert_contact(
|
||||
name="Alice",
|
||||
on_radio=False,
|
||||
contact_type=0,
|
||||
flags=0,
|
||||
last_contacted=None,
|
||||
last_advert=None,
|
||||
direct_path=None,
|
||||
@@ -88,7 +90,7 @@ async def _insert_contact(
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"flags": 0,
|
||||
"flags": flags,
|
||||
"direct_path": direct_path,
|
||||
"direct_path_len": direct_path_len,
|
||||
"direct_path_hash_mode": direct_path_hash_mode,
|
||||
@@ -516,10 +518,101 @@ class TestSyncAndOffloadAll:
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
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
|
||||
|
||||
@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
|
||||
async def test_advert_fill_skips_repeaters(self, test_db):
|
||||
"""Recent advert fallback only considers non-repeaters."""
|
||||
@@ -798,6 +891,81 @@ class TestSyncAndOffloadAll:
|
||||
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:
|
||||
"""Test the yielding background contact reconcile loop."""
|
||||
|
||||
@@ -844,6 +1012,151 @@ class TestBackgroundContactReconcile:
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
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
|
||||
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
|
||||
|
||||
@@ -722,6 +722,7 @@ class TestRepeaterStatus:
|
||||
"flood_dups": 10,
|
||||
"direct_dups": 5,
|
||||
"full_evts": 0,
|
||||
"recv_errors": 42,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -741,6 +742,7 @@ class TestRepeaterStatus:
|
||||
assert response.uptime_seconds == 86400
|
||||
assert response.sent_flood == 100
|
||||
assert response.recv_direct == 700
|
||||
assert response.recv_errors == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_504_on_timeout(self, test_db):
|
||||
|
||||
@@ -31,6 +31,7 @@ SAMPLE_STATUS = {
|
||||
"flood_dups": 5,
|
||||
"direct_dups": 2,
|
||||
"full_events": 0,
|
||||
"recv_errors": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class TestRoomStatus:
|
||||
"flood_dups": 2,
|
||||
"direct_dups": 1,
|
||||
"full_evts": 0,
|
||||
"recv_errors": 7,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -147,6 +148,7 @@ class TestRoomStatus:
|
||||
assert response.battery_volts == 4.025
|
||||
assert response.packets_received == 80
|
||||
assert response.recv_direct == 73
|
||||
assert response.recv_errors == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_acl_maps_entries(self, test_db):
|
||||
|
||||
@@ -768,7 +768,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meshcore"
|
||||
version = "2.3.2"
|
||||
version = "2.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bleak" },
|
||||
@@ -776,9 +776,9 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyserial-asyncio-fast" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/d1/e45d8fa3cac24d58c3bc2523fe67b8cd00c05ea68e1704fbbaf56cb19753/meshcore-2.3.7.tar.gz", hash = "sha256:267107e09a96f7d0d63f4bdb1402d033a724baadd9c9becf9b71a458170f60bb", size = 90787 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3d/ff4b5971a3210da07dc793b54af9b1231fea42dfb87e2818fdcc83e10d72/meshcore-2.3.7-py3-none-any.whl", hash = "sha256:952f028b25527155e78103d01598fa3897cccfa793ba2028a32bc36c86759f14", size = 60352 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1399,7 +1399,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1408,9 +1408,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -1453,11 +1453,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@@ -1533,7 +1533,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.12.0"
|
||||
version = "3.12.3"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomqtt" },
|
||||
@@ -1569,7 +1569,7 @@ requires-dist = [
|
||||
{ name = "boto3", specifier = ">=1.38.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "meshcore", specifier = "==2.3.2" },
|
||||
{ name = "meshcore", specifier = "==2.3.7" },
|
||||
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
@@ -1582,7 +1582,7 @@ dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pip-licenses", specifier = ">=5.0.0" },
|
||||
{ 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-xdist", specifier = ">=3.0" },
|
||||
{ name = "ruff", specifier = ">=0.8.0" },
|
||||
@@ -1590,7 +1590,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1598,9 +1598,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
||||
Reference in New Issue
Block a user