mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-15 13:56:09 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,10 @@
|
|||||||
|
name: "RemoteTerm CodeQL config"
|
||||||
|
|
||||||
|
# Exclude rules that flag intentional design decisions:
|
||||||
|
# - AES-ECB is required by the MeshCore radio protocol wire format
|
||||||
|
# - Repeater/room passwords are not meaningfully sensitive secrets
|
||||||
|
query-filters:
|
||||||
|
- exclude:
|
||||||
|
id: py/weak-cryptographic-algorithm
|
||||||
|
- exclude:
|
||||||
|
id: js/clear-text-storage-of-sensitive-data
|
||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-checks:
|
backend-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [javascript-typescript, python]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
config-file: .github/codeql/codeql-config.yml
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
@@ -25,6 +25,9 @@ concurrency:
|
|||||||
group: publish-aur
|
group: publish-aur
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-aur:
|
publish-aur:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -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/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||||
|
| GET | `/api/radio/private-key` | Export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`) |
|
||||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
@@ -379,6 +380,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||||
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
||||||
|
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||||
| GET | `/api/fanout` | List all fanout configs |
|
| GET | `/api/fanout` | List all fanout configs |
|
||||||
| POST | `/api/fanout` | Create new fanout config |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
@@ -504,6 +506,7 @@ mc.subscribe(EventType.ACK, handler)
|
|||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
| `MESHCORE_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`.
|
**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,20 @@
|
|||||||
|
## [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
|
## [3.12.1] - 2026-04-19
|
||||||
|
|
||||||
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
|
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# RemoteTerm for MeshCore
|
# RemoteTerm for MeshCore
|
||||||
|
|
||||||
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
|
Backend server + browser interface for MeshCore mesh radio networks, providing a rich, web-based power-user management and messaging system through a companion radio.
|
||||||
|
|
||||||
|
Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||||
|
|
||||||
* Send and receive DMs and channel messages
|
* Send and receive DMs and channel messages
|
||||||
* Cache all received packets, decrypting as you gain keys
|
* Cache all received packets, decrypting as you gain keys
|
||||||
@@ -8,8 +10,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||||
* Access your radio remotely over your network or VPN
|
* Access your radio remotely over your network or VPN
|
||||||
* Search for hashtag channel names for channels you don't have keys for yet
|
* Search for hashtag channel names for channels you don't have keys for yet
|
||||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
* Forward packets, messages, and automatic repeater telemetry to MQTT, Home Assistant, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
* Use the more recent 1.14+ firmwares which support multibyte pathing
|
||||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||||
|
|
||||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|||||||
+26
-7
@@ -1,25 +1,44 @@
|
|||||||
# Advanced Setup And Troubleshooting
|
# Advanced Setup And Troubleshooting
|
||||||
|
|
||||||
## Remediation Environment Variables
|
## Remediation & Advanced Environment Variables
|
||||||
|
|
||||||
These are intended for diagnosing or working around radios that behave oddly.
|
These are intended for diagnosing or working around radios that behave oddly, or enabling advanced functionality.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages ([docs](#message-poll-fallback)) |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send ([docs](#force-channel-slot-reconfigure)) |
|
||||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading (see [Contact Loading Issues](#contact-loading-issues) below) |
|
| `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 |
|
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot ([docs](#clock-wraparound)) |
|
||||||
|
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex for backup or migration. Only enable on a trusted network. Import via `PUT /api/radio/private-key` is always available. ([docs](#private-key-export)) |
|
||||||
|
|
||||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||||
|
|
||||||
- whether messages were left on the radio without reaching the app through event subscription
|
- whether messages were left on the radio without reaching the app through event subscription
|
||||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||||
|
|
||||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
If the audit finds a mismatch, you'll see an error in the application UI and your logs.
|
||||||
|
|
||||||
|
### Message Poll Fallback
|
||||||
|
|
||||||
|
If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net.
|
||||||
|
|
||||||
|
### Force Channel Slot Reconfigure
|
||||||
|
|
||||||
|
If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||||
|
|
||||||
|
### Clock Wraparound
|
||||||
|
|
||||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||||
|
|
||||||
|
### Private Key Export
|
||||||
|
|
||||||
|
`MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true` enables `GET /api/radio/private-key`, which returns the in-memory private key as hex for backup or migration. The key is held in memory only (exported from the radio on connect) and is never persisted to disk. Only enable this on a trusted network when you need to retrieve the key.
|
||||||
|
|
||||||
|
Import via `PUT /api/radio/private-key` is always available regardless of this setting — it is write-only and does not expose key material.
|
||||||
|
|
||||||
|
The Radio Settings config export/import feature uses these endpoints. When export is disabled, config exports will omit the private key and show a notice.
|
||||||
|
|
||||||
## Contact Loading Issues
|
## 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.
|
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.
|
||||||
|
|||||||
+9
-1
@@ -196,6 +196,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
### Radio
|
### Radio
|
||||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||||
|
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
|
||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
@@ -266,6 +267,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
- `POST /settings/blocked-names/toggle`
|
- `POST /settings/blocked-names/toggle`
|
||||||
- `POST /settings/tracked-telemetry/toggle`
|
- `POST /settings/tracked-telemetry/toggle`
|
||||||
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
||||||
|
- `POST /settings/muted-channels/toggle`
|
||||||
|
|
||||||
### Fanout
|
### Fanout
|
||||||
- `GET /fanout` — list all fanout configs
|
- `GET /fanout` — list all fanout configs
|
||||||
@@ -396,7 +398,7 @@ tests/
|
|||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
├── test_migrations.py # Schema migration system
|
├── test_mqtt_ha.py # MQTT HA (high-availability) behavior
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
@@ -415,7 +417,13 @@ tests/
|
|||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
|
├── test_push_send.py # Web Push send/dispatch
|
||||||
|
├── test_radio_stats.py # Radio stats sampling and noise-floor history
|
||||||
|
├── test_repeater_telemetry.py # Repeater telemetry history recording
|
||||||
|
├── test_service_installer.py # Service installer script behavior
|
||||||
|
├── test_sqs_fanout.py # SQS fanout module
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
|
├── test_telemetry_interval.py # Telemetry interval scheduling math
|
||||||
├── test_version_info.py # Version/build metadata resolution
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
└── test_websocket_route.py # WS endpoint lifecycle
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||||
)
|
)
|
||||||
|
enable_local_private_key_export: bool = False
|
||||||
load_with_autoevict: bool = False
|
load_with_autoevict: bool = False
|
||||||
skip_post_connect_sync: bool = False
|
skip_post_connect_sync: bool = False
|
||||||
basic_auth_username: str = ""
|
basic_auth_username: str = ""
|
||||||
|
|||||||
+18
-2
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
flood_scope_override TEXT,
|
flood_scope_override TEXT,
|
||||||
path_hash_mode_override INTEGER,
|
path_hash_mode_override INTEGER,
|
||||||
last_read_at INTEGER,
|
last_read_at INTEGER,
|
||||||
favorite INTEGER DEFAULT 0
|
favorite INTEGER DEFAULT 0,
|
||||||
|
muted INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -112,7 +113,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
discovery_blocked_types TEXT DEFAULT '[]',
|
discovery_blocked_types TEXT DEFAULT '[]',
|
||||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||||
auto_resend_channel INTEGER DEFAULT 0,
|
auto_resend_channel INTEGER DEFAULT 0,
|
||||||
telemetry_interval_hours INTEGER DEFAULT 8
|
telemetry_interval_hours INTEGER DEFAULT 8,
|
||||||
|
vapid_private_key TEXT DEFAULT '',
|
||||||
|
vapid_public_key TEXT DEFAULT '',
|
||||||
|
push_conversations TEXT DEFAULT '[]'
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||||
|
|
||||||
@@ -134,6 +138,18 @@ CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_success_at INTEGER,
|
||||||
|
failure_count INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(endpoint)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Indexes are created after migrations so that legacy databases have all
|
# Indexes are created after migrations so that legacy databases have all
|
||||||
|
|||||||
+127
-36
@@ -11,6 +11,28 @@ from app.path_utils import split_path_hex
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
# Variables available for user format strings
|
||||||
|
FORMAT_VARIABLES = (
|
||||||
|
"type",
|
||||||
|
"text",
|
||||||
|
"sender_name",
|
||||||
|
"sender_key",
|
||||||
|
"channel_name",
|
||||||
|
"conversation_key",
|
||||||
|
"hops",
|
||||||
|
"hops_backticked",
|
||||||
|
"hop_count",
|
||||||
|
"rssi",
|
||||||
|
"snr",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_urls(raw: str) -> list[str]:
|
def _parse_urls(raw: str) -> list[str]:
|
||||||
"""Split multi-line URL string into individual URLs."""
|
"""Split multi-line URL string into individual URLs."""
|
||||||
@@ -36,41 +58,91 @@ def _normalize_discord_url(url: str) -> str:
|
|||||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
def _compute_hops(data: dict) -> tuple[str, str, int]:
|
||||||
"""Build a human-readable notification body from message data."""
|
"""Extract hop info from message data. Returns (hops, hops_backticked, hop_count)."""
|
||||||
|
paths = data.get("paths")
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
path_str = first_path.get("path", "")
|
||||||
|
path_len = first_path.get("path_len")
|
||||||
|
else:
|
||||||
|
path_str = None
|
||||||
|
path_len = None
|
||||||
|
|
||||||
|
if path_str is None or path_str.strip() == "":
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
path_str = path_str.strip().lower()
|
||||||
|
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||||
|
hops = split_path_hex(path_str, hop_count)
|
||||||
|
if not hops:
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
", ".join(hops),
|
||||||
|
", ".join(f"`{h}`" for h in hops),
|
||||||
|
len(hops),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_vars(data: dict) -> dict[str, str]:
|
||||||
|
"""Build the variable dict for format string substitution."""
|
||||||
|
hops_raw, hops_bt, hop_count = _compute_hops(data)
|
||||||
|
|
||||||
|
paths = data.get("paths")
|
||||||
|
rssi = ""
|
||||||
|
snr = ""
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
rssi_val = first_path.get("rssi")
|
||||||
|
snr_val = first_path.get("snr")
|
||||||
|
if rssi_val is not None:
|
||||||
|
rssi = str(rssi_val)
|
||||||
|
if snr_val is not None:
|
||||||
|
snr = str(snr_val)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": data.get("type", ""),
|
||||||
|
"text": get_fanout_message_text(data),
|
||||||
|
"sender_name": data.get("sender_name") or "Unknown",
|
||||||
|
"sender_key": data.get("sender_key") or "",
|
||||||
|
"channel_name": data.get("channel_name") or data.get("conversation_key", "channel"),
|
||||||
|
"conversation_key": data.get("conversation_key", ""),
|
||||||
|
"hops": hops_raw,
|
||||||
|
"hops_backticked": hops_bt,
|
||||||
|
"hop_count": str(hop_count),
|
||||||
|
"rssi": rssi,
|
||||||
|
"snr": snr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_format(fmt: str, variables: dict[str, str]) -> str:
|
||||||
|
"""Apply template variables in a single pass to avoid re-expanding substituted values."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _replacer(m: re.Match[str]) -> str:
|
||||||
|
key = m.group(1)
|
||||||
|
return variables.get(key, m.group(0))
|
||||||
|
|
||||||
|
return re.sub(r"\{(\w+)\}", _replacer, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_body(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
|
||||||
|
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
|
||||||
|
) -> str:
|
||||||
|
"""Build a notification body from message data using format strings."""
|
||||||
|
variables = _build_template_vars(data)
|
||||||
msg_type = data.get("type", "")
|
msg_type = data.get("type", "")
|
||||||
text = get_fanout_message_text(data)
|
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
|
||||||
sender_name = data.get("sender_name") or "Unknown"
|
try:
|
||||||
|
return _apply_format(fmt, variables)
|
||||||
via = ""
|
except Exception:
|
||||||
if include_path:
|
logger.warning("Apprise format string error, falling back to default")
|
||||||
paths = data.get("paths")
|
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
return _apply_format(default, variables)
|
||||||
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
|
||||||
path_str = first_path.get("path", "")
|
|
||||||
path_len = first_path.get("path_len")
|
|
||||||
else:
|
|
||||||
path_str = None
|
|
||||||
path_len = None
|
|
||||||
|
|
||||||
if msg_type == "PRIV" and path_str is None:
|
|
||||||
via = " **via:** [`direct`]"
|
|
||||||
elif path_str is not None:
|
|
||||||
path_str = path_str.strip().lower()
|
|
||||||
if path_str == "":
|
|
||||||
via = " **via:** [`direct`]"
|
|
||||||
else:
|
|
||||||
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
|
||||||
hops = split_path_hex(path_str, hop_count)
|
|
||||||
if hops:
|
|
||||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
|
||||||
via = f" **via:** [{hop_list}]"
|
|
||||||
|
|
||||||
if msg_type == "PRIV":
|
|
||||||
return f"**DM:** {sender_name}: {text}{via}"
|
|
||||||
|
|
||||||
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
|
|
||||||
return f"**{channel_name}:** {sender_name}: {text}{via}"
|
|
||||||
|
|
||||||
|
|
||||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||||
@@ -106,8 +178,27 @@ class AppriseModule(FanoutModule):
|
|||||||
return
|
return
|
||||||
|
|
||||||
preserve_identity = self.config.get("preserve_identity", True)
|
preserve_identity = self.config.get("preserve_identity", True)
|
||||||
include_path = self.config.get("include_path", True)
|
|
||||||
body = _format_body(data, include_path=include_path)
|
# Read format strings; treat empty/whitespace as unset (use default).
|
||||||
|
# Fall back to legacy include_path for pre-migration configs.
|
||||||
|
body_format_dm = (self.config.get("body_format_dm") or "").strip() or None
|
||||||
|
body_format_channel = (self.config.get("body_format_channel") or "").strip() or None
|
||||||
|
if body_format_dm is None or body_format_channel is None:
|
||||||
|
include_path = self.config.get("include_path", True)
|
||||||
|
if body_format_dm is None:
|
||||||
|
body_format_dm = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
if body_format_channel is None:
|
||||||
|
body_format_channel = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
|
if include_path
|
||||||
|
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = await asyncio.to_thread(
|
success = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
|||||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||||
|
|
||||||
device_info = await self._fetch_device_info()
|
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
|
||||||
|
# radio_lifecycle) over the per-module _cached_device_info, which was only
|
||||||
|
# cleared on module restart and therefore served stale firmware versions after
|
||||||
|
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
|
||||||
|
# where device_info_loaded is False.
|
||||||
|
if radio_manager.device_info_loaded:
|
||||||
|
raw_ver = radio_manager.firmware_version or "unknown"
|
||||||
|
fw_build = radio_manager.firmware_build or ""
|
||||||
|
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
|
||||||
|
device_info = {
|
||||||
|
"model": radio_manager.device_model or "unknown",
|
||||||
|
"firmware_version": fw_str,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
device_info = await self._fetch_device_info()
|
||||||
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
||||||
|
|
||||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||||
|
|||||||
+24
-8
@@ -115,6 +115,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
|||||||
return f"lpp_{type_name}_ch{channel}"
|
return f"lpp_{type_name}_ch{channel}"
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]:
|
||||||
|
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
|
||||||
|
|
||||||
|
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
|
||||||
|
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
result: list[tuple[dict, str, int]] = []
|
||||||
|
for sensor in lpp_sensors:
|
||||||
|
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||||
|
n = counts.get(base, 0) + 1
|
||||||
|
counts[base] = n
|
||||||
|
result.append((sensor, base if n == 1 else f"{base}_{n}", n))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
@@ -123,8 +139,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if field is not None:
|
if field is not None:
|
||||||
payload[field] = data.get(field)
|
payload[field] = data.get(field)
|
||||||
|
|
||||||
for sensor in data.get("lpp_sensors", []) or []:
|
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
|
||||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
|
||||||
payload[key] = sensor.get("value")
|
payload[key] = sensor.get("value")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
@@ -139,16 +154,19 @@ def _lpp_discovery_configs(
|
|||||||
) -> list[tuple[str, dict]]:
|
) -> list[tuple[str, dict]]:
|
||||||
"""Build HA discovery configs for a repeater's LPP sensors."""
|
"""Build HA discovery configs for a repeater's LPP sensors."""
|
||||||
configs: list[tuple[str, dict]] = []
|
configs: list[tuple[str, dict]] = []
|
||||||
for sensor in lpp_sensors:
|
for sensor, field, occurrence in _assign_lpp_keys(lpp_sensors):
|
||||||
type_name = sensor.get("type_name", "unknown")
|
type_name = sensor.get("type_name", "unknown")
|
||||||
channel = sensor.get("channel", 0)
|
channel = sensor.get("channel", 0)
|
||||||
field = _lpp_sensor_key(type_name, channel)
|
|
||||||
meta = _LPP_HA_META.get(type_name, {})
|
meta = _LPP_HA_META.get(type_name, {})
|
||||||
|
|
||||||
nid = _node_id(pub_key)
|
nid = _node_id(pub_key)
|
||||||
object_id = field
|
object_id = field
|
||||||
display = type_name.replace("_", " ").title()
|
display = type_name.replace("_", " ").title()
|
||||||
name = f"{display} (Ch {channel})"
|
name = (
|
||||||
|
f"{display} (Ch {channel})"
|
||||||
|
if occurrence == 1
|
||||||
|
else f"{display} (Ch {channel}) #{occurrence}"
|
||||||
|
)
|
||||||
|
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -731,9 +749,7 @@ class MqttHaModule(FanoutModule):
|
|||||||
payload = _repeater_telemetry_payload(data)
|
payload = _repeater_telemetry_payload(data)
|
||||||
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
||||||
rediscover = False
|
rediscover = False
|
||||||
for sensor in lpp_sensors:
|
for _, key, _ in _assign_lpp_keys(lpp_sensors):
|
||||||
# Check if discovery for this sensor has been published yet
|
|
||||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
|
||||||
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
||||||
if expected_topic not in self._discovery_topics:
|
if expected_topic not in self._discovery_topics:
|
||||||
rediscover = True
|
rediscover = True
|
||||||
|
|||||||
+19
@@ -180,6 +180,25 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
|||||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_server_errors(request: Request, call_next):
|
||||||
|
"""Capture 5xx errors and unhandled exceptions into the log ring buffer.
|
||||||
|
|
||||||
|
Starlette writes unhandled-exception tracebacks to stderr, bypassing
|
||||||
|
Python logging, so they never reach the debug dump. This middleware
|
||||||
|
catches them and logs via ``logger.exception()`` so the full traceback
|
||||||
|
is preserved in the ring buffer for the ``GET /api/debug`` snapshot.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||||
|
raise
|
||||||
|
if response.status_code >= 500:
|
||||||
|
logger.error("HTTP %d on %s %s", response.status_code, request.method, request.url.path)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# API routes - all prefixed with /api for production compatibility
|
# API routes - all prefixed with /api for production compatibility
|
||||||
app.include_router(health.router, prefix="/api")
|
app.include_router(health.router, prefix="/api")
|
||||||
app.include_router(debug.router, prefix="/api")
|
app.include_router(debug.router, prefix="/api")
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Migrate apprise fanout configs from include_path boolean to format strings."""
|
||||||
|
table_check = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
|
||||||
|
)
|
||||||
|
if not await table_check.fetchone():
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("SELECT id, config FROM fanout_configs WHERE type = 'apprise'")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
config_id = row["id"] if isinstance(row, dict) else row[0]
|
||||||
|
config_raw = row["config"] if isinstance(row, dict) else row[1]
|
||||||
|
try:
|
||||||
|
config = json.loads(config_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if already migrated
|
||||||
|
if "body_format_dm" in config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
include_path = config.get("include_path", True)
|
||||||
|
config["body_format_dm"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
config["body_format_channel"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL if include_path else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
config.pop("include_path", None)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE fanout_configs SET config = ? WHERE id = ?",
|
||||||
|
(json.dumps(config), config_id),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Migrated apprise config %s: include_path=%s -> format strings", config_id, include_path
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
@@ -221,6 +221,9 @@ class CreateContactRequest(BaseModel):
|
|||||||
|
|
||||||
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
||||||
name: str | None = Field(default=None, description="Display name for the contact")
|
name: str | None = Field(default=None, description="Display name for the contact")
|
||||||
|
type: int = Field(
|
||||||
|
default=0, ge=0, le=3, description="Contact type (0=unknown, 1=client, 2=repeater, 3=room)"
|
||||||
|
)
|
||||||
try_historical: bool = Field(
|
try_historical: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Attempt to decrypt historical DM packets for this contact",
|
description="Attempt to decrypt historical DM packets for this contact",
|
||||||
|
|||||||
+6
-1
@@ -1273,7 +1273,12 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
budget -= 1
|
budget -= 1
|
||||||
if remove_result.type == EventType.OK:
|
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)
|
radio_contacts.pop(public_key, None)
|
||||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
removed += 1
|
removed += 1
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ async def create_contact(
|
|||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
|
type=request.type,
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
|
|||||||
+34
-5
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
|
|||||||
path_hash_mode_supported: bool
|
path_hash_mode_supported: bool
|
||||||
channel_slot_reuse_enabled: bool
|
channel_slot_reuse_enabled: bool
|
||||||
channel_send_cache_capacity: int
|
channel_send_cache_capacity: int
|
||||||
remediation_flags: dict[str, bool]
|
|
||||||
|
|
||||||
|
|
||||||
class DebugContactAudit(BaseModel):
|
class DebugContactAudit(BaseModel):
|
||||||
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
|
|||||||
basic_auth_enabled: bool = False
|
basic_auth_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DebugEnvironment(BaseModel):
|
||||||
|
connection_type: str
|
||||||
|
serial_port: str
|
||||||
|
serial_baudrate: int
|
||||||
|
tcp_host: str
|
||||||
|
tcp_port: int
|
||||||
|
ble_address: str
|
||||||
|
log_level: str
|
||||||
|
database_path: str
|
||||||
|
disable_bots: bool
|
||||||
|
enable_message_poll_fallback: bool
|
||||||
|
force_channel_slot_reconfigure: bool
|
||||||
|
load_with_autoevict: bool
|
||||||
|
|
||||||
|
|
||||||
class DebugAppSettings(BaseModel):
|
class DebugAppSettings(BaseModel):
|
||||||
max_radio_contacts: int
|
max_radio_contacts: int
|
||||||
auto_decrypt_dm_on_advert: bool
|
auto_decrypt_dm_on_advert: bool
|
||||||
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
|
|||||||
captured_at: str
|
captured_at: str
|
||||||
system: DebugSystemInfo
|
system: DebugSystemInfo
|
||||||
application: DebugApplicationInfo
|
application: DebugApplicationInfo
|
||||||
|
environment: DebugEnvironment
|
||||||
health: DebugHealthSummary
|
health: DebugHealthSummary
|
||||||
settings: DebugAppSettings
|
settings: DebugAppSettings
|
||||||
runtime: DebugRuntimeInfo
|
runtime: DebugRuntimeInfo
|
||||||
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_environment() -> DebugEnvironment:
|
||||||
|
return DebugEnvironment(
|
||||||
|
connection_type=settings.connection_type,
|
||||||
|
serial_port=settings.serial_port,
|
||||||
|
serial_baudrate=settings.serial_baudrate,
|
||||||
|
tcp_host=settings.tcp_host,
|
||||||
|
tcp_port=settings.tcp_port,
|
||||||
|
ble_address=settings.ble_address,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
database_path=settings.database_path,
|
||||||
|
disable_bots=settings.disable_bots,
|
||||||
|
enable_message_poll_fallback=settings.enable_message_poll_fallback,
|
||||||
|
force_channel_slot_reconfigure=settings.force_channel_slot_reconfigure,
|
||||||
|
load_with_autoevict=settings.load_with_autoevict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||||
return DebugAppSettings(
|
return DebugAppSettings(
|
||||||
max_radio_contacts=app_settings.max_radio_contacts,
|
max_radio_contacts=app_settings.max_radio_contacts,
|
||||||
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
captured_at=datetime.now(UTC).isoformat(),
|
captured_at=datetime.now(UTC).isoformat(),
|
||||||
system=_build_system_info(),
|
system=_build_system_info(),
|
||||||
application=_build_application_info(),
|
application=_build_application_info(),
|
||||||
|
environment=_build_environment(),
|
||||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||||
settings=_build_debug_app_settings(app_settings),
|
settings=_build_debug_app_settings(app_settings),
|
||||||
runtime=DebugRuntimeInfo(
|
runtime=DebugRuntimeInfo(
|
||||||
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||||
remediation_flags={
|
|
||||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
|
||||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
database=DebugDatabaseInfo(
|
database=DebugDatabaseInfo(
|
||||||
total_dms=message_totals["total_dms"],
|
total_dms=message_totals["total_dms"],
|
||||||
|
|||||||
@@ -259,6 +259,21 @@ def _validate_apprise_config(config: dict) -> None:
|
|||||||
if not urls or not urls.strip():
|
if not urls or not urls.strip():
|
||||||
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import FORMAT_VARIABLES, _apply_format
|
||||||
|
|
||||||
|
dummy_vars: dict[str, str] = dict.fromkeys(FORMAT_VARIABLES, "test")
|
||||||
|
for field in ("body_format_dm", "body_format_channel"):
|
||||||
|
value = config.get(field)
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{field} must be a string")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
_apply_format(value, dummy_vars)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid format string in {field}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def _validate_webhook_config(config: dict) -> None:
|
def _validate_webhook_config(config: dict) -> None:
|
||||||
"""Validate webhook config blob."""
|
"""Validate webhook config blob."""
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
|
|||||||
# Core stats
|
# Core stats
|
||||||
battery_mv: int | None = None
|
battery_mv: int | None = None
|
||||||
uptime_secs: int | None = None
|
uptime_secs: int | None = None
|
||||||
|
queue_len: int | None = None
|
||||||
|
errors: int | None = None
|
||||||
# Radio stats
|
# Radio stats
|
||||||
noise_floor: int | None = None
|
noise_floor: int | None = None
|
||||||
last_rssi: int | None = None
|
last_rssi: int | None = None
|
||||||
@@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
|||||||
"timestamp": raw_stats.get("timestamp"),
|
"timestamp": raw_stats.get("timestamp"),
|
||||||
"battery_mv": raw_stats.get("battery_mv"),
|
"battery_mv": raw_stats.get("battery_mv"),
|
||||||
"uptime_secs": raw_stats.get("uptime_secs"),
|
"uptime_secs": raw_stats.get("uptime_secs"),
|
||||||
|
"queue_len": raw_stats.get("queue_len"),
|
||||||
|
"errors": raw_stats.get("errors"),
|
||||||
"noise_floor": raw_stats.get("noise_floor"),
|
"noise_floor": raw_stats.get("noise_floor"),
|
||||||
"last_rssi": raw_stats.get("last_rssi"),
|
"last_rssi": raw_stats.get("last_rssi"),
|
||||||
"last_snr": raw_stats.get("last_snr"),
|
"last_snr": raw_stats.get("last_snr"),
|
||||||
|
|||||||
@@ -385,6 +385,30 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
|||||||
return await get_radio_config()
|
return await get_radio_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/private-key")
|
||||||
|
async def get_private_key() -> dict:
|
||||||
|
"""Return the in-memory private key (exported from radio on startup).
|
||||||
|
|
||||||
|
Gated behind MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
from app.keystore import get_private_key as ks_get
|
||||||
|
|
||||||
|
if not settings.enable_local_private_key_export:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Private key export is disabled (set MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true)",
|
||||||
|
)
|
||||||
|
|
||||||
|
key = ks_get()
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Private key not available (not exported from radio)",
|
||||||
|
)
|
||||||
|
return {"private_key": key.hex()}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/private-key")
|
@router.put("/private-key")
|
||||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||||
"""Set the radio's private key. This is write-only."""
|
"""Set the radio's private key. This is write-only."""
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ async def send_channel_message_with_effective_scope(
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||||
if send_result.type == EventType.ERROR:
|
if send_result.type == EventType.ERROR:
|
||||||
|
logger.error(
|
||||||
|
"Radio returned error during %s for channel %s: %s",
|
||||||
|
action_label,
|
||||||
|
channel.name,
|
||||||
|
send_result.payload,
|
||||||
|
)
|
||||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||||
else:
|
else:
|
||||||
radio_manager.note_channel_slot_used(channel_key)
|
radio_manager.note_channel_slot_used(channel_key)
|
||||||
@@ -856,7 +862,7 @@ async def send_channel_message_to_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never let watchdog setup failure break the send
|
logger.error("Echo watchdog setup failed", exc_info=True)
|
||||||
|
|
||||||
return outgoing_message
|
return outgoing_message
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
# MESHCORE_DISABLE_BOTS: "true"
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
# MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# MESHCORE_LOG_LEVEL: INFO
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
|||||||
+3
-6
@@ -75,7 +75,6 @@ frontend/src/
|
|||||||
├── utils/
|
├── utils/
|
||||||
│ ├── urlHash.ts # Hash parsing and encoding
|
│ ├── urlHash.ts # Hash parsing and encoding
|
||||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
|
||||||
│ ├── messageParser.ts # Message text → rendered segments
|
│ ├── messageParser.ts # Message text → rendered segments
|
||||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
@@ -132,6 +131,9 @@ frontend/src/
|
|||||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
|
│ ├── ChannelPathHashModeOverrideModal.tsx # Per-channel path hash mode override editor
|
||||||
|
│ ├── BulkAddChannelResultModal.tsx # Results dialog for bulk channel creation
|
||||||
|
│ ├── CommandPalette.tsx # Command palette overlay
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
@@ -178,7 +180,6 @@ frontend/src/
|
|||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
├── rawPacketDetailModal.test.tsx
|
├── rawPacketDetailModal.test.tsx
|
||||||
├── rawPacketFeedView.test.tsx
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
@@ -350,10 +351,6 @@ It falls back to a 12-char prefix when `name` is missing.
|
|||||||
|
|
||||||
Distance/validation helpers used by path + map UI.
|
Distance/validation helpers used by path + map UI.
|
||||||
|
|
||||||
### `utils/favorites.ts`
|
|
||||||
|
|
||||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
|
||||||
|
|
||||||
## Types and Contracts (`types.ts`)
|
## Types and Contracts (`types.ts`)
|
||||||
|
|
||||||
`AppSettings` currently includes:
|
`AppSettings` currently includes:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.1",
|
"version": "3.12.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+3
-2
@@ -96,6 +96,7 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
|
||||||
setPrivateKey: (privateKey: string) =>
|
setPrivateKey: (privateKey: string) =>
|
||||||
fetchJson<{ status: string }>('/radio/private-key', {
|
fetchJson<{ status: string }>('/radio/private-key', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -157,10 +158,10 @@ export const api = {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ public_keys: publicKeys }),
|
body: JSON.stringify({ public_keys: publicKeys }),
|
||||||
}),
|
}),
|
||||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean, type?: number) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
|
body: JSON.stringify({ public_key: publicKey, name, type, try_historical: tryHistorical }),
|
||||||
}),
|
}),
|
||||||
markContactRead: (publicKey: string) =>
|
markContactRead: (publicKey: string) =>
|
||||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
} from 'react';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
import { useSwipeable } from 'react-swipeable';
|
||||||
|
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
@@ -140,6 +148,26 @@ export function AppShell({
|
|||||||
crackerMounted.current = true;
|
crackerMounted.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position toasts below the conversation header when in chat, otherwise below the status bar
|
||||||
|
const TOAST_TOP_PADDING = 10;
|
||||||
|
const [toastTopOffset, setToastTopOffset] = useState<number | undefined>(undefined);
|
||||||
|
const hasLocalLabel = !!localLabel.text;
|
||||||
|
const activeType = conversationPaneProps.activeConversation?.type;
|
||||||
|
const activeId = conversationPaneProps.activeConversation?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
const measure = () => {
|
||||||
|
const anchor =
|
||||||
|
document.querySelector('[data-toast-anchor="conversation"]') ??
|
||||||
|
document.querySelector('[data-toast-anchor="statusbar"]');
|
||||||
|
setToastTopOffset(
|
||||||
|
anchor ? anchor.getBoundingClientRect().top + TOAST_TOP_PADDING : undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => window.removeEventListener('resize', measure);
|
||||||
|
}, [hasLocalLabel, activeType, activeId, showSettings]);
|
||||||
|
|
||||||
const settingsSidebarContent = (
|
const settingsSidebarContent = (
|
||||||
<nav
|
<nav
|
||||||
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
||||||
@@ -220,6 +248,7 @@ export function AppShell({
|
|||||||
onSettingsClick={onToggleSettingsView}
|
onSettingsClick={onToggleSettingsView}
|
||||||
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
||||||
/>
|
/>
|
||||||
|
<div data-toast-anchor="statusbar" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||||
@@ -344,7 +373,11 @@ export function AppShell({
|
|||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||||
<Toaster position="top-right" />
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
offset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
mobileOffset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ export function ConversationPane({
|
|||||||
{activeContactIsRoom && activeContact && (
|
{activeContactIsRoom && activeContact && (
|
||||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||||
)}
|
)}
|
||||||
|
{showRoomChat && <div data-toast-anchor="conversation" aria-hidden="true" />}
|
||||||
{showRoomChat && (
|
{showRoomChat && (
|
||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ interface NewMessageModalProps {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
} | null;
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (
|
||||||
|
name: string,
|
||||||
|
publicKey: string,
|
||||||
|
tryHistorical: boolean,
|
||||||
|
type?: number
|
||||||
|
) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -91,6 +96,7 @@ export function NewMessageModal({
|
|||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [contactType, setContactType] = useState(1);
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
@@ -103,6 +109,7 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
|
setContactType(1);
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
setBulkChannelText('');
|
setBulkChannelText('');
|
||||||
@@ -161,7 +168,7 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical, contactType);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Channel name and key are required');
|
setError('Channel name and key are required');
|
||||||
@@ -293,6 +300,19 @@ export function NewMessageModal({
|
|||||||
placeholder="64-character hex public key"
|
placeholder="64-character hex public key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contact-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="contact-type"
|
||||||
|
value={contactType}
|
||||||
|
onChange={(e) => setContactType(Number(e.target.value))}
|
||||||
|
className="block h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value={1}>Client</option>
|
||||||
|
<option value={2}>Repeater</option>
|
||||||
|
<option value={3}>Room Server</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<div data-toast-anchor="conversation" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
|
import { useMemo } from 'react';
|
||||||
|
import { RepeaterPane, NotFetched, LppSensorRow, formatLppLabel } from './repeaterPaneShared';
|
||||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||||
|
|
||||||
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { distanceUnit } = useDistanceUnit();
|
const { distanceUnit } = useDistanceUnit();
|
||||||
|
|
||||||
|
// Build disambiguated labels matching the telemetry history chart names
|
||||||
|
const labels = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return data.sensors.map((s) => {
|
||||||
|
const base = `${s.type_name}_${s.channel}`;
|
||||||
|
const n = (counts.get(base) ?? 0) + 1;
|
||||||
|
counts.set(base, n);
|
||||||
|
return formatLppLabel(s.type_name) + ` Ch${s.channel}` + (n > 1 ? ` (${n})` : '');
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{data.sensors.map((sensor, i) => (
|
{data.sensors.map((sensor, i) => (
|
||||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
|
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,9 +37,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
|
|||||||
// Stable color rotation for dynamic LPP sensors
|
// Stable color rotation for dynamic LPP sensors
|
||||||
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
||||||
|
|
||||||
/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */
|
/** Assign disambiguated flat keys to an array of LPP sensors.
|
||||||
function lppKey(s: TelemetryLppSensor): string {
|
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
|
||||||
return `lpp_${s.type_name}_ch${s.channel}`;
|
function assignLppKeys(
|
||||||
|
sensors: TelemetryLppSensor[]
|
||||||
|
): { sensor: TelemetryLppSensor; key: string; occurrence: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return sensors.map((s) => {
|
||||||
|
const base = `lpp_${s.type_name}_ch${s.channel}`;
|
||||||
|
const n = (counts.get(base) ?? 0) + 1;
|
||||||
|
counts.set(base, n);
|
||||||
|
return { sensor: s, key: n === 1 ? base : `${base}_${n}`, occurrence: n };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLTIP_STYLE = {
|
const TOOLTIP_STYLE = {
|
||||||
@@ -93,11 +102,10 @@ export function TelemetryHistoryPane({
|
|||||||
|
|
||||||
// Discover unique LPP sensors across all history entries
|
// Discover unique LPP sensors across all history entries
|
||||||
const lppMetrics = useMemo(() => {
|
const lppMetrics = useMemo(() => {
|
||||||
const seen = new Map<string, { type_name: string; channel: number }>();
|
const seen = new Map<string, { type_name: string; channel: number; occurrence: number }>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
for (const s of e.data.lpp_sensors ?? []) {
|
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
|
||||||
const k = lppKey(s);
|
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
|
||||||
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
||||||
@@ -106,7 +114,8 @@ export function TelemetryHistoryPane({
|
|||||||
const label =
|
const label =
|
||||||
info.type_name.charAt(0).toUpperCase() +
|
info.type_name.charAt(0).toUpperCase() +
|
||||||
info.type_name.slice(1).replace(/_/g, ' ') +
|
info.type_name.slice(1).replace(/_/g, ' ') +
|
||||||
` Ch${info.channel}`;
|
` Ch${info.channel}` +
|
||||||
|
(info.occurrence > 1 ? ` (${info.occurrence})` : '');
|
||||||
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
||||||
result.push({
|
result.push({
|
||||||
key: k,
|
key: k,
|
||||||
@@ -148,9 +157,9 @@ export function TelemetryHistoryPane({
|
|||||||
uptime_seconds: d.uptime_seconds,
|
uptime_seconds: d.uptime_seconds,
|
||||||
};
|
};
|
||||||
// Flatten LPP sensors into the point, converting units as needed
|
// Flatten LPP sensors into the point, converting units as needed
|
||||||
for (const s of d.lpp_sensors ?? []) {
|
for (const { sensor: s, key } of assignLppKeys(d.lpp_sensors ?? [])) {
|
||||||
if (typeof s.value === 'number') {
|
if (typeof s.value === 'number') {
|
||||||
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return point;
|
return point;
|
||||||
|
|||||||
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
|
|||||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
|
export function LppSensorRow({
|
||||||
const label = formatLppLabel(sensor.type_name);
|
sensor,
|
||||||
|
unitPref,
|
||||||
|
label: labelOverride,
|
||||||
|
}: {
|
||||||
|
sensor: LppSensor;
|
||||||
|
unitPref?: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const label = labelOverride ?? formatLppLabel(sensor.type_name);
|
||||||
|
|
||||||
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
||||||
// Multi-value sensor (GPS, accelerometer, etc.)
|
// Multi-value sensor (GPS, accelerometer, etc.)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
|
|||||||
4: 'Sensor',
|
4: 'Sensor',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleDateString([], {
|
return new Date(ts * 1000).toLocaleDateString([], {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number {
|
|||||||
return Math.floor(d.getTime() / 1000);
|
return Math.floor(d.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
sortField: SortField;
|
||||||
|
sortDir: SortDir;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sortField === field;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`px-3 py-1.5 cursor-pointer select-none hover:text-foreground transition-colors ${className ?? ''}`}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BulkDeleteContactsModalProps {
|
interface BulkDeleteContactsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({
|
|||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [lastHeardAfter, setLastHeardAfter] = useState('');
|
||||||
|
const [lastHeardBefore, setLastHeardBefore] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('first_seen');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const lastClickedKeyRef = useRef<string | null>(null);
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(field === 'name' || field === 'key' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sortField]
|
||||||
|
);
|
||||||
|
|
||||||
const resetAndClose = useCallback(() => {
|
const resetAndClose = useCallback(() => {
|
||||||
setStep('select');
|
setStep('select');
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
setEndDate('');
|
setEndDate('');
|
||||||
|
setLastHeardAfter('');
|
||||||
|
setLastHeardBefore('');
|
||||||
setTypeFilter('all');
|
setTypeFilter('all');
|
||||||
|
setSortField('first_seen');
|
||||||
|
setSortDir('desc');
|
||||||
lastClickedKeyRef.current = null;
|
lastClickedKeyRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
let list = [...contacts];
|
||||||
if (typeFilter !== 'all') {
|
if (typeFilter !== 'all') {
|
||||||
list = list.filter((c) => c.type === typeFilter);
|
list = list.filter((c) => c.type === typeFilter);
|
||||||
}
|
}
|
||||||
@@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({
|
|||||||
const end = datetimeToUnix(endDate);
|
const end = datetimeToUnix(endDate);
|
||||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||||
}
|
}
|
||||||
|
if (lastHeardAfter) {
|
||||||
|
const after = datetimeToUnix(lastHeardAfter);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) >= after);
|
||||||
|
}
|
||||||
|
if (lastHeardBefore) {
|
||||||
|
const before = datetimeToUnix(lastHeardBefore);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) <= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name': {
|
||||||
|
const an = getContactDisplayName(a.name, a.public_key, a.last_advert).toLowerCase();
|
||||||
|
const bn = getContactDisplayName(b.name, b.public_key, b.last_advert).toLowerCase();
|
||||||
|
return an < bn ? -dir : an > bn ? dir : 0;
|
||||||
|
}
|
||||||
|
case 'type':
|
||||||
|
return (a.type - b.type) * dir;
|
||||||
|
case 'key':
|
||||||
|
return a.public_key < b.public_key ? -dir : a.public_key > b.public_key ? dir : 0;
|
||||||
|
case 'first_seen':
|
||||||
|
return ((a.first_seen ?? 0) - (b.first_seen ?? 0)) * dir;
|
||||||
|
case 'last_seen':
|
||||||
|
return ((a.last_seen ?? 0) - (b.last_seen ?? 0)) * dir;
|
||||||
|
}
|
||||||
|
});
|
||||||
return list;
|
return list;
|
||||||
}, [contacts, typeFilter, startDate, endDate]);
|
}, [
|
||||||
|
contacts,
|
||||||
|
typeFilter,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
lastHeardAfter,
|
||||||
|
lastHeardBefore,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||||
@@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||||
@@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
{step === 'select' && (
|
{step === 'select' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Show</label>
|
<div className="space-y-1">
|
||||||
<select
|
<label className="text-xs text-muted-foreground">Show</label>
|
||||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
<select
|
||||||
onChange={(e) =>
|
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
onChange={(e) =>
|
||||||
}
|
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
}
|
||||||
>
|
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
<option value="all">All</option>
|
>
|
||||||
<option value="1">Clients</option>
|
<option value="all">All</option>
|
||||||
<option value="2">Repeaters</option>
|
<option value="1">Clients</option>
|
||||||
<option value="3">Room Servers</option>
|
<option value="2">Repeaters</option>
|
||||||
<option value="4">Sensors</option>
|
<option value="3">Room Servers</option>
|
||||||
</select>
|
<option value="4">Sensors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created after</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Created after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={startDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
value={startDate}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created before</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Last heard after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={endDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
value={lastHeardAfter}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setLastHeardAfter(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Last heard before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={lastHeardBefore}
|
||||||
|
onChange={(e) => setLastHeardBefore(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
@@ -211,7 +322,7 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||||
{(startDate || endDate) && ' (filtered)'}
|
{hasFilters && ' (filtered)'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{selectedKeys.size} selected
|
{selectedKeys.size} selected
|
||||||
</div>
|
</div>
|
||||||
@@ -219,17 +330,51 @@ export function BulkDeleteContactsModal({
|
|||||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
{filteredContacts.length === 0 ? (
|
{filteredContacts.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
No contacts match the selected date range.
|
No contacts match the selected filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
<tr className="text-left text-xs text-muted-foreground">
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
<th className="px-3 py-1.5 w-8" />
|
<th className="px-3 py-1.5 w-8" />
|
||||||
<th className="px-3 py-1.5">Name</th>
|
<SortableHeader
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
label="Name"
|
||||||
<th className="px-3 py-1.5">Key</th>
|
field="name"
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Type"
|
||||||
|
field="type"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Key"
|
||||||
|
field="key"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Created"
|
||||||
|
field="first_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Last heard"
|
||||||
|
field="last_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -265,6 +410,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -298,6 +446,7 @@ export function BulkDeleteContactsModal({
|
|||||||
<th className="px-3 py-1.5">Type</th>
|
<th className="px-3 py-1.5">Type</th>
|
||||||
<th className="px-3 py-1.5">Key</th>
|
<th className="px-3 py-1.5">Key</th>
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Last heard</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -315,6 +464,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import { ChevronDown, Info } from 'lucide-react';
|
import { ChevronDown, Info } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
@@ -278,7 +287,9 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
config: {
|
config: {
|
||||||
urls: '',
|
urls: '',
|
||||||
preserve_identity: true,
|
preserve_identity: true,
|
||||||
include_path: true,
|
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
|
body_format_channel:
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
},
|
},
|
||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
@@ -2376,6 +2387,91 @@ function ScopeSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||||
|
const APPRISE_DEFAULT_CHANNEL =
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS: Record<string, string> = {
|
||||||
|
type: 'CHAN',
|
||||||
|
text: 'hello world',
|
||||||
|
sender_name: 'Alice',
|
||||||
|
sender_key: 'a1b2c3d4e5f6',
|
||||||
|
channel_name: '#general',
|
||||||
|
conversation_key: 'abcdef1234567890',
|
||||||
|
hops: '2a, 3b',
|
||||||
|
hops_backticked: '`2a`, `3b`',
|
||||||
|
hop_count: '2',
|
||||||
|
rssi: '-95',
|
||||||
|
snr: '6.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
|
||||||
|
...APPRISE_SAMPLE_VARS,
|
||||||
|
type: 'PRIV',
|
||||||
|
channel_name: '',
|
||||||
|
conversation_key: 'a1b2c3d4e5f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
|
||||||
|
let result = fmt;
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
result = result.split(`{${key}}`).join(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a markdown-ish string into inline React elements (bold + code spans). */
|
||||||
|
function appriseRenderMarkdown(s: string): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
// Split on **bold** and `code` spans
|
||||||
|
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={key++} className="font-bold">
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
nodes.push(
|
||||||
|
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else if (part) {
|
||||||
|
nodes.push(<span key={key++}>{part}</span>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
|
||||||
|
const raw = appriseApplyFormat(format, vars);
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Rendered (Discord, Slack)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Raw (Telegram, email)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
|
||||||
|
if (value == null) return true;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === '' || s === defaultStr;
|
||||||
|
}
|
||||||
|
|
||||||
function AppriseConfigEditor({
|
function AppriseConfigEditor({
|
||||||
config,
|
config,
|
||||||
scope,
|
scope,
|
||||||
@@ -2387,6 +2483,10 @@ function AppriseConfigEditor({
|
|||||||
onChange: (config: Record<string, unknown>) => void;
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const dmFormat = ((config.body_format_dm as string) || '').trim() || APPRISE_DEFAULT_DM;
|
||||||
|
const chanFormat =
|
||||||
|
((config.body_format_channel as string) || '').trim() || APPRISE_DEFAULT_CHANNEL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
@@ -2445,15 +2545,111 @@ function AppriseConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<Separator />
|
||||||
<input
|
|
||||||
type="checkbox"
|
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||||
checked={config.include_path !== false}
|
|
||||||
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
|
<details className="group">
|
||||||
className="h-4 w-4 rounded border-border"
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
|
Available variables
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
|
||||||
|
<span className="text-muted-foreground">Message body</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender display name</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender public key (hex)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{channel_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Channel name (channel messages only)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{conversation_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Contact pubkey (DM) or channel key (channel)
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
|
||||||
|
<span className="text-muted-foreground">PRIV or CHAN</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Comma-separated hop IDs, or "direct"
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hops_backticked}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hop_count}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop SNR in dB</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
|
||||||
|
{!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset DM format to default"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onChange({ ...config, body_format_dm: APPRISE_DEFAULT_DM })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="fanout-apprise-fmt-dm"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||||
|
placeholder={APPRISE_DEFAULT_DM}
|
||||||
|
value={(config.body_format_dm as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
|
||||||
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Include routing path in notifications</span>
|
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
|
||||||
|
{!appriseIsDefault(config.body_format_channel, APPRISE_DEFAULT_CHANNEL) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset channel format to default"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onChange({ ...config, body_format_channel: APPRISE_DEFAULT_CHANNEL })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="fanout-apprise-fmt-chan"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||||
|
placeholder={APPRISE_DEFAULT_CHANNEL}
|
||||||
|
value={(config.body_format_channel as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { MapPinned } from 'lucide-react';
|
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
import { api } from '../../api';
|
||||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||||
import type {
|
import type {
|
||||||
@@ -17,8 +26,116 @@ import type {
|
|||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
RadioDiscoveryTarget,
|
RadioDiscoveryTarget,
|
||||||
|
RadioStatsSnapshot,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
|
function formatUptime(secs: number): string {
|
||||||
|
const days = Math.floor(secs / 86400);
|
||||||
|
const hours = Math.floor((secs % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((secs % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAirtime(secs: number): string {
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const hours = Math.floor(secs / 3600);
|
||||||
|
const minutes = Math.floor((secs % 3600) / 60);
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 py-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono tabular-nums ${warn ? 'text-warning font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) {
|
||||||
|
const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null;
|
||||||
|
const packets = {
|
||||||
|
recv: stats.packets_recv,
|
||||||
|
sent: stats.packets_sent,
|
||||||
|
flood_tx: stats.flood_tx,
|
||||||
|
direct_tx: stats.direct_tx,
|
||||||
|
flood_rx: stats.flood_rx,
|
||||||
|
direct_rx: stats.direct_rx,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
|
Radio Details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||||
|
{age !== null && (
|
||||||
|
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Updated {age < 5 ? 'just now' : `${age}s ago`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Core */}
|
||||||
|
{stats.uptime_secs != null && (
|
||||||
|
<StatRow label="Uptime" value={formatUptime(stats.uptime_secs)} />
|
||||||
|
)}
|
||||||
|
{stats.battery_mv != null && stats.battery_mv > 0 && (
|
||||||
|
<StatRow label="Battery" value={`${(stats.battery_mv / 1000).toFixed(2)}V`} />
|
||||||
|
)}
|
||||||
|
{stats.queue_len != null && (
|
||||||
|
<StatRow
|
||||||
|
label="TX Queue"
|
||||||
|
value={`${stats.queue_len} / 16`}
|
||||||
|
warn={stats.queue_len >= 14}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stats.errors != null && (
|
||||||
|
<StatRow label="Errors" value={String(stats.errors)} warn={stats.errors > 0} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RF */}
|
||||||
|
{stats.noise_floor != null && (
|
||||||
|
<StatRow label="Noise Floor" value={`${stats.noise_floor} dBm`} />
|
||||||
|
)}
|
||||||
|
{stats.last_rssi != null && <StatRow label="Last RSSI" value={`${stats.last_rssi} dBm`} />}
|
||||||
|
{stats.last_snr != null && <StatRow label="Last SNR" value={`${stats.last_snr} dB`} />}
|
||||||
|
|
||||||
|
{/* Airtime */}
|
||||||
|
{(stats.tx_air_secs != null || stats.rx_air_secs != null) && (
|
||||||
|
<>
|
||||||
|
{stats.tx_air_secs != null && (
|
||||||
|
<StatRow label="TX Airtime" value={formatAirtime(stats.tx_air_secs)} />
|
||||||
|
)}
|
||||||
|
{stats.rx_air_secs != null && (
|
||||||
|
<StatRow label="RX Airtime" value={formatAirtime(stats.rx_air_secs)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Packets */}
|
||||||
|
{packets.recv != null && <StatRow label="Packets Received" value={String(packets.recv)} />}
|
||||||
|
{packets.sent != null && <StatRow label="Packets Sent" value={String(packets.sent)} />}
|
||||||
|
{packets.flood_tx != null && <StatRow label="Flood TX" value={String(packets.flood_tx)} />}
|
||||||
|
{packets.flood_rx != null && <StatRow label="Flood RX" value={String(packets.flood_rx)} />}
|
||||||
|
{packets.direct_tx != null && (
|
||||||
|
<StatRow label="Direct TX" value={String(packets.direct_tx)} />
|
||||||
|
)}
|
||||||
|
{packets.direct_rx != null && (
|
||||||
|
<StatRow label="Direct RX" value={String(packets.direct_rx)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsRadioSection({
|
export function SettingsRadioSection({
|
||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
@@ -320,6 +437,169 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
|
||||||
|
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
const buildConfigProfile = () => ({
|
||||||
|
version: 1,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
name: config.name,
|
||||||
|
lat: config.lat,
|
||||||
|
lon: config.lon,
|
||||||
|
tx_power: config.tx_power,
|
||||||
|
radio: { ...config.radio },
|
||||||
|
path_hash_mode: config.path_hash_mode,
|
||||||
|
advert_location_source: config.advert_location_source ?? 'current',
|
||||||
|
multi_acks_enabled: config.multi_acks_enabled ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadJson = (profile: object, suffix: string) => {
|
||||||
|
const blob = new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeName = (config.name || 'radio').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.replace(/[/:, ]+/g, '-');
|
||||||
|
a.download = `${safeName}-${suffix}-${timestamp}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportConfig = async () => {
|
||||||
|
const profile = buildConfigProfile();
|
||||||
|
try {
|
||||||
|
const { private_key } = await api.getPrivateKey();
|
||||||
|
downloadJson({ ...profile, private_key }, 'config');
|
||||||
|
toast.success('Export generated with private key');
|
||||||
|
} catch {
|
||||||
|
downloadJson(profile, 'config');
|
||||||
|
toast.info('Export generated without private key', {
|
||||||
|
description: 'See README_ADVANCED.md for private key export enable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateImportData = (
|
||||||
|
data: unknown
|
||||||
|
): data is {
|
||||||
|
name: string;
|
||||||
|
radio: { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
[k: string]: unknown;
|
||||||
|
} =>
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'name' in data &&
|
||||||
|
typeof (data as Record<string, unknown>).name === 'string' &&
|
||||||
|
'radio' in data &&
|
||||||
|
typeof (data as Record<string, unknown>).radio === 'object' &&
|
||||||
|
(data as Record<string, unknown>).radio !== null &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.freq === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.bw === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.sf === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.cr === 'number';
|
||||||
|
|
||||||
|
const populateFormFromImport = (data: Record<string, unknown>) => {
|
||||||
|
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
setName(data.name as string);
|
||||||
|
if (typeof data.lat === 'number') setLat(String(data.lat));
|
||||||
|
if (typeof data.lon === 'number') setLon(String(data.lon));
|
||||||
|
if (typeof data.tx_power === 'number') setTxPower(String(data.tx_power));
|
||||||
|
setFreq(String(radio.freq));
|
||||||
|
setBw(String(radio.bw));
|
||||||
|
setSf(String(radio.sf));
|
||||||
|
setCr(String(radio.cr));
|
||||||
|
if (typeof data.path_hash_mode === 'number') setPathHashMode(String(data.path_hash_mode));
|
||||||
|
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||||
|
setAdvertLocationSource(data.advert_location_source);
|
||||||
|
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
|
||||||
|
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
const update: RadioConfigUpdate = {
|
||||||
|
name: data.name as string,
|
||||||
|
lat: typeof data.lat === 'number' ? data.lat : config.lat,
|
||||||
|
lon: typeof data.lon === 'number' ? data.lon : config.lon,
|
||||||
|
tx_power: typeof data.tx_power === 'number' ? (data.tx_power as number) : config.tx_power,
|
||||||
|
radio,
|
||||||
|
};
|
||||||
|
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||||
|
update.advert_location_source = data.advert_location_source;
|
||||||
|
if (typeof data.multi_acks_enabled === 'boolean')
|
||||||
|
update.multi_acks_enabled = data.multi_acks_enabled;
|
||||||
|
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
|
||||||
|
update.path_hash_mode = data.path_hash_mode as number;
|
||||||
|
return update;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyImport = async (data: Record<string, unknown>) => {
|
||||||
|
populateFormFromImport(data);
|
||||||
|
const update = buildUpdateFromImport(data);
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setRebooting(true);
|
||||||
|
try {
|
||||||
|
if (typeof data.private_key === 'string' && data.private_key) {
|
||||||
|
await onSetPrivateKey(data.private_key);
|
||||||
|
toast.success('Config + private key imported, saving & rebooting...');
|
||||||
|
} else {
|
||||||
|
toast.success('Config imported, saving & rebooting...');
|
||||||
|
}
|
||||||
|
await onSave(update);
|
||||||
|
await onReboot();
|
||||||
|
if (!pageMode) onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||||
|
} finally {
|
||||||
|
setRebooting(false);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!validateImportData(data)) {
|
||||||
|
toast.error('Invalid config file', {
|
||||||
|
description: 'File must contain name and radio parameters (freq, bw, sf, cr)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.private_key === 'string' && data.private_key) {
|
||||||
|
// Private key present — show warning dialog before applying
|
||||||
|
pendingImportRef.current = data;
|
||||||
|
setKeyImportDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
await applyImport(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||||
|
} finally {
|
||||||
|
if (importInputRef.current) importInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmKeyImport = async () => {
|
||||||
|
setKeyImportDialogOpen(false);
|
||||||
|
const data = pendingImportRef.current;
|
||||||
|
pendingImportRef.current = null;
|
||||||
|
if (data) await applyImport(data);
|
||||||
|
};
|
||||||
|
|
||||||
const radioState =
|
const radioState =
|
||||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||||
const connectionActionLabel =
|
const connectionActionLabel =
|
||||||
@@ -414,6 +694,9 @@ export function SettingsRadioSection({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
||||||
|
|
||||||
|
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -678,6 +961,37 @@ export function SettingsRadioSection({
|
|||||||
Some settings may require a reboot to take effect on some radios.
|
Some settings may require a reboot to take effect on some radios.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportConfig} className="flex-1">
|
||||||
|
<Download className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
Export Config
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
disabled={busy || rebooting}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Upload className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
Import & Reboot
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImportConfig(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
Export saves the current server config to a JSON file. Import loads a config file, applies
|
||||||
|
it, and reboots the radio.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* ── Messaging ── */}
|
{/* ── Messaging ── */}
|
||||||
@@ -907,6 +1221,44 @@ export function SettingsRadioSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Private Key Import Warning ── */}
|
||||||
|
<Dialog
|
||||||
|
open={keyImportDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setKeyImportDialogOpen(open);
|
||||||
|
if (!open) pendingImportRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import includes Private Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This config file contains a private key. Importing it will change your radio's
|
||||||
|
identity — your radio will have a new public key and other nodes will see it as
|
||||||
|
a different device. This cannot be undone without the original key.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setKeyImportDialogOpen(false);
|
||||||
|
pendingImportRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmKeyImport}
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Import Config & Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function useContactsAndChannels({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateContact = useCallback(
|
const handleCreateContact = useCallback(
|
||||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
async (name: string, publicKey: string, tryHistorical: boolean, type?: number) => {
|
||||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
const created = await api.createContact(publicKey, name || undefined, tryHistorical, type);
|
||||||
const data = await fetchAllContacts();
|
const data = await fetchAllContacts();
|
||||||
setContacts(data);
|
setContacts(data);
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
console.error('Push subscribe failed:', err);
|
console.error('Push subscribe failed:', err);
|
||||||
toast.error('Failed to enable push notifications', {
|
toast.error('Failed to enable push notifications', {
|
||||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||||
|
duration: 8_000,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
|
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false, 1);
|
||||||
});
|
});
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,9 +171,12 @@ describe('usePushSubscription', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.loading).toBe(false);
|
expect(result.current.loading).toBe(false);
|
||||||
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to enable push notifications', {
|
expect(mocks.toast.error).toHaveBeenCalledWith(
|
||||||
description: expect.stringContaining('trusted TLS certificate for service workers'),
|
'Failed to enable push notifications',
|
||||||
});
|
expect.objectContaining({
|
||||||
|
description: expect.stringContaining('trusted TLS certificate for service workers'),
|
||||||
|
})
|
||||||
|
);
|
||||||
}, 5_000);
|
}, 5_000);
|
||||||
|
|
||||||
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
|
|||||||
timestamp: number | null;
|
timestamp: number | null;
|
||||||
battery_mv: number | null;
|
battery_mv: number | null;
|
||||||
uptime_secs: number | null;
|
uptime_secs: number | null;
|
||||||
|
queue_len: number | null;
|
||||||
|
errors: number | null;
|
||||||
noise_floor: number | null;
|
noise_floor: number | null;
|
||||||
last_rssi: number | null;
|
last_rssi: number | null;
|
||||||
last_snr: number | null;
|
last_snr: number | null;
|
||||||
|
|||||||
@@ -29,3 +29,7 @@ MESHCORE_DISABLE_BOTS=true
|
|||||||
# HTTP Basic Auth (recommended when bots are enabled)
|
# HTTP Basic Auth (recommended when bots are enabled)
|
||||||
#MESHCORE_BASIC_AUTH_USERNAME=
|
#MESHCORE_BASIC_AUTH_USERNAME=
|
||||||
#MESHCORE_BASIC_AUTH_PASSWORD=
|
#MESHCORE_BASIC_AUTH_PASSWORD=
|
||||||
|
|
||||||
|
# Enable GET /api/radio/private-key to return the in-memory private key as hex
|
||||||
|
# for backup or migration. Only enable on a trusted network.
|
||||||
|
#MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=false
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.1"
|
version = "3.12.3"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ test.describe('Apprise integration settings', () => {
|
|||||||
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
const preserveIdentity = page.getByText('Preserve identity on Discord');
|
||||||
await expect(preserveIdentity).toBeVisible();
|
await expect(preserveIdentity).toBeVisible();
|
||||||
|
|
||||||
// Verify include routing path checkbox is checked by default
|
// Verify format textareas are present under Message Format heading
|
||||||
const includePath = page.getByText('Include routing path in notifications');
|
await expect(page.getByText('Message Format')).toBeVisible();
|
||||||
await expect(includePath).toBeVisible();
|
await expect(page.locator('#fanout-apprise-fmt-dm')).toBeVisible();
|
||||||
|
await expect(page.locator('#fanout-apprise-fmt-chan')).toBeVisible();
|
||||||
|
|
||||||
// Rename it
|
// Rename it
|
||||||
const nameInput = page.locator('#fanout-edit-name');
|
const nameInput = page.locator('#fanout-edit-name');
|
||||||
@@ -94,7 +95,8 @@ test.describe('Apprise integration settings', () => {
|
|||||||
config: {
|
config: {
|
||||||
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
|
||||||
preserve_identity: false,
|
preserve_identity: false,
|
||||||
include_path: false,
|
body_format_dm: '{sender_name}: {text}',
|
||||||
|
body_format_channel: '{channel_name} | {sender_name}: {text}',
|
||||||
},
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
@@ -113,18 +115,18 @@ test.describe('Apprise integration settings', () => {
|
|||||||
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
|
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
|
||||||
|
|
||||||
// Verify checkboxes reflect our config (both unchecked)
|
// Verify preserve identity checkbox reflects our config (unchecked)
|
||||||
const preserveCheckbox = page
|
const preserveCheckbox = page
|
||||||
.getByText('Preserve identity on Discord')
|
.getByText('Preserve identity on Discord')
|
||||||
.locator('xpath=ancestor::label[1]')
|
.locator('xpath=ancestor::label[1]')
|
||||||
.locator('input[type="checkbox"]');
|
.locator('input[type="checkbox"]');
|
||||||
await expect(preserveCheckbox).not.toBeChecked();
|
await expect(preserveCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
const pathCheckbox = page
|
// Verify format textareas reflect our custom formats
|
||||||
.getByText('Include routing path in notifications')
|
const dmFormat = page.locator('#fanout-apprise-fmt-dm');
|
||||||
.locator('xpath=ancestor::label[1]')
|
await expect(dmFormat).toHaveValue('{sender_name}: {text}');
|
||||||
.locator('input[type="checkbox"]');
|
const chanFormat = page.locator('#fanout-apprise-fmt-chan');
|
||||||
await expect(pathCheckbox).not.toBeChecked();
|
await expect(chanFormat).toHaveValue('{channel_name} | {sender_name}: {text}');
|
||||||
|
|
||||||
// Go back
|
// Go back
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -203,6 +203,30 @@ class TestHealthEndpoint:
|
|||||||
class TestDebugEndpoint:
|
class TestDebugEndpoint:
|
||||||
"""Test the debug support snapshot endpoint."""
|
"""Test the debug support snapshot endpoint."""
|
||||||
|
|
||||||
|
def test_build_environment_exposes_env_settings(self):
|
||||||
|
"""_build_environment should expose env config without secrets."""
|
||||||
|
from app.config import Settings
|
||||||
|
from app.routers.debug import _build_environment
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.routers.debug.settings",
|
||||||
|
Settings(
|
||||||
|
serial_port="/dev/ttyUSB0",
|
||||||
|
serial_baudrate=115200,
|
||||||
|
log_level="DEBUG",
|
||||||
|
database_path="data/test.db",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
env = _build_environment()
|
||||||
|
|
||||||
|
assert env.connection_type == "serial"
|
||||||
|
assert env.serial_port == "/dev/ttyUSB0"
|
||||||
|
assert env.log_level == "DEBUG"
|
||||||
|
assert env.database_path == "data/test.db"
|
||||||
|
assert not hasattr(env, "ble_pin")
|
||||||
|
assert not hasattr(env, "basic_auth_password")
|
||||||
|
assert not hasattr(env, "basic_auth_username")
|
||||||
|
|
||||||
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
|
||||||
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
"""Debug radio probe should redact advertised lat/lon from self_info."""
|
||||||
from app.routers.debug import _sanitize_radio_probe_self_info
|
from app.routers.debug import _sanitize_radio_probe_self_info
|
||||||
@@ -300,6 +324,8 @@ class TestDebugEndpoint:
|
|||||||
assert "multi_acks_enabled" not in payload["radio_probe"]
|
assert "multi_acks_enabled" not in payload["radio_probe"]
|
||||||
assert "max_channels" not in payload["runtime"]
|
assert "max_channels" not in payload["runtime"]
|
||||||
assert "path_hash_mode" not in payload["runtime"]
|
assert "path_hash_mode" not in payload["runtime"]
|
||||||
|
assert "environment" in payload
|
||||||
|
assert payload["environment"]["connection_type"] in ("serial", "tcp", "ble")
|
||||||
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
assert payload["runtime"]["channels_with_incoming_messages"] == 0
|
||||||
assert payload["database"]["total_dms"] == 0
|
assert payload["database"]["total_dms"] == 0
|
||||||
assert payload["database"]["total_channel_messages"] == 0
|
assert payload["database"]["total_channel_messages"] == 0
|
||||||
|
|||||||
@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
|
|||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = MagicMock()
|
mock_radio.meshcore = MagicMock()
|
||||||
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
||||||
|
mock_radio.device_info_loaded = True
|
||||||
|
mock_radio.device_model = "T-Deck"
|
||||||
|
mock_radio.firmware_version = "v2.2.2"
|
||||||
|
mock_radio.firmware_build = "2025-01-15"
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
patch("app.radio.radio_manager", mock_radio),
|
patch("app.radio.radio_manager", mock_radio),
|
||||||
patch.object(
|
|
||||||
pub,
|
|
||||||
"_fetch_device_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
|
||||||
),
|
|
||||||
patch.object(
|
patch.object(
|
||||||
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
|
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
|
||||||
),
|
),
|
||||||
@@ -852,6 +850,82 @@ class TestLwtAndStatusPublish:
|
|||||||
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
||||||
assert payload["stats"] == {"battery_mv": 4200}
|
assert payload["stats"] == {"battery_mv": 4200}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_status_uses_fallback_fetch_when_device_info_not_loaded(self):
|
||||||
|
"""When device_info_loaded is False, _fetch_device_info() should be called as fallback."""
|
||||||
|
pub = CommunityMqttPublisher()
|
||||||
|
private_key, public_key = _make_test_keys()
|
||||||
|
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
|
||||||
|
|
||||||
|
mock_radio = MagicMock()
|
||||||
|
mock_radio.meshcore = MagicMock()
|
||||||
|
mock_radio.meshcore.self_info = {"name": "OldNode"}
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
|
patch("app.radio.radio_manager", mock_radio),
|
||||||
|
patch.object(
|
||||||
|
pub,
|
||||||
|
"_fetch_device_info",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={"model": "LegacyBoard", "firmware_version": "v2"},
|
||||||
|
) as mock_fetch,
|
||||||
|
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||||
|
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||||
|
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm/0-x"),
|
||||||
|
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||||
|
):
|
||||||
|
await pub._publish_status(settings)
|
||||||
|
|
||||||
|
mock_fetch.assert_awaited_once()
|
||||||
|
payload = mock_publish.call_args[0][1]
|
||||||
|
assert payload["model"] == "LegacyBoard"
|
||||||
|
assert payload["firmware_version"] == "v2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_status_reflects_updated_firmware_version_after_reconnect(self):
|
||||||
|
"""After firmware update + radio reconnect, the published firmware_version must be fresh.
|
||||||
|
|
||||||
|
This is a regression test for the stale-cache bug: previously _cached_device_info
|
||||||
|
was never cleared between reconnects, so a radio firmware update was invisible to
|
||||||
|
the Community MQTT status payload until the fanout module itself restarted.
|
||||||
|
"""
|
||||||
|
pub = CommunityMqttPublisher()
|
||||||
|
private_key, public_key = _make_test_keys()
|
||||||
|
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
|
||||||
|
|
||||||
|
mock_radio = MagicMock()
|
||||||
|
mock_radio.meshcore = MagicMock()
|
||||||
|
mock_radio.meshcore.self_info = {"name": "MyNode"}
|
||||||
|
mock_radio.device_info_loaded = True
|
||||||
|
mock_radio.device_model = "T-Deck"
|
||||||
|
mock_radio.firmware_version = "1.14.1"
|
||||||
|
mock_radio.firmware_build = ""
|
||||||
|
|
||||||
|
async def _publish_once(radio_mock):
|
||||||
|
with (
|
||||||
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
|
patch("app.radio.radio_manager", radio_mock),
|
||||||
|
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||||
|
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||||
|
patch("app.fanout.community_mqtt._get_client_version", return_value="RT/0-x"),
|
||||||
|
patch.object(pub, "publish", new_callable=AsyncMock) as mock_pub,
|
||||||
|
):
|
||||||
|
await pub._publish_status(settings)
|
||||||
|
return mock_pub.call_args[0][1]
|
||||||
|
|
||||||
|
first_payload = await _publish_once(mock_radio)
|
||||||
|
assert first_payload["firmware_version"] == "1.14.1"
|
||||||
|
|
||||||
|
# Simulate firmware update: radio reboots, radio_lifecycle refreshes the manager fields
|
||||||
|
mock_radio.firmware_version = "1.15.0"
|
||||||
|
|
||||||
|
second_payload = await _publish_once(mock_radio)
|
||||||
|
assert second_payload["firmware_version"] == "1.15.0", (
|
||||||
|
"Expected updated firmware version after reconnect; stale cache bug would return v1.14.1"
|
||||||
|
)
|
||||||
|
|
||||||
def test_lwt_and_online_share_same_topic(self):
|
def test_lwt_and_online_share_same_topic(self):
|
||||||
"""LWT and on-connect status should use the same topic path."""
|
"""LWT and on-connect status should use the same topic path."""
|
||||||
pub = CommunityMqttPublisher()
|
pub = CommunityMqttPublisher()
|
||||||
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
|
|||||||
|
|
||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = None
|
mock_radio.meshcore = None
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
|
|||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = MagicMock()
|
mock_radio.meshcore = MagicMock()
|
||||||
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
||||||
|
mock_radio.device_info_loaded = True
|
||||||
|
mock_radio.device_model = "T-Deck"
|
||||||
|
mock_radio.firmware_version = "v2.2.2"
|
||||||
|
mock_radio.firmware_build = "2025-01-15"
|
||||||
|
|
||||||
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
|
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
patch("app.radio.radio_manager", mock_radio),
|
patch("app.radio.radio_manager", mock_radio),
|
||||||
patch.object(
|
|
||||||
pub,
|
|
||||||
"_fetch_device_info",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
|
||||||
),
|
|
||||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
|
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
|
||||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
||||||
patch(
|
patch(
|
||||||
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
|
|||||||
|
|
||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = None
|
mock_radio.meshcore = None
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.keystore.get_public_key", return_value=public_key),
|
patch("app.keystore.get_public_key", return_value=public_key),
|
||||||
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
|
|||||||
|
|
||||||
mock_radio = MagicMock()
|
mock_radio = MagicMock()
|
||||||
mock_radio.meshcore = None
|
mock_radio.meshcore = None
|
||||||
|
mock_radio.device_info_loaded = False
|
||||||
|
|
||||||
before = time.monotonic()
|
before = time.monotonic()
|
||||||
|
|
||||||
|
|||||||
+136
-9
@@ -1049,7 +1049,8 @@ class TestAppriseFormatBody:
|
|||||||
from app.fanout.apprise_mod import _format_body
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"}, include_path=False
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="**DM:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**DM:** Alice: hi"
|
assert body == "**DM:** Alice: hi"
|
||||||
|
|
||||||
@@ -1058,7 +1059,7 @@ class TestAppriseFormatBody:
|
|||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
|
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#general"},
|
||||||
include_path=False,
|
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**#general:** Bob: hi"
|
assert body == "**#general:** Bob: hi"
|
||||||
|
|
||||||
@@ -1072,7 +1073,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Bob",
|
"sender_name": "Bob",
|
||||||
"channel_name": "#general",
|
"channel_name": "#general",
|
||||||
},
|
},
|
||||||
include_path=False,
|
body_format_channel="**{channel_name}:** {sender_name}: {text}",
|
||||||
)
|
)
|
||||||
assert body == "**#general:** Bob: hi"
|
assert body == "**#general:** Bob: hi"
|
||||||
|
|
||||||
@@ -1086,7 +1087,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "2027"}],
|
"paths": [{"path": "2027"}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`20`" in body
|
assert "`20`" in body
|
||||||
@@ -1097,7 +1098,7 @@ class TestAppriseFormatBody:
|
|||||||
|
|
||||||
body = _format_body(
|
body = _format_body(
|
||||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "`direct`" in body
|
assert "`direct`" in body
|
||||||
|
|
||||||
@@ -1112,7 +1113,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aabb`" in body
|
assert "`aabb`" in body
|
||||||
@@ -1129,7 +1130,7 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
"paths": [{"path": "aabbccddeeff", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aabbcc`" in body
|
assert "`aabbcc`" in body
|
||||||
@@ -1147,7 +1148,7 @@ class TestAppriseFormatBody:
|
|||||||
"channel_name": "#general",
|
"channel_name": "#general",
|
||||||
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
"paths": [{"path": "aabbccdd", "path_len": 2}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_channel="**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**#general:**" in body
|
assert "**#general:**" in body
|
||||||
assert "`aabb`" in body
|
assert "`aabb`" in body
|
||||||
@@ -1164,12 +1165,118 @@ class TestAppriseFormatBody:
|
|||||||
"sender_name": "Alice",
|
"sender_name": "Alice",
|
||||||
"paths": [{"path": "aabb"}],
|
"paths": [{"path": "aabb"}],
|
||||||
},
|
},
|
||||||
include_path=True,
|
body_format_dm="**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
)
|
)
|
||||||
assert "**via:**" in body
|
assert "**via:**" in body
|
||||||
assert "`aa`" in body
|
assert "`aa`" in body
|
||||||
assert "`bb`" in body
|
assert "`bb`" in body
|
||||||
|
|
||||||
|
def test_default_format_strings(self):
|
||||||
|
"""Default format strings produce expected output."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": "2a3b"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert body == "**DM:** Alice: hi **via:** [`2a`, `3b`]"
|
||||||
|
|
||||||
|
def test_custom_format_with_rssi(self):
|
||||||
|
"""Custom format string can include rssi/snr."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": "2a", "rssi": -95, "snr": 6.5}],
|
||||||
|
},
|
||||||
|
body_format_dm="From {sender_name}: {text} (rssi: {rssi}, snr: {snr})",
|
||||||
|
)
|
||||||
|
assert body == "From Alice: hi (rssi: -95, snr: 6.5)"
|
||||||
|
|
||||||
|
def test_unknown_placeholder_left_as_is(self):
|
||||||
|
"""Unknown {placeholders} pass through unchanged."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text} {unknown_var}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hi {unknown_var}"
|
||||||
|
|
||||||
|
def test_none_fields_render_empty(self):
|
||||||
|
"""None optional fields render as empty string, not 'None'."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text} rssi={rssi}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hi rssi="
|
||||||
|
assert "None" not in body
|
||||||
|
|
||||||
|
def test_hops_direct_when_no_paths(self):
|
||||||
|
"""hops is 'direct' when no path data exists."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
|
||||||
|
body_format_channel="{channel_name} {hops}",
|
||||||
|
)
|
||||||
|
assert body == "#gen direct"
|
||||||
|
|
||||||
|
def test_hops_direct_when_empty_path(self):
|
||||||
|
"""hops is 'direct' when path string is empty."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{
|
||||||
|
"type": "PRIV",
|
||||||
|
"text": "hi",
|
||||||
|
"sender_name": "Alice",
|
||||||
|
"paths": [{"path": ""}],
|
||||||
|
},
|
||||||
|
body_format_dm="{hops}",
|
||||||
|
)
|
||||||
|
assert body == "direct"
|
||||||
|
|
||||||
|
def test_no_re_expansion_of_substituted_values(self):
|
||||||
|
"""Placeholders in message text must not be expanded by later passes."""
|
||||||
|
from app.fanout.apprise_mod import _format_body
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
{"type": "PRIV", "text": "hello {sender_name}", "sender_name": "Alice"},
|
||||||
|
body_format_dm="{sender_name}: {text}",
|
||||||
|
)
|
||||||
|
assert body == "Alice: hello {sender_name}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_format_string_uses_default(self):
|
||||||
|
"""Empty format strings in config should produce default output, not blank."""
|
||||||
|
from unittest.mock import patch as _patch
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import AppriseModule
|
||||||
|
|
||||||
|
mod = AppriseModule(
|
||||||
|
"test",
|
||||||
|
{"urls": "json://localhost", "body_format_dm": "", "body_format_channel": " "},
|
||||||
|
)
|
||||||
|
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
|
||||||
|
await mod.on_message(
|
||||||
|
{"type": "PRIV", "text": "hi", "outgoing": False, "sender_name": "Alice"}
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
body = mock_send.call_args[0][1]
|
||||||
|
assert "Alice" in body
|
||||||
|
assert "hi" in body
|
||||||
|
assert body != ""
|
||||||
|
|
||||||
|
|
||||||
class TestAppriseNormalizeDiscordUrl:
|
class TestAppriseNormalizeDiscordUrl:
|
||||||
def test_discord_scheme(self):
|
def test_discord_scheme(self):
|
||||||
@@ -1233,6 +1340,26 @@ class TestAppriseValidation:
|
|||||||
|
|
||||||
_validate_apprise_config({"urls": "discord://123/abc"})
|
_validate_apprise_config({"urls": "discord://123/abc"})
|
||||||
|
|
||||||
|
def test_validate_apprise_config_accepts_format_strings(self):
|
||||||
|
from app.routers.fanout import _validate_apprise_config
|
||||||
|
|
||||||
|
_validate_apprise_config(
|
||||||
|
{
|
||||||
|
"urls": "discord://123/abc",
|
||||||
|
"body_format_dm": "DM from {sender_name}: {text}",
|
||||||
|
"body_format_channel": "{channel_name}: {text}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_apprise_config_rejects_non_string_format(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.fanout import _validate_apprise_config
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_validate_apprise_config({"urls": "discord://123/abc", "body_format_dm": 123})
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
def test_enforce_scope_apprise_strips_raw_packets(self):
|
def test_enforce_scope_apprise_strips_raw_packets(self):
|
||||||
from app.routers.fanout import _enforce_scope
|
from app.routers.fanout import _enforce_scope
|
||||||
|
|
||||||
|
|||||||
@@ -1171,7 +1171,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"preserve_identity": True,
|
"preserve_identity": True,
|
||||||
"include_path": False,
|
"body_format_dm": "**DM:** {sender_name}: {text}",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -1212,7 +1213,8 @@ class TestFanoutAppriseIntegration:
|
|||||||
name="Channel Apprise",
|
name="Channel Apprise",
|
||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"include_path": False,
|
"body_format_dm": "**DM:** {sender_name}: {text}",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text}",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -1541,13 +1543,14 @@ class TestFanoutAppriseIntegration:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
|
async def test_apprise_includes_routing_path(self, apprise_capture_server, integration_db):
|
||||||
"""Apprise with include_path=True shows routing hops in the body."""
|
"""Apprise with hops in format string shows routing hops in the body."""
|
||||||
cfg = await FanoutConfigRepository.create(
|
cfg = await FanoutConfigRepository.create(
|
||||||
config_type="apprise",
|
config_type="apprise",
|
||||||
name="Path Apprise",
|
name="Path Apprise",
|
||||||
config={
|
config={
|
||||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||||
"include_path": True,
|
"body_format_dm": "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
|
"body_format_channel": "**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]",
|
||||||
},
|
},
|
||||||
scope={"messages": "all", "raw_packets": "none"},
|
scope={"messages": "all", "raw_packets": "none"},
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||||
# change, not every individual assertion.
|
# change, not every individual assertion.
|
||||||
LATEST_SCHEMA_VERSION = 59
|
LATEST_SCHEMA_VERSION = 60
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
|
|
||||||
from app.fanout.mqtt_ha import (
|
from app.fanout.mqtt_ha import (
|
||||||
MqttHaModule,
|
MqttHaModule,
|
||||||
|
_assign_lpp_keys,
|
||||||
_contact_tracker_discovery_config,
|
_contact_tracker_discovery_config,
|
||||||
_device_payload,
|
_device_payload,
|
||||||
_lpp_discovery_configs,
|
_lpp_discovery_configs,
|
||||||
@@ -552,6 +553,45 @@ class TestLppSensorKey:
|
|||||||
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
|
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssignLppKeys:
|
||||||
|
def test_no_duplicates(self):
|
||||||
|
sensors = [
|
||||||
|
{"type_name": "temperature", "channel": 1, "value": 20},
|
||||||
|
{"type_name": "humidity", "channel": 2, "value": 45},
|
||||||
|
]
|
||||||
|
result = _assign_lpp_keys(sensors)
|
||||||
|
assert [(k, n) for _, k, n in result] == [
|
||||||
|
("lpp_temperature_ch1", 1),
|
||||||
|
("lpp_humidity_ch2", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_duplicate_type_and_channel(self):
|
||||||
|
sensors = [
|
||||||
|
{"type_name": "temperature", "channel": 1, "value": 20},
|
||||||
|
{"type_name": "humidity", "channel": 2, "value": 45},
|
||||||
|
{"type_name": "temperature", "channel": 1, "value": 53},
|
||||||
|
]
|
||||||
|
result = _assign_lpp_keys(sensors)
|
||||||
|
assert [(k, n) for _, k, n in result] == [
|
||||||
|
("lpp_temperature_ch1", 1),
|
||||||
|
("lpp_humidity_ch2", 1),
|
||||||
|
("lpp_temperature_ch1_2", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_triple_duplicate(self):
|
||||||
|
sensors = [
|
||||||
|
{"type_name": "voltage", "channel": 0, "value": 3.3},
|
||||||
|
{"type_name": "voltage", "channel": 0, "value": 5.0},
|
||||||
|
{"type_name": "voltage", "channel": 0, "value": 12.0},
|
||||||
|
]
|
||||||
|
result = _assign_lpp_keys(sensors)
|
||||||
|
keys = [k for _, k, _ in result]
|
||||||
|
assert keys == ["lpp_voltage_ch0", "lpp_voltage_ch0_2", "lpp_voltage_ch0_3"]
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
assert _assign_lpp_keys([]) == []
|
||||||
|
|
||||||
|
|
||||||
class TestLppDiscoveryConfigs:
|
class TestLppDiscoveryConfigs:
|
||||||
def test_produces_config_per_sensor(self):
|
def test_produces_config_per_sensor(self):
|
||||||
nid = "ccdd11223344"
|
nid = "ccdd11223344"
|
||||||
@@ -583,6 +623,27 @@ class TestLppDiscoveryConfigs:
|
|||||||
assert cfg["suggested_display_precision"] == 1
|
assert cfg["suggested_display_precision"] == 1
|
||||||
assert "lpp_temperature_ch1" in cfg["value_template"]
|
assert "lpp_temperature_ch1" in cfg["value_template"]
|
||||||
|
|
||||||
|
def test_duplicate_type_channel_gets_indexed_keys(self):
|
||||||
|
nid = "ccdd11223344"
|
||||||
|
device = _device_payload(nid, "Rep1", "Repeater")
|
||||||
|
sensors = [
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 20.0},
|
||||||
|
{"channel": 2, "type_name": "humidity", "value": 45.0},
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 53.0},
|
||||||
|
]
|
||||||
|
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
|
||||||
|
|
||||||
|
assert len(configs) == 3
|
||||||
|
topics = [t for t, _ in configs]
|
||||||
|
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config" in topics
|
||||||
|
assert f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config" in topics
|
||||||
|
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config" in topics
|
||||||
|
|
||||||
|
# First temperature keeps base name, second gets #2 suffix
|
||||||
|
names = {cfg["unique_id"]: cfg["name"] for _, cfg in configs}
|
||||||
|
assert names[f"meshcore_{nid}_lpp_temperature_ch1"] == "Temperature (Ch 1)"
|
||||||
|
assert names[f"meshcore_{nid}_lpp_temperature_ch1_2"] == "Temperature (Ch 1) #2"
|
||||||
|
|
||||||
def test_unknown_sensor_type_no_device_class(self):
|
def test_unknown_sensor_type_no_device_class(self):
|
||||||
nid = "ccdd11223344"
|
nid = "ccdd11223344"
|
||||||
device = _device_payload(nid, "Rep1", "Repeater")
|
device = _device_payload(nid, "Rep1", "Repeater")
|
||||||
@@ -712,6 +773,35 @@ class TestMqttHaTelemetryWithLpp:
|
|||||||
|
|
||||||
mod._publish_discovery.assert_not_awaited()
|
mod._publish_discovery.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telemetry_duplicate_lpp_sensors_not_overwritten(self):
|
||||||
|
"""Two sensors with same (type_name, channel) get distinct keys."""
|
||||||
|
key = "ccdd11223344"
|
||||||
|
nid = _node_id(key)
|
||||||
|
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
|
||||||
|
mod._publisher = MagicMock()
|
||||||
|
mod._publisher.connected = True
|
||||||
|
mod._publisher.publish = AsyncMock()
|
||||||
|
mod._discovery_topics = [
|
||||||
|
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config",
|
||||||
|
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config",
|
||||||
|
]
|
||||||
|
|
||||||
|
await mod.on_telemetry(
|
||||||
|
{
|
||||||
|
"public_key": key,
|
||||||
|
"battery_volts": 4.1,
|
||||||
|
"lpp_sensors": [
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 20.0},
|
||||||
|
{"channel": 1, "type_name": "temperature", "value": 53.0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mod._publisher.publish.call_args[0][1]
|
||||||
|
assert payload["lpp_temperature_ch1"] == 20.0
|
||||||
|
assert payload["lpp_temperature_ch1_2"] == 53.0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_telemetry_without_lpp_sensors(self):
|
async def test_on_telemetry_without_lpp_sensors(self):
|
||||||
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.routers.radio import (
|
|||||||
RadioSettings,
|
RadioSettings,
|
||||||
disconnect_radio,
|
disconnect_radio,
|
||||||
discover_mesh,
|
discover_mesh,
|
||||||
|
get_private_key,
|
||||||
get_radio_config,
|
get_radio_config,
|
||||||
reboot_radio,
|
reboot_radio,
|
||||||
reconnect_radio,
|
reconnect_radio,
|
||||||
@@ -283,6 +284,38 @@ class TestUpdateRadioConfig:
|
|||||||
mc.commands.send_appstart.assert_not_awaited()
|
mc.commands.send_appstart.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrivateKeyExport:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_403_when_export_disabled(self):
|
||||||
|
with patch("app.config.settings") as mock_settings:
|
||||||
|
mock_settings.enable_local_private_key_export = False
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_private_key()
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_404_when_no_key_available(self):
|
||||||
|
with (
|
||||||
|
patch("app.config.settings") as mock_settings,
|
||||||
|
patch("app.keystore.get_private_key", return_value=None),
|
||||||
|
):
|
||||||
|
mock_settings.enable_local_private_key_export = True
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_private_key()
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_key_hex_when_enabled_and_available(self):
|
||||||
|
key_bytes = bytes.fromhex("ab" * 64)
|
||||||
|
with (
|
||||||
|
patch("app.config.settings") as mock_settings,
|
||||||
|
patch("app.keystore.get_private_key", return_value=key_bytes),
|
||||||
|
):
|
||||||
|
mock_settings.enable_local_private_key_export = True
|
||||||
|
result = await get_private_key()
|
||||||
|
assert result == {"private_key": "ab" * 64}
|
||||||
|
|
||||||
|
|
||||||
class TestPrivateKeyImport:
|
class TestPrivateKeyImport:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rejects_invalid_hex(self):
|
async def test_rejects_invalid_hex(self):
|
||||||
|
|||||||
@@ -1453,11 +1453,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.1"
|
version = "1.2.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1533,7 +1533,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.1"
|
version = "3.12.3"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiomqtt" },
|
{ name = "aiomqtt" },
|
||||||
|
|||||||
Reference in New Issue
Block a user