Compare commits

...

90 Commits

Author SHA1 Message Date
Jack Kingsman 89d7725a17 (bug) fix contact telemetry chart visual issues. Closes #261. 2026-05-14 21:03:06 -07:00
Jack Kingsman d5b27a2405 (bug) invalidate repeater panel map tiling on resize. Closes #262. 2026-05-14 20:57:09 -07:00
Jack Kingsman e23613d18a fix: units in lpp sensors to HA. Extends PR #260. 2026-05-14 09:01:10 -07:00
Jack Kingsman 2a6453bc5a Merge pull request #260 from mitte2/mitte2-patch-1
current sensor unit
2026-05-14 08:58:28 -07:00
Jack Kingsman f3e9953b48 Fix linting 2026-05-14 08:58:18 -07:00
mitte2 48fd2412e8 current sensor unit
fix wrong unit in lpp sensors, add convertion between A and mA
2026-05-14 17:47:52 +02:00
Jack Kingsman 9b67512225 Merge pull request #239 from Bjorkan/API-test
Add Swagger UI API page to /docs
2026-05-14 08:44:17 -07:00
Bjorkan 40591610ea Removed button from Integration page 2026-05-14 10:28:02 +02:00
Jack Kingsman 6cfd5eff63 Updating changelog + build for 3.14.0 2026-05-13 18:16:32 -07:00
Jack Kingsman 9addb2b403 Merge pull request #229 from jkingsman/tracked-contact-telemetry
Tracked contact telemetry
2026-05-13 17:52:32 -07:00
Jack Kingsman 2778e8bd4f Don't use ghost shape of telemetry, and fix ceiling behavior 2026-05-13 17:47:24 -07:00
Jack Kingsman 896267ff7e Fix migration bump 2026-05-13 17:30:07 -07:00
Jack Kingsman a4fd1d3b37 Propagate to HA 2026-05-13 17:30:07 -07:00
Jack Kingsman 2eb8ac15a8 Reject repeaters from contact telemetry opt-in 2026-05-13 17:30:06 -07:00
Jack Kingsman 84aa352be3 Split up setting to be a bit neater 2026-05-13 17:30:06 -07:00
Jack Kingsman 7f1bb92e92 Add telemetry config to radio settings 2026-05-13 17:30:05 -07:00
Jack Kingsman 0bd0c062f2 Initial tracke telemetry for contacts 2026-05-13 17:30:05 -07:00
Jack Kingsman 72f3d95acf Fix gap in don't-ingest logic. Closes #247. 2026-05-13 16:59:29 -07:00
Jack Kingsman b77660196b Persist login status for room servers. Closes #244. 2026-05-13 16:52:32 -07:00
Jack Kingsman 79c8b45d89 Don't forward unparseable packets to community endpoints. Closes #255. 2026-05-13 16:43:52 -07:00
Jack Kingsman baca8b5234 Merge pull request #253 from MartinFournier/feature/community-mqtt-websocket-path
Add WebSocket path config for community MQTT
2026-05-13 16:40:11 -07:00
Jack Kingsman f1a27279e8 Merge pull request #258 from Rescla/main
Remove autoComplete="off" from MessageInput textarea
2026-05-13 16:39:58 -07:00
Jack Kingsman 5033beacc9 Add test and simplify strip logic 2026-05-13 16:36:45 -07:00
Jack Kingsman 6e4f1ac47b Drop token renewal time to one hour. Closes #248. 2026-05-13 16:31:16 -07:00
Jack Kingsman 8905392b29 Add missing frequencies. Closes #245. 2026-05-13 16:28:05 -07:00
Jack Kingsman e95acecbfb Stable packet analytics coloring. Closes #246. 2026-05-13 16:15:41 -07:00
Jack Kingsman f1eca53625 Add packet scope to inspection. Closes #256 2026-05-13 16:06:44 -07:00
Jack Kingsman a13b16b81c Merge pull request #254 from jkingsman/dependabot/uv/uv-c30c77f42d
Bump urllib3 from 2.6.3 to 2.7.0 in the uv group across 1 directory
2026-05-13 16:00:42 -07:00
Rescla 34cd06cc04 Remove autoComplete="off" from textarea 2026-05-13 13:57:32 +02:00
dependabot[bot] 11f17773df Bump urllib3 from 2.6.3 to 2.7.0 in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [urllib3](https://github.com/urllib3/urllib3).


Updates `urllib3` from 2.6.3 to 2.7.0
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 19:06:59 +00:00
Martin Fournier 25190cded5 Add WebSocket path config for community MQTT
Path hardcoded to "/". Brokers like analyzer.montrealmesh.ca need
non-root path (e.g. /mqtt). Expose field in fanout config + UI.
2026-05-11 02:13:09 -04:00
jkingsman 70cb133b24 Revise hop length buckets. Closes #240. 2026-05-03 12:32:50 -07:00
Björkan 32de22865e Removed empty app/tcp_proxy/__init__.py 2026-05-01 08:19:17 +02:00
Björkan 431d4d419a Add Swagger UI api page on Automation page 2026-05-01 08:15:50 +02:00
Jack Kingsman f95745cb05 Updating changelog + build for 3.13.0 2026-04-30 20:31:32 -07:00
Jack Kingsman 39ba88bc4b Fix up e2e tests 2026-04-30 20:22:59 -07:00
Jack Kingsman e814653300 Add non-markdown option. Closes #232. 2026-04-30 19:54:43 -07:00
Jack Kingsman e76d922752 Add recieved time to packet display. Closes #238. 2026-04-30 19:07:50 -07:00
Jack Kingsman d0e02a42f8 Merge pull request #237 from Bjorkan/TraceFix
Return HTTP 422 for missing trace responses to avoid confusing proxies in front of RemoteTerm. Closes #236.
2026-04-30 18:51:24 -07:00
Jack Kingsman dbf14259dc Do full rewrite of 5xx => 4xx 2026-04-30 18:47:35 -07:00
Jack Kingsman a9ac87e668 Allow newlines in text input. Closes #234. 2026-04-30 18:36:36 -07:00
Björkan f710a1f2d9 Change failed trace from using 504 to instead use 422 2026-04-30 23:03:08 +02:00
Björkan 9f6c0f12c5 Don't include .codex file 2026-04-30 22:58:59 +02:00
Jack Kingsman 466f693c21 Fix page to dvh. Closes #233. 2026-04-28 14:41:56 -07:00
Jack Kingsman 16f87e640f Attempt up to three Apprise retries. Closes #232. 2026-04-28 14:40:14 -07:00
Jack Kingsman 761fd82da6 Backoff MQTT failures all the way up to 1hr on connection failure, and also don't multi-toast on connection error. Closes #231. 2026-04-28 12:00:03 -07:00
Jack Kingsman 2c1279eb9e Add error rate percentage to metrics graph 2026-04-27 11:21:02 -07:00
Jack Kingsman 047d713003 Permit hourly checks for direct/routed repeaters. Closes #226. 2026-04-27 09:51:57 -07:00
Jack Kingsman 25041e1367 Add dynamic text replacement. Closes #223. 2026-04-25 15:00:36 -07:00
Jack Kingsman b3fe717416 Correct packet sum for repeater error rate. Closes #225. 2026-04-25 14:48:44 -07:00
Jack Kingsman 9a4e78c504 Show RX error percentage 2026-04-25 14:01:39 -07:00
Jack Kingsman d436de67a2 Merge pull request #224 from jkingsman/repeater-error-count
Repeater error count
2026-04-25 13:54:42 -07:00
Jack Kingsman 89cee49725 Actuall bump lib 2026-04-25 13:45:43 -07:00
Jack Kingsman b37ce89c96 Add repeater telemetry error count 2026-04-25 13:45:17 -07:00
Jack Kingsman f0b7842c60 Merge pull request #221 from jkingsman/dependabot/npm_and_yarn/frontend/npm_and_yarn-754666cf41
Bump postcss from 8.5.8 to 8.5.10 in /frontend in the npm_and_yarn group across 1 directory
2026-04-24 18:06:54 -07:00
Jack Kingsman 4eb29f376e Make clearer save button for advert interval 2026-04-24 14:44:27 -07:00
dependabot[bot] 82a6553539 Bump postcss in /frontend in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [postcss](https://github.com/postcss/postcss).


Updates `postcss` from 8.5.8 to 8.5.10
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 21:10:08 +00:00
Jack Kingsman a69eb9c534 Updating changelog + build for 3.12.3 2026-04-24 14:03:11 -07:00
Jack Kingsman 70aabb78aa Remove MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT from standard README -- new users don't need that. 2026-04-23 12:38:42 -07:00
Jack Kingsman cafd9678ee Fix e2e tests after apprise message updates 2026-04-22 18:12:01 -07:00
Jack Kingsman a8e346d0c5 Docs, schema, and error handling improvements 2026-04-22 18:08:47 -07:00
Jack Kingsman 55f05bf03b Add dropdown to choose contact addition type. Closes #216. 2026-04-22 17:43:01 -07:00
Jack Kingsman 091ba06ccf Make bulk delete sortable and filterable by last-heard. Closes #218. 2026-04-22 17:01:50 -07:00
Jack Kingsman c5c828a4ed Bypass error on fail-to-unload-contact-because-it's-not-there 2026-04-21 20:38:05 -07:00
Jack Kingsman 7eac3a9754 Use padding in repeaters 2026-04-21 20:15:16 -07:00
Jack Kingsman 329df1a0d2 Add conversational padding to toasts. Closes #214. 2026-04-21 20:09:30 -07:00
Jack Kingsman ecb4c99a43 Make Apprise strings customizable. Closes #212. 2026-04-21 19:40:14 -07:00
Jack Kingsman 2f412e1a93 Be clearer about private key export 2026-04-21 13:47:46 -07:00
Jack Kingsman 0353a98e87 Merge pull request #213 from jkingsman/dependabot/uv/uv-4ea199e985
Bump python-dotenv from 1.2.1 to 1.2.2 in the uv group across 1 directory
2026-04-21 13:15:00 -07:00
dependabot[bot] 3e2258c34b Bump python-dotenv in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [python-dotenv](https://github.com/theskumar/python-dotenv).


Updates `python-dotenv` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.2
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 20:11:34 +00:00
Jack Kingsman e695d629b9 Updating changelog + build for 3.12.2 2026-04-21 13:10:25 -07:00
Jack Kingsman 300677aca3 Disambiguate colliding sensors and display all. Closes #211. 2026-04-21 10:14:09 -07:00
jkingsman b89f7ce76b Add missing docs around pk export 2026-04-20 20:10:21 -07:00
Jack Kingsman 82bd25a09f Merge pull request #210 from jkingsman/radio-config-export
Add config export
2026-04-20 19:58:58 -07:00
Jack Kingsman 7528e4121f Add config export 2026-04-20 19:55:25 -07:00
Jack Kingsman b8f0228f68 Merge pull request #209 from kizniche/fix-stale-mqtt-radio-values
Fix Community MQTT publishing stale firmware_version and model
2026-04-20 19:48:11 -07:00
Kizniche 25089930f1 fIX Community MQTT publishing stale firmware_version and model 2026-04-20 21:47:38 -04:00
Jack Kingsman 291bd85c78 Better env var/config knob exposure 2026-04-20 16:43:43 -07:00
Jack Kingsman 4bc87b4a0f Add debug radio details to radio pane 2026-04-20 16:10:24 -07:00
Jack Kingsman 6d0434d59e Add more intense logging on errors 2026-04-20 16:10:24 -07:00
Jack Kingsman f22184c166 Update README.md to be more clear about core purpose 2026-04-19 23:40:23 -07:00
Jack Kingsman d10de8abf7 CI/CD improvements for codeql 2026-04-19 23:33:45 -07:00
Jack Kingsman 5f78294cd1 Longer linger for web push mobile error 2026-04-19 23:04:36 -07:00
Jack Kingsman 6b81dd3082 Updating changelog + build for 3.12.1 2026-04-19 21:18:26 -07:00
Jack Kingsman cc2b16e53f Test fix 2026-04-19 21:14:38 -07:00
Jack Kingsman 330007e120 Be smarter about web push not being available on snakeoil certs for mobile 2026-04-19 21:10:17 -07:00
Jack Kingsman f5a2a21f11 Fix e2e tests 2026-04-19 20:45:11 -07:00
Jack Kingsman a3e62885d4 Merge pull request #206 from jkingsman/dependabot/uv/uv-2c6491f7af
Bump the uv group across 1 directory with 2 updates
2026-04-19 19:36:12 -07:00
Jack Kingsman dbdd722c48 Merge pull request #207 from jkingsman/channel-mute
Add channel mute
2026-04-19 19:35:52 -07:00
dependabot[bot] b8683e57d8 Bump the uv group across 1 directory with 2 updates
Bumps the uv group with 2 updates in the / directory: [pytest](https://github.com/pytest-dev/pytest) and [requests](https://github.com/psf/requests).


Updates `pytest` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

Updates `requests` from 2.32.5 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
  dependency-group: uv
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 01:44:21 +00:00
126 changed files with 6511 additions and 990 deletions
+10
View File
@@ -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
+3
View File
@@ -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
+35
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ references/
# ancillary LLM files # ancillary LLM files
.claude/ .claude/
.codex
# local Docker compose files # local Docker compose files
docker-compose.yml docker-compose.yml
+8 -1
View File
@@ -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 |
@@ -349,6 +350,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals | | POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info | | POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) | | GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server | | POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry | | POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
@@ -379,6 +382,9 @@ 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/tracked-telemetry-contacts/toggle` | Toggle tracked LPP telemetry for any contact |
| GET | `/api/settings/tracked-telemetry-contacts/schedule` | Contact telemetry scheduling derivation (shared ceiling with repeaters) |
| 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,8 +510,9 @@ 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`, `tracked_telemetry_contacts`, `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`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+54
View File
@@ -1,3 +1,57 @@
## [3.14.0] - 2026-05-13
* Feature: Support active/intervalized contact telemetry gathering + HA forwarding
* Feature: Stable packet analyzer chart coloring
* Feature: Add packet scope to inscpection
* Feature: Support websocket path config for community mqtt
* Bugfix: Drop token renewal time to 1hr for more sensitive services
* Bugfix: Don't forward unparseable packets to communitya ggregators
* Bugfix: Persist login status for rooms
* Bugfix: Fix gap in repeater/contact/sensor non-ingest logic
* Misc: Revise hop-length buckets to reflect path bit width
* Misc: Remove autocomplete from textarea
* Misc: Test & Dependency updates
## [3.13.0] - 2026-04-30
* Feature: Error counts included in repeater telemetry
* Feature: RX error rate + percentage surfaced and tracked for repeaters
* Feature: Dynamic as-you-type text replacement for Cyrillic byte optimization
* Feature: Permit hourly checks for direct/routed repeaters
* Feature: Allow newlines in input
* Feature: Packet-send radio time added to packet analyzer
* Feature: Enable forced plaintext for Apprise
* Bugfix: Less annoying MQTT failure notifications with backoff
* Bugfis: Don't obscure input; use dvh everywhere
* Bugfix: Clearer save button for advert interval
* Misc: Library updates
* Misc: Rewrite 5xx to 4xx to avoid issues with proxies that don't react well to 503/504
## [3.12.3] - 2026-04-24
* Feature: Customizable Apprise strings
* Feature: Choose contact addition type
* Featuer: Make bulk-delete sortable by last-heard
* Misc: Bypass error on fail-to-unload-contact when it's not there
* Misc: Docs & test updates
## [3.12.2] - 2026-04-21
* Feature: Auto-disambiguate colliding LPP sensor names
* Feature: Radio config import/export
* Bugfix: Don't push stale firmware version/model on community MQTT
* Misc: Expose env vars in debug blob
* Misc: Longer linger for web push error
* Misc: Docs, test, & CI/CD improvements
## [3.12.1] - 2026-04-19
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
* Feature: Channel mute
* Misc: HA Documentation improvements
* Misc: Bump deps & update tests
* Misc: Improve warnings around web push in untrusted contexts
## [3.12.0] - 2026-04-17 ## [3.12.0] - 2026-04-17
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed! * Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
+1 -1
View File
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
</details> </details>
### meshcore (2.3.2) — MIT ### meshcore (2.3.7) — MIT
<details> <details>
<summary>Full license text</summary> <summary>Full license text</summary>
+6 -4
View File
@@ -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).
@@ -78,7 +80,7 @@ cd frontend && npm install && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
``` ```
Access the app at http://localhost:8000. Access the app at http://localhost:8000. Once the backend is running, the interactive API docs are available at http://localhost:8000/docs.
Source checkouts expect a normal frontend build in `frontend/dist`. Source checkouts expect a normal frontend build in `frontend/dist`.
+28 -7
View File
@@ -1,25 +1,46 @@
# Advanced Setup And Troubleshooting # Advanced Setup And Troubleshooting
## Remediation Environment Variables Once the backend is running, FastAPI serves interactive API docs at `/docs` on the same host and port as the web UI. For a default local launch, that is http://localhost:8000/docs.
These are intended for diagnosing or working around radios that behave oddly. ## Remediation & Advanced Environment Variables
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.
+17 -3
View File
@@ -169,7 +169,8 @@ app/
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`. - Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message`, `raw_packet`, and `contact` events. - `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message`, `raw_packet`, and `contact` events.
- `on_message` and `on_raw` are scope-gated. `on_contact`, `on_telemetry`, and `on_health` are dispatched to all modules unconditionally (modules filter internally). - `on_message` and `on_raw` are scope-gated. `on_contact`, `on_telemetry`, and `on_health` are dispatched to all modules unconditionally (modules filter internally).
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch). - Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch). Contact LPP telemetry is similarly recorded to `ContactTelemetryRepository` and dispatched to fanout.
- The telemetry collection loop in `radio_sync.py` is unified: it iterates over both `tracked_telemetry_repeaters` and `tracked_telemetry_contacts`, dispatching to `_collect_repeater_telemetry` (type 2) or `_collect_contact_telemetry` (others). The daily check ceiling uses the combined count.
- The 60-second radio stats sampling loop in `radio_stats.py` dispatches an enriched health snapshot (radio identity + full stats) to all fanout modules after each sample. - The 60-second radio stats sampling loop in `radio_stats.py` dispatches an enriched health snapshot (radio identity + full stats) to all fanout modules after each sample.
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes. - Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes. - See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
@@ -196,6 +197,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
### Radio ### Radio
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` - `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled` - `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
- `PUT /radio/private-key` - `PUT /radio/private-key`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`) - `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors - `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
@@ -226,6 +228,8 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /contacts/{public_key}/repeater/advert-intervals` - `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info` - `POST /contacts/{public_key}/repeater/owner-info`
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access) - `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
- `POST /contacts/{public_key}/room/login` - `POST /contacts/{public_key}/room/login`
- `POST /contacts/{public_key}/room/status` - `POST /contacts/{public_key}/room/status`
- `POST /contacts/{public_key}/room/lpp-telemetry` - `POST /contacts/{public_key}/room/lpp-telemetry`
@@ -266,6 +270,9 @@ 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/tracked-telemetry-contacts/toggle` — toggle tracked LPP telemetry for any contact (max 8)
- `GET /settings/tracked-telemetry-contacts/schedule` — contact telemetry scheduling (shared ceiling with repeaters)
- `POST /settings/muted-channels/toggle`
### Fanout ### Fanout
- `GET /fanout` — list all fanout configs - `GET /fanout` — list all fanout configs
@@ -318,6 +325,7 @@ Main tables:
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count) - `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
- `contact_name_history` (tracks name changes over time) - `contact_name_history` (tracks name changes over time)
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters) - `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
- `contact_telemetry_history` (time-series LPP telemetry snapshots for tracked contacts; same schema as repeater table)
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs) - `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
- `push_subscriptions` (Web Push browser subscriptions with delivery metadata; UNIQUE on endpoint) - `push_subscriptions` (Web Push browser subscriptions with delivery metadata; UNIQUE on endpoint)
- `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing) - `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing)
@@ -341,7 +349,7 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types` - `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters` - `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel` - `auto_resend_channel`
- `telemetry_interval_hours` - `telemetry_interval_hours`
@@ -396,7 +404,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 +423,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
+515
View File
@@ -0,0 +1,515 @@
"""Custom OpenAPI documentation page for the RemoteTerm API."""
from __future__ import annotations
import json
from html import escape
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse
API_DESCRIPTION = (
"RemoteTerm exposes the MeshCore companion radio as a local REST and WebSocket API.\n\n"
"REST endpoints are mounted below `/api`. The live WebSocket stream is available at "
"`/api/ws` for health, message, raw-packet, contact, and telemetry events.\n\n"
"**Trusted network note:** RemoteTerm is designed for trusted local networks. Optional "
"HTTP Basic auth can be enabled for the whole app, but operators should pair it with "
"HTTPS when credentials cross the network."
)
API_TAGS_METADATA: list[dict[str, Any]] = [
{
"name": "health",
"description": "Connection state, build info, database size, radio stats, and fanout health.",
},
{
"name": "debug",
"description": "Support snapshots for logs, live radio probes, drift audits, and version data.",
},
{
"name": "radio",
"description": "Radio configuration, connection lifecycle, discovery, trace, and advert commands.",
},
{
"name": "contacts",
"description": "Mesh contacts, analytics, read state, route overrides, and path discovery.",
},
{
"name": "repeaters",
"description": "Repeater login, telemetry, ACL, owner info, radio settings, and CLI commands.",
},
{
"name": "rooms",
"description": "Room-server login, status, telemetry, and ACL operations.",
},
{
"name": "channels",
"description": "Channel creation, metadata, read state, flood scope, and path-hash overrides.",
},
{
"name": "messages",
"description": "Message history, direct sends, channel sends, and channel resend workflows.",
},
{
"name": "packets",
"description": "Raw packet inspection, historical decryption, undecrypted counts, and maintenance.",
},
{
"name": "read-state",
"description": "Server-side unread counters, mention flags, and mark-all-read operations.",
},
{
"name": "settings",
"description": "App settings, favorites, muted channels, block lists, and telemetry tracking.",
},
{
"name": "push",
"description": "Browser Web Push subscriptions, per-device preferences, tests, and conversations.",
},
{
"name": "fanout",
"description": "MQTT, bots, webhooks, Apprise, SQS, Home Assistant, and map upload integrations.",
},
{
"name": "statistics",
"description": "Aggregated mesh, message, packet, channel, and contact statistics.",
},
]
SWAGGER_UI_CSS_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"
SWAGGER_UI_BUNDLE_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"
SWAGGER_UI_PRESET_URL = (
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"
)
COMMON_ERROR_RESPONSES: dict[str, str] = {
"400": "Bad request",
"401": "Authentication required",
"403": "Forbidden",
"404": "Not found",
"408": "Request timed out",
"409": "Conflict",
"422": "Validation or command error",
"423": "Radio unavailable or locked",
"500": "Server error",
}
ERROR_RESPONSE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"detail": {
"description": "Human-readable error detail or structured validation detail.",
"oneOf": [
{"type": "string"},
{"type": "array", "items": {}},
{"type": "object", "additionalProperties": True},
],
}
},
}
def _relative_openapi_url(app: FastAPI) -> str:
"""Keep docs usable behind reverse-proxy path prefixes."""
openapi_url = app.openapi_url or "/openapi.json"
return openapi_url.lstrip("/")
def _error_response(description: str) -> dict[str, Any]:
return {
"description": description,
"content": {
"application/json": {
"schema": ERROR_RESPONSE_SCHEMA,
}
},
}
def _add_common_error_responses(openapi_schema: dict[str, Any]) -> None:
paths = openapi_schema.get("paths")
if not isinstance(paths, dict):
return
for path_item in paths.values():
if not isinstance(path_item, dict):
continue
for operation in path_item.values():
if not isinstance(operation, dict):
continue
responses = operation.setdefault("responses", {})
if not isinstance(responses, dict):
continue
for status_code, description in COMMON_ERROR_RESPONSES.items():
responses.setdefault(status_code, _error_response(description))
def install_custom_openapi(app: FastAPI) -> None:
"""Install OpenAPI metadata polishing used by the custom docs page."""
original_openapi = app.openapi
def custom_openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
openapi_version=app.openapi_version,
description=app.description,
routes=app.routes,
tags=app.openapi_tags,
servers=app.servers,
)
_add_common_error_responses(openapi_schema)
app.openapi_schema = openapi_schema
return app.openapi_schema
# Keep a reference for debuggability and for tests that may introspect it.
app.state.default_openapi = original_openapi
app.openapi = custom_openapi # type: ignore[method-assign]
def _build_swagger_docs_html(app: FastAPI) -> str:
title = escape(app.title)
version = escape(app.version)
openapi_url = json.dumps(_relative_openapi_url(app))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{title}</title>
<link rel="stylesheet" href="{SWAGGER_UI_CSS_URL}">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111419'/%3E%3Cpath d='M16 42h32M18 42l8-20 6 14 6-14 8 20' fill='none' stroke='%2374d99f' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='26' cy='22' r='4' fill='%23f5c15c'/%3E%3Ccircle cx='38' cy='22' r='4' fill='%2386b7ff'/%3E%3C/svg%3E">
<style>
:root {{
--docs-ink: #111419;
--docs-muted: #5b6674;
--docs-panel: #ffffff;
--docs-border: #d8dee7;
--docs-page: #f4f7f6;
--docs-green: #2f9d69;
--docs-amber: #b7791f;
--docs-blue: #286fc2;
--docs-red: #c2413b;
}}
* {{
box-sizing: border-box;
}}
body {{
margin: 0;
background: var(--docs-page);
color: var(--docs-ink);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}}
.docs-hero {{
color: #ffffff;
background: #141a1d;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}}
.docs-hero-inner {{
width: min(1280px, calc(100% - 40px));
margin: 0 auto;
padding: 32px 0 28px;
}}
.docs-kicker {{
margin: 0 0 10px;
color: #74d99f;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}}
.docs-hero h1 {{
margin: 0;
font-size: 3.2rem;
line-height: 1.04;
letter-spacing: 0;
}}
.docs-lede {{
max-width: 780px;
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.82);
font-size: 1.04rem;
line-height: 1.55;
}}
.docs-actions {{
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}}
.docs-action {{
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 13px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 8px;
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
font-size: 0.9rem;
font-weight: 700;
text-decoration: none;
}}
.docs-action:hover {{
background: rgba(255, 255, 255, 0.15);
}}
.docs-action strong {{
color: #f5c15c;
font-weight: 800;
}}
.docs-meta {{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 22px;
}}
.docs-pill {{
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
color: rgba(255, 255, 255, 0.84);
background: rgba(255, 255, 255, 0.08);
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}}
.docs-pill span {{
margin-left: 6px;
color: #ffffff;
font-weight: 800;
}}
.swagger-ui {{
width: min(1280px, calc(100% - 40px));
margin: 24px auto 56px;
}}
.swagger-ui .topbar {{
display: none;
}}
.swagger-ui .information-container.wrapper,
.swagger-ui .scheme-container,
.swagger-ui .opblock-tag-section,
.swagger-ui .models {{
max-width: none;
}}
.swagger-ui .info {{
margin: 0 0 20px;
padding: 22px 24px;
border: 1px solid var(--docs-border);
border-radius: 8px;
background: var(--docs-panel);
box-shadow: 0 10px 32px rgba(17, 20, 25, 0.08);
}}
.swagger-ui .info .title {{
color: var(--docs-ink);
font-size: 1.55rem;
}}
.swagger-ui .info p,
.swagger-ui .info li,
.swagger-ui .markdown p {{
color: var(--docs-muted);
line-height: 1.55;
}}
.swagger-ui .scheme-container {{
margin: 0 0 20px;
padding: 16px 18px;
border: 1px solid var(--docs-border);
border-radius: 8px;
background: var(--docs-panel);
box-shadow: 0 10px 32px rgba(17, 20, 25, 0.06);
}}
.swagger-ui .opblock-tag {{
margin: 18px 0 10px;
padding: 14px 18px;
border: 1px solid var(--docs-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: var(--docs-ink);
box-shadow: 0 8px 22px rgba(17, 20, 25, 0.05);
}}
.swagger-ui .opblock {{
overflow: hidden;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(17, 20, 25, 0.05);
}}
.swagger-ui .opblock.opblock-get {{
border-color: rgba(40, 111, 194, 0.42);
background: rgba(40, 111, 194, 0.06);
}}
.swagger-ui .opblock.opblock-post {{
border-color: rgba(47, 157, 105, 0.42);
background: rgba(47, 157, 105, 0.06);
}}
.swagger-ui .opblock.opblock-put,
.swagger-ui .opblock.opblock-patch {{
border-color: rgba(183, 121, 31, 0.44);
background: rgba(183, 121, 31, 0.07);
}}
.swagger-ui .opblock.opblock-delete {{
border-color: rgba(194, 65, 59, 0.4);
background: rgba(194, 65, 59, 0.06);
}}
.swagger-ui .opblock .opblock-summary-method {{
border-radius: 6px;
min-width: 78px;
text-shadow: none;
}}
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock .opblock-summary-path__deprecated {{
color: var(--docs-ink);
font-weight: 800;
}}
.swagger-ui .btn,
.swagger-ui select {{
border-radius: 7px;
}}
.swagger-ui input[type="text"],
.swagger-ui textarea {{
border-radius: 7px;
}}
.swagger-ui .models {{
border: 1px solid var(--docs-border);
border-radius: 8px;
background: var(--docs-panel);
}}
@media (max-width: 720px) {{
.docs-hero-inner,
.swagger-ui {{
width: min(100% - 24px, 1280px);
}}
.docs-hero-inner {{
padding: 24px 0 22px;
}}
.docs-hero h1 {{
font-size: 2.05rem;
}}
.docs-actions {{
display: grid;
grid-template-columns: 1fr;
}}
.docs-action {{
justify-content: center;
width: 100%;
}}
.swagger-ui {{
margin-top: 16px;
}}
.swagger-ui .info,
.swagger-ui .scheme-container {{
padding: 16px;
}}
}}
</style>
</head>
<body>
<header class="docs-hero">
<div class="docs-hero-inner">
<p class="docs-kicker">RemoteTerm API</p>
<h1>{title}</h1>
<p class="docs-lede">
Explore radio control, messaging, packet inspection, push notifications,
and fanout integration endpoints from one interactive console.
</p>
<div class="docs-actions" aria-label="API shortcuts">
<a class="docs-action" href="api/health"><strong>GET</strong>&nbsp;/api/health</a>
<a class="docs-action" href="openapi.json">OpenAPI JSON</a>
<span class="docs-action">WebSocket /api/ws</span>
</div>
<div class="docs-meta" aria-label="API metadata">
<span class="docs-pill">Version <span>{version}</span></span>
<span class="docs-pill">REST base <span>/api</span></span>
<span class="docs-pill">Auth <span>optional Basic</span></span>
</div>
</div>
</header>
<main id="swagger-ui" aria-label="Swagger UI"></main>
<script src="{SWAGGER_UI_BUNDLE_URL}" crossorigin></script>
<script src="{SWAGGER_UI_PRESET_URL}" crossorigin></script>
<script>
window.ui = SwaggerUIBundle({{
url: {openapi_url},
dom_id: "#swagger-ui",
deepLinking: true,
displayRequestDuration: true,
docExpansion: "none",
filter: true,
persistAuthorization: true,
showExtensions: true,
showCommonExtensions: true,
tryItOutEnabled: false,
withCredentials: true,
defaultModelsExpandDepth: 1,
defaultModelExpandDepth: 1,
syntaxHighlight: {{
activate: true,
theme: "nord"
}},
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "BaseLayout"
}});
</script>
</body>
</html>
"""
def register_api_docs_routes(app: FastAPI) -> None:
"""Register the custom Swagger UI route."""
install_custom_openapi(app)
@app.get("/docs", include_in_schema=False)
async def swagger_ui_html() -> HTMLResponse:
return HTMLResponse(_build_swagger_docs_html(app))
+1
View File
@@ -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
View File
@@ -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
+18
View File
@@ -237,6 +237,24 @@ async def on_new_contact(event: "Event") -> None:
logger.debug("New contact: %s", public_key[:12]) logger.debug("New contact: %s", public_key[:12])
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False) contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
# Block new contacts whose type is in discovery_blocked_types, matching
# the same guard in _process_advertisement. Existing contacts (already
# in the DB) are always updated.
existing = await ContactRepository.get_by_key(public_key.lower())
contact_type = contact_upsert.type or 0
if existing is None and contact_type > 0:
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if contact_type in settings.discovery_blocked_types:
logger.debug(
"Skipping new contact %s: type %d is in discovery_blocked_types",
public_key[:12],
contact_type,
)
return
# Intentionally do not set first_seen or last_seen here: NEW_CONTACT # Intentionally do not set first_seen or last_seen here: NEW_CONTACT
# fires from the radio's stored contact DB, not an RF observation. # fires from the radio's stored contact DB, not an RF observation.
# Both first_seen and last_seen are RF-only timestamps — they track # Both first_seen and last_seen are RF-only timestamps — they track
+207 -47
View File
@@ -11,6 +11,37 @@ from app.path_utils import split_path_hex
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MAX_SEND_ATTEMPTS = 3
_RETRY_DELAY_S = 2
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}"
# Plain-text variants (no markdown formatting)
DEFAULT_BODY_FORMAT_DM_PLAIN = "DM: {sender_name}: {text} via: [{hops}]"
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN = "{channel_name}: {sender_name}: {text} via: [{hops}]"
_DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN = "DM: {sender_name}: {text}"
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN = "{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,46 +67,111 @@ 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 | None = None,
body_format_channel: str | None = None,
markdown: bool = True,
) -> str:
"""Build a notification body from message data using format strings."""
if body_format_dm is None:
body_format_dm = DEFAULT_BODY_FORMAT_DM if markdown else DEFAULT_BODY_FORMAT_DM_PLAIN
if body_format_channel is None:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL if markdown else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
)
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") if markdown:
if paths and isinstance(paths, list) and len(paths) > 0: default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
first_path = paths[0] if isinstance(paths[0], dict) else {}
path_str = first_path.get("path", "")
path_len = first_path.get("path_len")
else: else:
path_str = None default = (
path_len = None DEFAULT_BODY_FORMAT_DM_PLAIN
if msg_type == "PRIV"
if msg_type == "PRIV" and path_str is None: else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
via = " **via:** [`direct`]" )
elif path_str is not None: return _apply_format(default, variables)
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, markdown: bool = True) -> bool:
"""Send notification synchronously via Apprise. Returns True on success.""" """Send notification synchronously via Apprise. Returns True on success."""
import apprise as apprise_lib import apprise as apprise_lib
from apprise import NotifyFormat
urls = _parse_urls(urls_raw) urls = _parse_urls(urls_raw)
if not urls: if not urls:
@@ -87,7 +183,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
url = _normalize_discord_url(url) url = _normalize_discord_url(url)
notifier.add(url) notifier.add(url)
return bool(notifier.notify(title="", body=body)) body_fmt = NotifyFormat.MARKDOWN if markdown else NotifyFormat.TEXT
return bool(notifier.notify(title="", body=body, body_format=body_fmt))
class AppriseModule(FanoutModule): class AppriseModule(FanoutModule):
@@ -106,19 +203,82 @@ 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) markdown = self.config.get("markdown_format", True)
body = _format_body(data, include_path=include_path)
try: # Read format strings; treat empty/whitespace as unset (use default).
success = await asyncio.to_thread( # Fall back to legacy include_path for pre-migration configs.
_send_sync, urls, body, preserve_identity=preserve_identity 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
self._set_last_error(None if success else "Apprise notify returned failure") if body_format_dm is None or body_format_channel is None:
if not success: include_path = self.config.get("include_path", True)
logger.warning("Apprise notification failed for module %s", self.config_id) if body_format_dm is None:
except Exception as exc: if markdown:
self._set_last_error(str(exc)) body_format_dm = (
logger.exception("Apprise send error for module %s", self.config_id) DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
)
else:
body_format_dm = (
DEFAULT_BODY_FORMAT_DM_PLAIN
if include_path
else _DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN
)
if body_format_channel is None:
if markdown:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
)
else:
body_format_channel = (
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
if include_path
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN
)
body = _format_body(
data,
body_format_dm=body_format_dm,
body_format_channel=body_format_channel,
markdown=markdown,
)
last_exc: Exception | None = None
for attempt in range(_MAX_SEND_ATTEMPTS):
try:
success = await asyncio.to_thread(
_send_sync,
urls,
body,
preserve_identity=preserve_identity,
markdown=markdown,
)
if success:
self._set_last_error(None)
return
logger.warning(
"Apprise notification failed for module %s (attempt %d/%d)",
self.config_id,
attempt + 1,
_MAX_SEND_ATTEMPTS,
)
except Exception as exc:
last_exc = exc
logger.warning(
"Apprise send error for module %s (attempt %d/%d): %s",
self.config_id,
attempt + 1,
_MAX_SEND_ATTEMPTS,
exc,
)
if attempt < _MAX_SEND_ATTEMPTS - 1:
await asyncio.sleep(_RETRY_DELAY_S)
# All attempts exhausted
if last_exc is not None:
self._set_last_error(str(last_exc))
else:
self._set_last_error("Apprise notify returned failure")
@property @property
def status(self) -> str: def status(self) -> str:
+32 -8
View File
@@ -32,9 +32,11 @@ _DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default _DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
_CLIENT_ID = "RemoteTerm" _CLIENT_ID = "RemoteTerm"
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires # JWT lifetime kept under 1 hour for compatibility with services that reject
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp) # tokens with exp > 3600s from iat (e.g. Waev.app). Proactive renewal
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours # reconnects 5 minutes before expiry.
_TOKEN_LIFETIME = 3300 # 55 minutes
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 300 # 50 minutes
# Periodic status republish interval (matches meshcore-packet-capture reference) # Periodic status republish interval (matches meshcore-packet-capture reference)
_STATS_REFRESH_INTERVAL = 300 # 5 minutes _STATS_REFRESH_INTERVAL = 300 # 5 minutes
@@ -59,6 +61,7 @@ class CommunityMqttSettings(Protocol):
community_mqtt_iata: str community_mqtt_iata: str
community_mqtt_email: str community_mqtt_email: str
community_mqtt_token_audience: str community_mqtt_token_audience: str
community_mqtt_websocket_path: str
def _base64url_encode(data: bytes) -> str: def _base64url_encode(data: bytes) -> str:
@@ -164,13 +167,20 @@ def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], i
return route, packet_type, payload_len, path_values, payload_type return route, packet_type, payload_len, path_values, payload_type
def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict: def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: str) -> dict | None:
"""Convert a RawPacketBroadcast dict to meshcore-packet-capture format.""" """Convert a RawPacketBroadcast dict to meshcore-packet-capture format.
Returns ``None`` when the packet cannot be decoded — callers should skip
publishing rather than forwarding malformed data.
"""
raw_hex = data.get("data", "") raw_hex = data.get("data", "")
raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b"" raw_bytes = bytes.fromhex(raw_hex) if raw_hex else b""
route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes) route, packet_type, payload_len, path_values, _payload_type = _decode_packet_fields(raw_bytes)
if route == "U":
return None
# Reference format uses local "now" timestamp and derived time/date fields. # Reference format uses local "now" timestamp and derived time/date fields.
current_time = datetime.now() current_time = datetime.now()
ts_str = current_time.isoformat() ts_str = current_time.isoformat()
@@ -245,7 +255,7 @@ def _get_client_version() -> str:
class CommunityMqttPublisher(BaseMqttPublisher): class CommunityMqttPublisher(BaseMqttPublisher):
"""Manages the community MQTT connection and publishes raw packets.""" """Manages the community MQTT connection and publishes raw packets."""
_backoff_max = 60 _backoff_max = 3600
_log_prefix = "Community MQTT" _log_prefix = "Community MQTT"
_not_configured_timeout: float | None = 30 _not_configured_timeout: float | None = 30
@@ -361,7 +371,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
kwargs["username"] = s.community_mqtt_username or None kwargs["username"] = s.community_mqtt_username or None
kwargs["password"] = s.community_mqtt_password or None kwargs["password"] = s.community_mqtt_password or None
if transport == "websockets": if transport == "websockets":
kwargs["websocket_path"] = "/" kwargs["websocket_path"] = (s.community_mqtt_websocket_path or "").strip() or "/"
return kwargs return kwargs
def _on_connected(self, settings: object) -> tuple[str, str]: def _on_connected(self, settings: object) -> tuple[str, str]:
@@ -477,7 +487,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)
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateMqttSettings(Protocol):
class MqttPublisher(BaseMqttPublisher): class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events.""" """Manages an MQTT connection and publishes mesh network events."""
_backoff_max = 30 _backoff_max = 3600
_log_prefix = "MQTT" _log_prefix = "MQTT"
def _is_configured(self) -> bool: def _is_configured(self) -> bool:
+8 -3
View File
@@ -65,6 +65,7 @@ class BaseMqttPublisher(ABC):
self.connected: bool = False self.connected: bool = False
self.integration_name: str = "" self.integration_name: str = ""
self._last_error: str | None = None self._last_error: str | None = None
self._error_notified: bool = False
def set_integration_name(self, name: str) -> None: def set_integration_name(self, name: str) -> None:
"""Attach the configured fanout-module name for operator-facing logs.""" """Attach the configured fanout-module name for operator-facing logs."""
@@ -104,6 +105,7 @@ class BaseMqttPublisher(ABC):
self._client = None self._client = None
self.connected = False self.connected = False
self._last_error = None self._last_error = None
self._error_notified = False
async def restart(self, settings: object) -> None: async def restart(self, settings: object) -> None:
"""Called when settings change — stop + start.""" """Called when settings change — stop + start."""
@@ -217,6 +219,7 @@ class BaseMqttPublisher(ABC):
self._client = client self._client = client
self.connected = True self.connected = True
self._last_error = None self._last_error = None
self._error_notified = False
backoff = _BACKOFF_MIN backoff = _BACKOFF_MIN
title, detail = self._on_connected(settings) title, detail = self._on_connected(settings)
@@ -281,9 +284,11 @@ class BaseMqttPublisher(ABC):
) )
return return
title, detail = self._on_error() if not self._error_notified:
broadcast_error(title, detail) title, detail = self._on_error()
_broadcast_health() broadcast_error(title, detail)
_broadcast_health()
self._error_notified = True
logger.warning( logger.warning(
"%s connection error. This is usually transient network noise; " "%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s " "if it self-resolves, it is generally not a concern: %s "
+3
View File
@@ -62,6 +62,7 @@ def _config_to_settings(config: dict) -> SimpleNamespace:
community_mqtt_iata=config.get("iata", ""), community_mqtt_iata=config.get("iata", ""),
community_mqtt_email=config.get("email", ""), community_mqtt_email=config.get("email", ""),
community_mqtt_token_audience=config.get("token_audience", ""), community_mqtt_token_audience=config.get("token_audience", ""),
community_mqtt_websocket_path=config.get("websocket_path", "/"),
) )
@@ -129,6 +130,8 @@ async def _publish_community_packet(
device_name = radio_manager.meshcore.self_info.get("name", "") device_name = radio_manager.meshcore.self_info.get("name", "")
packet = _format_raw_packet(data, device_name, pubkey_hex) packet = _format_raw_packet(data, device_name, pubkey_hex)
if packet is None:
return
iata = config.get("iata", "").upper().strip() iata = config.get("iata", "").upper().strip()
if not _IATA_RE.fullmatch(iata): if not _IATA_RE.fullmatch(iata):
logger.debug("Community MQTT: skipping publish — no valid IATA code configured") logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
+83 -16
View File
@@ -81,6 +81,15 @@ _REPEATER_SENSORS: list[dict[str, Any]] = [
"unit": None, "unit": None,
"precision": 0, "precision": 0,
}, },
{
"field": "recv_errors",
"name": "RX Errors",
"object_id": "recv_errors",
"device_class": None,
"state_class": "total_increasing",
"unit": None,
"precision": 0,
},
{ {
"field": "uptime_seconds", "field": "uptime_seconds",
"name": "Uptime", "name": "Uptime",
@@ -99,11 +108,11 @@ _LPP_HA_META: dict[str, dict[str, Any]] = {
"humidity": {"device_class": "humidity", "unit": "%", "precision": 1}, "humidity": {"device_class": "humidity", "unit": "%", "precision": 1},
"barometer": {"device_class": "atmospheric_pressure", "unit": "hPa", "precision": 1}, "barometer": {"device_class": "atmospheric_pressure", "unit": "hPa", "precision": 1},
"voltage": {"device_class": "voltage", "unit": "V", "precision": 2}, "voltage": {"device_class": "voltage", "unit": "V", "precision": 2},
"current": {"device_class": "current", "unit": "mA", "precision": 1}, "current": {"device_class": "current", "unit": "A", "precision": 3},
"luminosity": {"device_class": "illuminance", "unit": "lux", "precision": 0}, "luminosity": {"device_class": "illuminance", "unit": "lux", "precision": 0},
"power": {"device_class": "power", "unit": "W", "precision": 1}, "power": {"device_class": "power", "unit": "W", "precision": 1},
"energy": {"device_class": "energy", "unit": "kWh", "precision": 2}, "energy": {"device_class": "energy", "unit": "kWh", "precision": 2},
"distance": {"device_class": "distance", "unit": "mm", "precision": 0}, "distance": {"device_class": "distance", "unit": "m", "precision": 3},
"concentration": {"device_class": None, "unit": "ppm", "precision": 0}, "concentration": {"device_class": None, "unit": "ppm", "precision": 0},
"direction": {"device_class": None, "unit": "°", "precision": 0}, "direction": {"device_class": None, "unit": "°", "precision": 0},
"altitude": {"device_class": None, "unit": "m", "precision": 1}, "altitude": {"device_class": None, "unit": "m", "precision": 1},
@@ -115,6 +124,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,13 +148,24 @@ 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
def _contact_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Build the flat HA state payload for a contact LPP telemetry snapshot.
Unlike repeaters, contacts only have LPP sensor data — no battery_volts,
noise_floor_dbm, packets_received, etc.
"""
payload: dict[str, Any] = {}
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
payload[key] = sensor.get("value")
return payload
def _lpp_discovery_configs( def _lpp_discovery_configs(
prefix: str, prefix: str,
pub_key: str, pub_key: str,
@@ -139,16 +175,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,
@@ -289,7 +328,7 @@ def _device_payload(
class _HaMqttPublisher(BaseMqttPublisher): class _HaMqttPublisher(BaseMqttPublisher):
"""Thin MQTT lifecycle wrapper for the HA discovery module.""" """Thin MQTT lifecycle wrapper for the HA discovery module."""
_backoff_max = 30 _backoff_max = 3600
_log_prefix = "HA-MQTT" _log_prefix = "HA-MQTT"
def __init__(self) -> None: def __init__(self) -> None:
@@ -549,12 +588,30 @@ class MqttHaModule(FanoutModule):
) )
) )
# Tracked contacts — resolve names from DB best-effort # Tracked contacts — resolve names and LPP sensors from DB best-effort
for pub_key in self._tracked_contacts: for pub_key in self._tracked_contacts:
cname = await self._resolve_contact_name(pub_key) cname = await self._resolve_contact_name(pub_key)
configs.append( configs.append(
_contact_tracker_discovery_config(self._prefix, pub_key, cname, self._radio_key) _contact_tracker_discovery_config(self._prefix, pub_key, cname, self._radio_key)
) )
# LPP sensor entities for contacts with telemetry history
latest_ct = await self._resolve_latest_contact_telemetry(pub_key)
latest_ct_data = latest_ct.get("data", {}) if latest_ct else {}
ct_lpp_sensors = latest_ct_data.get("lpp_sensors", [])
if ct_lpp_sensors:
ct_nid = _node_id(pub_key)
ct_device = _device_payload(pub_key, cname, "Node", via_device_key=self._radio_key)
ct_state_topic = f"{self._prefix}/{ct_nid}/telemetry"
configs.extend(
_lpp_discovery_configs(
self._prefix, pub_key, ct_device, ct_lpp_sensors, ct_state_topic
)
)
if latest_ct_data:
ct_payload = _contact_telemetry_payload(latest_ct_data)
cached_repeater_states.append(
(f"{self._prefix}/{_node_id(pub_key)}/telemetry", ct_payload)
)
# Message event entity (namespaced to this radio) # Message event entity (namespaced to this radio)
configs.append(_message_event_discovery_config(self._prefix, self._radio_key, radio_name)) configs.append(_message_event_discovery_config(self._prefix, self._radio_key, radio_name))
@@ -617,6 +674,17 @@ class MqttHaModule(FanoutModule):
pass pass
return None return None
@staticmethod
async def _resolve_latest_contact_telemetry(pub_key: str) -> dict | None:
"""Return the most recent contact telemetry row, or None."""
try:
from app.repository.contact_telemetry import ContactTelemetryRepository
return await ContactTelemetryRepository.get_latest(pub_key)
except Exception:
pass
return None
def _seed_radio_identity_from_runtime(self) -> None: def _seed_radio_identity_from_runtime(self) -> None:
"""Best-effort bootstrap from the currently connected radio session.""" """Best-effort bootstrap from the currently connected radio session."""
try: try:
@@ -722,18 +790,17 @@ class MqttHaModule(FanoutModule):
return return
pub_key = data.get("public_key", "") pub_key = data.get("public_key", "")
if pub_key not in self._tracked_repeaters: if pub_key not in self._tracked_repeaters and pub_key not in self._tracked_contacts:
return return
nid = _node_id(pub_key) nid = _node_id(pub_key)
# Publish the full telemetry dict — HA sensors use value_template is_repeater = pub_key in self._tracked_repeaters
# to extract individual fields payload = (
payload = _repeater_telemetry_payload(data) _repeater_telemetry_payload(data) if is_repeater else _contact_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
+26 -3
View File
@@ -44,6 +44,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api_docs import API_DESCRIPTION, API_TAGS_METADATA, register_api_docs_routes
from app.config import settings as server_settings from app.config import settings as server_settings
from app.config import setup_logging from app.config import setup_logging
from app.database import db from app.database import db
@@ -158,11 +159,14 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="RemoteTerm for MeshCore API", title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks", description=API_DESCRIPTION,
version=get_app_build_info().version, version=get_app_build_info().version,
openapi_tags=API_TAGS_METADATA,
docs_url=None,
lifespan=lifespan, lifespan=lifespan,
) )
register_api_docs_routes(app)
add_optional_basic_auth_middleware(app, server_settings) add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500) app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware( app.add_middleware(
@@ -176,8 +180,27 @@ app.add_middleware(
@app.exception_handler(RadioDisconnectedError) @app.exception_handler(RadioDisconnectedError)
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError): async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
"""Return 503 when a radio disconnect race occurs during an operation.""" """Return 423 when a radio disconnect race occurs during an operation."""
return JSONResponse(status_code=503, content={"detail": "Radio not connected"}) return JSONResponse(status_code=423, 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
@@ -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()
@@ -0,0 +1,20 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Add telemetry_routed_hourly boolean column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "telemetry_routed_hourly" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN telemetry_routed_hourly INTEGER DEFAULT 0"
)
await conn.commit()
@@ -0,0 +1,40 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Create contact_telemetry_history table and tracked_telemetry_contacts setting."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_telemetry_history" not in tables:
await conn.execute(
"""
CREATE TABLE contact_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_contact_telemetry_pk_ts
ON contact_telemetry_history(public_key, timestamp)
"""
)
if "app_settings" in tables:
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "tracked_telemetry_contacts" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_contacts TEXT DEFAULT '[]'"
)
await conn.commit()
+33 -4
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -42,7 +44,7 @@ class ContactUpsert(BaseModel):
first_seen: int | None = None first_seen: int | None = None
@classmethod @classmethod
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert": def from_contact(cls, contact: Contact, **changes) -> ContactUpsert:
return cls.model_validate( return cls.model_validate(
{ {
**contact.model_dump(exclude={"last_read_at"}), **contact.model_dump(exclude={"last_read_at"}),
@@ -53,7 +55,7 @@ class ContactUpsert(BaseModel):
@classmethod @classmethod
def from_radio_dict( def from_radio_dict(
cls, public_key: str, radio_data: dict, on_radio: bool = False cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert": ) -> ContactUpsert:
"""Convert radio contact data to the contact-row write shape.""" """Convert radio contact data to the contact-row write shape."""
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route( direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"), radio_data.get("out_path"),
@@ -221,6 +223,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",
@@ -445,6 +450,8 @@ class RawPacketDecryptedInfo(BaseModel):
sender: str | None = None sender: str | None = None
channel_key: str | None = None channel_key: str | None = None
contact_key: str | None = None contact_key: str | None = None
sender_timestamp: int | None = None
message: str | None = None
class RawPacketBroadcast(BaseModel): class RawPacketBroadcast(BaseModel):
@@ -537,7 +544,8 @@ class RepeaterStatusResponse(BaseModel):
flood_dups: int = Field(description="Duplicate flood packets") flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets") direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count") full_events: int = Field(description="Full event queue count")
telemetry_history: list["TelemetryHistoryEntry"] = Field( recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
telemetry_history: list[TelemetryHistoryEntry] = Field(
default_factory=list, description="Recent telemetry history snapshots" default_factory=list, description="Recent telemetry history snapshots"
) )
@@ -592,6 +600,16 @@ class RepeaterLppTelemetryResponse(BaseModel):
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings") sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
class ContactTelemetryResponse(BaseModel):
"""On-demand CayenneLPP telemetry snapshot from any contact."""
sensors: list[LppSensor] = Field(default_factory=list, description="List of sensor readings")
fetched_at: int = Field(description="Unix timestamp when this telemetry was fetched")
telemetry_history: list[TelemetryHistoryEntry] = Field(
default_factory=list, description="Recent telemetry history entries"
)
class NeighborInfo(BaseModel): class NeighborInfo(BaseModel):
"""Information about a neighbor seen by a repeater.""" """Information about a neighbor seen by a repeater."""
@@ -843,12 +861,23 @@ class AppSettings(BaseModel):
default_factory=list, default_factory=list,
description="Public keys of repeaters opted into periodic telemetry collection (max 8)", description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
) )
tracked_telemetry_contacts: list[str] = Field(
default_factory=list,
description="Public keys of contacts opted into periodic LPP telemetry collection (max 8)",
)
telemetry_interval_hours: int = Field( telemetry_interval_hours: int = Field(
default=8, default=8,
description=( description=(
"User-preferred telemetry collection interval in hours. The backend " "User-preferred telemetry collection interval in hours. The backend "
"clamps this up to the shortest legal interval given the number of " "clamps this up to the shortest legal interval given the number of "
"tracked repeaters so daily checks stay under a 24/day ceiling." "tracked repeaters and contacts so daily checks stay under a 24/day ceiling."
),
)
telemetry_routed_hourly: bool = Field(
default=False,
description=(
"When enabled, tracked repeaters/contacts with a direct or routed (non-flood) "
"path are polled every hour instead of on the normal scheduled interval."
), ),
) )
auto_resend_channel: bool = Field( auto_resend_channel: bool = Field(
+6
View File
@@ -366,6 +366,8 @@ async def process_raw_packet(
sender=result["sender"], sender=result["sender"],
channel_key=result.get("channel_key"), channel_key=result.get("channel_key"),
contact_key=result.get("contact_key"), contact_key=result.get("contact_key"),
sender_timestamp=result.get("sender_timestamp"),
message=result.get("message"),
) )
if result["decrypted"] if result["decrypted"]
else None, else None,
@@ -428,6 +430,8 @@ async def _process_group_text(
"sender": decrypted.sender, "sender": decrypted.sender,
"message_id": msg_id, # None if duplicate, msg_id if new "message_id": msg_id, # None if duplicate, msg_id if new
"channel_key": channel.key, "channel_key": channel.key,
"sender_timestamp": decrypted.timestamp,
"message": decrypted.message,
} }
# Couldn't decrypt with any known key # Couldn't decrypt with any known key
@@ -694,6 +698,8 @@ async def _process_direct_message(
"sender": contact.name or contact.public_key[:12], "sender": contact.name or contact.public_key[:12],
"message_id": msg_id, "message_id": msg_id,
"contact_key": contact.public_key, "contact_key": contact.public_key,
"sender_timestamp": result.timestamp,
"message": result.message,
} }
# Couldn't decrypt with any known contact # Couldn't decrypt with any known contact
+174 -21
View File
@@ -31,6 +31,7 @@ from app.repository import (
ContactRepository, ContactRepository,
RepeaterTelemetryRepository, RepeaterTelemetryRepository,
) )
from app.repository.contact_telemetry import ContactTelemetryRepository
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact, promote_prefix_contacts_for_contact,
reconcile_contact_messages, reconcile_contact_messages,
@@ -1273,7 +1274,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
@@ -1816,6 +1822,7 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
"flood_dups": status.get("flood_dups", 0), "flood_dups": status.get("flood_dups", 0),
"direct_dups": status.get("direct_dups", 0), "direct_dups": status.get("direct_dups", 0),
"full_events": status.get("full_evts", 0), "full_events": status.get("full_evts", 0),
"recv_errors": status.get("recv_errors"),
} }
# Best-effort LPP sensor fetch — failure here does not fail the overall # Best-effort LPP sensor fetch — failure here does not fail the overall
@@ -1884,21 +1891,111 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
return False return False
async def _run_telemetry_cycle() -> None: async def _collect_contact_telemetry(mc: MeshCore, contact: Contact) -> bool:
"""Collect one telemetry sample from every tracked repeater.""" """Fetch LPP telemetry from a non-repeater contact and record it.
Unlike repeaters, companions/rooms/sensors only respond to
req_telemetry_sync (LPP), not req_status_sync (repeater status struct).
All sensor values including multi-value (GPS, accel) are stored.
Returns True on success, False on failure (logged, not raised).
"""
try:
await mc.commands.add_contact(contact.to_radio_dict())
lpp_raw = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
except Exception as e:
logger.debug(
"Contact telemetry collect: radio command failed for %s: %s",
contact.public_key[:12],
e,
)
return False
if lpp_raw is None:
logger.debug("Contact telemetry collect: no response from %s", contact.public_key[:12])
return False
lpp_sensors = []
for entry in lpp_raw:
lpp_sensors.append(
{
"channel": entry.get("channel", 0),
"type_name": str(entry.get("type", "unknown")),
"value": entry.get("value", 0),
}
)
data: dict = {}
if lpp_sensors:
data["lpp_sensors"] = lpp_sensors
try:
timestamp = int(time.time())
await ContactTelemetryRepository.record(
public_key=contact.public_key,
timestamp=timestamp,
data=data,
)
logger.info(
"Contact telemetry collect: recorded snapshot for %s (%s)",
contact.name or contact.public_key[:12],
contact.public_key[:12],
)
# Dispatch to fanout modules
from app.fanout.manager import fanout_manager
asyncio.create_task(
fanout_manager.broadcast_telemetry(
{
"public_key": contact.public_key,
"name": contact.name or contact.public_key[:12],
"timestamp": timestamp,
**data,
}
)
)
return True
except Exception as e:
logger.warning(
"Contact telemetry collect: failed to record for %s: %s",
contact.public_key[:12],
e,
)
return False
async def _run_telemetry_cycle(
*,
routed_only: bool = False,
collect_repeaters: bool = True,
collect_contacts: bool = True,
) -> None:
"""Collect one telemetry sample from tracked repeaters and/or contacts.
When *routed_only* is True, only targets whose effective route is
``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected.
This is used by the hourly routed-path fast-poll feature.
*collect_repeaters* and *collect_contacts* allow the scheduler to
selectively skip one list when its interval hasn't elapsed yet.
"""
if not radio_manager.is_connected: if not radio_manager.is_connected:
logger.debug("Telemetry collect: radio not connected, skipping cycle") logger.debug("Telemetry collect: radio not connected, skipping cycle")
return return
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
tracked = app_settings.tracked_telemetry_repeaters tracked_repeaters = app_settings.tracked_telemetry_repeaters if collect_repeaters else []
if not tracked: tracked_contacts = app_settings.tracked_telemetry_contacts if collect_contacts else []
if not tracked_repeaters and not tracked_contacts:
return return
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked)) # Build repeater candidates
collected = 0 candidates: list[tuple[str, Contact, bool]] = [] # (key, contact, is_repeater)
for pub_key in tracked_repeaters:
for pub_key in tracked:
contact = await ContactRepository.get_by_key(pub_key) contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2: if not contact or contact.type != 2:
logger.debug( logger.debug(
@@ -1906,25 +2003,60 @@ async def _run_telemetry_cycle() -> None:
pub_key[:12], pub_key[:12],
) )
continue continue
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
continue
candidates.append((pub_key, contact, True))
# Build contact (non-repeater) candidates
for pub_key in tracked_contacts:
contact = await ContactRepository.get_by_key(pub_key)
if not contact:
logger.debug(
"Telemetry collect: skipping contact %s (not found)",
pub_key[:12],
)
continue
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
continue
candidates.append((pub_key, contact, False))
if not candidates:
if routed_only:
logger.debug("Telemetry collect: no routed targets to poll this hour")
return
label = "routed" if routed_only else "full"
logger.info(
"Telemetry collect: starting %s cycle for %d target(s)",
label,
len(candidates),
)
collected = 0
for _pub_key, contact, is_repeater in candidates:
try: try:
async with radio_manager.radio_operation( async with radio_manager.radio_operation(
"telemetry_collect", "telemetry_collect",
blocking=False, blocking=False,
suspend_auto_fetch=True, suspend_auto_fetch=True,
) as mc: ) as mc:
if await _collect_repeater_telemetry(mc, contact): if is_repeater:
success = await _collect_repeater_telemetry(mc, contact)
else:
success = await _collect_contact_telemetry(mc, contact)
if success:
collected += 1 collected += 1
except RadioOperationBusyError: except RadioOperationBusyError:
logger.debug( logger.debug(
"Telemetry collect: radio busy, skipping %s", "Telemetry collect: radio busy, skipping %s",
pub_key[:12], contact.public_key[:12],
) )
logger.info( logger.info(
"Telemetry collect: cycle complete, %d/%d successful", "Telemetry collect: %s cycle complete, %d/%d successful",
label,
collected, collected,
len(tracked), len(candidates),
) )
@@ -1948,15 +2080,36 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
telemetry). telemetry).
""" """
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
tracked_count = len(app_settings.tracked_telemetry_repeaters) n_repeaters = len(app_settings.tracked_telemetry_repeaters)
if tracked_count == 0: n_contacts = len(app_settings.tracked_telemetry_contacts)
if n_repeaters == 0 and n_contacts == 0:
return return
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count)
if effective_hours <= 0: pref = app_settings.telemetry_interval_hours
return routed_hourly = app_settings.telemetry_routed_hourly
if now.hour % effective_hours != 0:
return # Each list has its own 24/day ceiling. Check eligibility independently
await _run_telemetry_cycle() # so 8 repeaters on an 8h interval don't drag 1 contact to 8h too.
repeaters_due = False
contacts_due = False
if n_repeaters > 0:
eff_rep = clamp_telemetry_interval(pref, n_repeaters)
if now.hour % eff_rep == 0:
repeaters_due = True
if n_contacts > 0:
eff_ct = clamp_telemetry_interval(pref, n_contacts)
if now.hour % eff_ct == 0:
contacts_due = True
if repeaters_due or contacts_due:
await _run_telemetry_cycle(
collect_repeaters=repeaters_due,
collect_contacts=contacts_due,
)
elif routed_hourly:
await _run_telemetry_cycle(routed_only=True)
async def _telemetry_collect_loop() -> None: async def _telemetry_collect_loop() -> None:
+100
View File
@@ -0,0 +1,100 @@
import json
import logging
import time
from app.database import db
logger = logging.getLogger(__name__)
# Maximum age for telemetry history entries (30 days)
_MAX_AGE_SECONDS = 30 * 86400
# Maximum entries to keep per contact (sanity cap)
_MAX_ENTRIES_PER_CONTACT = 1000
class ContactTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
data: dict,
) -> None:
"""Insert a telemetry history row and prune stale entries."""
cutoff = int(time.time()) - _MAX_AGE_SECONDS
async with db.tx() as conn:
async with conn.execute(
"""
INSERT INTO contact_telemetry_history
(public_key, timestamp, data)
VALUES (?, ?, ?)
""",
(public_key, timestamp, json.dumps(data)),
):
pass
# Prune entries older than 30 days
async with conn.execute(
"DELETE FROM contact_telemetry_history WHERE public_key = ? AND timestamp < ?",
(public_key, cutoff),
):
pass
# Cap at _MAX_ENTRIES_PER_CONTACT (keep newest)
async with conn.execute(
"""
DELETE FROM contact_telemetry_history
WHERE public_key = ? AND id NOT IN (
SELECT id FROM contact_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT ?
)
""",
(public_key, public_key, _MAX_ENTRIES_PER_CONTACT),
):
pass
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a contact since a given timestamp, ordered ASC."""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT timestamp, data
FROM contact_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
) as cursor:
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
for row in rows
]
@staticmethod
async def get_latest(public_key: str) -> dict | None:
"""Return the most recent telemetry row for a contact, or None."""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT timestamp, data
FROM contact_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT 1
""",
(public_key,),
) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return {
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
+34 -2
View File
@@ -41,8 +41,9 @@ class AppSettingsRepository:
last_message_times, last_message_times,
advert_interval, last_advert_time, flood_scope, advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types, blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters, auto_resend_channel, tracked_telemetry_repeaters, tracked_telemetry_contacts,
telemetry_interval_hours auto_resend_channel,
telemetry_interval_hours, telemetry_routed_hourly
FROM app_settings WHERE id = 1 FROM app_settings WHERE id = 1
""" """
) as cursor: ) as cursor:
@@ -97,6 +98,15 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError, KeyError): except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = [] tracked_telemetry_repeaters = []
# Parse tracked_telemetry_contacts JSON
tracked_telemetry_contacts: list[str] = []
try:
raw_tracked_contacts = row["tracked_telemetry_contacts"]
if raw_tracked_contacts:
tracked_telemetry_contacts = json.loads(raw_tracked_contacts)
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_contacts = []
# Parse auto_resend_channel boolean # Parse auto_resend_channel boolean
try: try:
auto_resend_channel = bool(row["auto_resend_channel"]) auto_resend_channel = bool(row["auto_resend_channel"])
@@ -113,6 +123,12 @@ class AppSettingsRepository:
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError):
telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS
# Parse telemetry_routed_hourly boolean
try:
telemetry_routed_hourly = bool(row["telemetry_routed_hourly"])
except (KeyError, TypeError):
telemetry_routed_hourly = False
return AppSettings( return AppSettings(
max_radio_contacts=row["max_radio_contacts"], max_radio_contacts=row["max_radio_contacts"],
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]), auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
@@ -124,8 +140,10 @@ class AppSettingsRepository:
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types, discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters, tracked_telemetry_repeaters=tracked_telemetry_repeaters,
tracked_telemetry_contacts=tracked_telemetry_contacts,
auto_resend_channel=auto_resend_channel, auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours, telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly,
) )
@staticmethod @staticmethod
@@ -142,8 +160,10 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None, discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None, tracked_telemetry_repeaters: list[str] | None = None,
tracked_telemetry_contacts: list[str] | None = None,
auto_resend_channel: bool | None = None, auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None, telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None,
) -> None: ) -> None:
"""Apply field updates using an already-acquired connection. """Apply field updates using an already-acquired connection.
@@ -193,6 +213,10 @@ class AppSettingsRepository:
updates.append("tracked_telemetry_repeaters = ?") updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(tracked_telemetry_repeaters)) params.append(json.dumps(tracked_telemetry_repeaters))
if tracked_telemetry_contacts is not None:
updates.append("tracked_telemetry_contacts = ?")
params.append(json.dumps(tracked_telemetry_contacts))
if auto_resend_channel is not None: if auto_resend_channel is not None:
updates.append("auto_resend_channel = ?") updates.append("auto_resend_channel = ?")
params.append(1 if auto_resend_channel else 0) params.append(1 if auto_resend_channel else 0)
@@ -201,6 +225,10 @@ class AppSettingsRepository:
updates.append("telemetry_interval_hours = ?") updates.append("telemetry_interval_hours = ?")
params.append(telemetry_interval_hours) params.append(telemetry_interval_hours)
if telemetry_routed_hourly is not None:
updates.append("telemetry_routed_hourly = ?")
params.append(1 if telemetry_routed_hourly else 0)
if updates: if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
async with conn.execute(query, params): async with conn.execute(query, params):
@@ -227,8 +255,10 @@ class AppSettingsRepository:
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None, discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None, tracked_telemetry_repeaters: list[str] | None = None,
tracked_telemetry_contacts: list[str] | None = None,
auto_resend_channel: bool | None = None, auto_resend_channel: bool | None = None,
telemetry_interval_hours: int | None = None, telemetry_interval_hours: int | None = None,
telemetry_routed_hourly: bool | None = None,
) -> AppSettings: ) -> AppSettings:
"""Update app settings. Only provided fields are updated.""" """Update app settings. Only provided fields are updated."""
async with db.tx() as conn: async with db.tx() as conn:
@@ -244,8 +274,10 @@ class AppSettingsRepository:
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types, discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters, tracked_telemetry_repeaters=tracked_telemetry_repeaters,
tracked_telemetry_contacts=tracked_telemetry_contacts,
auto_resend_channel=auto_resend_channel, auto_resend_channel=auto_resend_channel,
telemetry_interval_hours=telemetry_interval_hours, telemetry_interval_hours=telemetry_interval_hours,
telemetry_routed_hourly=telemetry_routed_hourly,
) )
return await AppSettingsRepository._get_in_conn(conn) return await AppSettingsRepository._get_in_conn(conn)
+92 -6
View File
@@ -14,11 +14,14 @@ from app.models import (
ContactAdvertPathSummary, ContactAdvertPathSummary,
ContactAnalytics, ContactAnalytics,
ContactRoutingOverrideRequest, ContactRoutingOverrideRequest,
ContactTelemetryResponse,
ContactUpsert, ContactUpsert,
CreateContactRequest, CreateContactRequest,
LppSensor,
NearestRepeater, NearestRepeater,
PathDiscoveryResponse, PathDiscoveryResponse,
PathDiscoveryRoute, PathDiscoveryRoute,
TelemetryHistoryEntry,
TraceResponse, TraceResponse,
) )
from app.packet_processor import start_historical_dm_decryption from app.packet_processor import start_historical_dm_decryption
@@ -66,11 +69,11 @@ async def _resolve_contact_or_404(
async def _ensure_on_radio(mc, contact: Contact) -> None: async def _ensure_on_radio(mc, contact: Contact) -> None:
"""Add a contact to the radio for routing, raising 500 on failure.""" """Add a contact to the radio for routing, raising 422 on failure."""
add_result = await mc.commands.add_contact(contact.to_radio_dict()) add_result = await mc.commands.add_contact(contact.to_radio_dict())
if add_result is not None and add_result.type == EventType.ERROR: if add_result is not None and add_result.type == EventType.ERROR:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}" status_code=422, detail=f"Failed to add contact to radio: {add_result.payload}"
) )
@@ -315,6 +318,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)
@@ -451,7 +455,7 @@ async def request_trace(public_key: str) -> TraceResponse:
) )
if result.type == EventType.ERROR: if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}") raise HTTPException(status_code=422, detail=f"Failed to send trace: {result.payload}")
# Wait for the matching TRACE_DATA event # Wait for the matching TRACE_DATA event
event = await mc.wait_for_event( event = await mc.wait_for_event(
@@ -461,7 +465,7 @@ async def request_trace(public_key: str) -> TraceResponse:
) )
if event is None: if event is None:
raise HTTPException(status_code=504, detail="No trace response heard") raise HTTPException(status_code=408, detail="No trace response heard")
trace = event.payload trace = event.payload
path = trace.get("path", []) path = trace.get("path", [])
@@ -505,7 +509,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
result = await mc.commands.send_path_discovery(contact.public_key) result = await mc.commands.send_path_discovery(contact.public_key)
if result.type == EventType.ERROR: if result.type == EventType.ERROR:
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail=f"Failed to send path discovery: {result.payload}", detail=f"Failed to send path discovery: {result.payload}",
) )
@@ -517,7 +521,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
await response_task await response_task
if event is None: if event is None:
raise HTTPException(status_code=504, detail="No path discovery response heard") raise HTTPException(status_code=408, detail="No path discovery response heard")
payload = event.payload payload = event.payload
forward_path = str(payload.get("out_path") or "") forward_path = str(payload.get("out_path") or "")
@@ -612,3 +616,85 @@ async def set_contact_routing_override(
await _broadcast_contact_update(updated_contact) await _broadcast_contact_update(updated_contact)
return {"status": "ok", "public_key": contact.public_key} return {"status": "ok", "public_key": contact.public_key}
# ---------------------------------------------------------------------------
# On-demand contact telemetry (CayenneLPP)
# ---------------------------------------------------------------------------
@router.post("/{public_key}/telemetry", response_model=ContactTelemetryResponse)
async def request_contact_telemetry(public_key: str) -> ContactTelemetryResponse:
"""Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout).
Persists the result in contact_telemetry_history and returns the latest
sensor readings along with recent telemetry history.
"""
from app.repository.contact_telemetry import ContactTelemetryRepository
radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key)
async with radio_manager.radio_operation(
"contact_telemetry", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
telemetry = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from contact")
sensors: list[LppSensor] = []
for entry in telemetry:
channel = entry.get("channel", 0)
type_name = str(entry.get("type", "unknown"))
value = entry.get("value", 0)
sensors.append(LppSensor(channel=channel, type_name=type_name, value=value))
fetched_at = int(time.time())
# Persist snapshot
data = {"lpp_sensors": [s.model_dump() for s in sensors]}
await ContactTelemetryRepository.record(
public_key=contact.public_key,
timestamp=fetched_at,
data=data,
)
# Dispatch to fanout modules (e.g. HA MQTT)
from app.fanout.manager import fanout_manager
asyncio.create_task(
fanout_manager.broadcast_telemetry(
{
"public_key": contact.public_key,
"name": contact.name or contact.public_key[:12],
"timestamp": fetched_at,
**data,
}
)
)
# Fetch recent history (30 days)
since = fetched_at - 30 * 86400
rows = await ContactTelemetryRepository.get_history(contact.public_key, since)
history = [TelemetryHistoryEntry(**row) for row in rows]
return ContactTelemetryResponse(
sensors=sensors,
fetched_at=fetched_at,
telemetry_history=history,
)
@router.get("/{public_key}/telemetry-history", response_model=list[TelemetryHistoryEntry])
async def get_contact_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Get stored telemetry history for a contact (read-only, no radio access)."""
from app.repository.contact_telemetry import ContactTelemetryRepository
contact = await _resolve_contact_or_404(public_key)
since = int(time.time()) - 30 * 86400
rows = await ContactTelemetryRepository.get_history(contact.public_key, since)
return [TelemetryHistoryEntry(**row) for row in rows]
+34 -5
View File
@@ -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"],
+19
View File
@@ -259,6 +259,25 @@ 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
markdown_format = config.get("markdown_format")
if markdown_format is not None:
config["markdown_format"] = bool(markdown_format)
def _validate_webhook_config(config: dict) -> None: def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob.""" """Validate webhook config blob."""
+4
View File
@@ -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"),
+4
View File
@@ -128,11 +128,15 @@ async def get_raw_packet(packet_id: int) -> RawPacketDetail:
sender=message.sender_name, sender=message.sender_name,
channel_key=message.conversation_key, channel_key=message.conversation_key,
contact_key=message.sender_key, contact_key=message.sender_key,
sender_timestamp=message.sender_timestamp,
message=message.text,
) )
else: else:
decrypted_info = RawPacketDecryptedInfo( decrypted_info = RawPacketDecryptedInfo(
sender=message.sender_name, sender=message.sender_name,
contact_key=message.conversation_key, contact_key=message.conversation_key,
sender_timestamp=message.sender_timestamp,
message=message.text,
) )
return RawPacketDetail( return RawPacketDetail(
+5 -5
View File
@@ -48,7 +48,7 @@ async def vapid_public_key() -> VapidPublicKeyResponse:
"""Return the VAPID public key for browser PushManager.subscribe().""" """Return the VAPID public key for browser PushManager.subscribe()."""
key = get_vapid_public_key() key = get_vapid_public_key()
if not key: if not key:
raise HTTPException(status_code=503, detail="VAPID keys not initialized") raise HTTPException(status_code=423, detail="VAPID keys not initialized")
return VapidPublicKeyResponse(public_key=key) return VapidPublicKeyResponse(public_key=key)
@@ -103,7 +103,7 @@ async def test_push(subscription_id: str) -> dict:
vapid_key = get_vapid_private_key() vapid_key = get_vapid_private_key()
if not vapid_key: if not vapid_key:
raise HTTPException(status_code=503, detail="VAPID keys not initialized") raise HTTPException(status_code=423, detail="VAPID keys not initialized")
payload = json.dumps( payload = json.dumps(
{ {
@@ -127,7 +127,7 @@ async def test_push(subscription_id: str) -> dict:
) )
return {"status": "sent"} return {"status": "sent"}
except TimeoutError: except TimeoutError:
raise HTTPException(status_code=504, detail="Push delivery timed out") from None raise HTTPException(status_code=408, detail="Push delivery timed out") from None
except WebPushException as e: except WebPushException as e:
status_code = getattr(getattr(e, "response", None), "status_code", 0) status_code = getattr(getattr(e, "response", None), "status_code", 0)
if status_code in (403, 404, 410): if status_code in (403, 404, 410):
@@ -143,10 +143,10 @@ async def test_push(subscription_id: str) -> dict:
"Re-enable push from a conversation header.", "Re-enable push from a conversation header.",
) from None ) from None
logger.warning("Test push failed: %s", e) logger.warning("Test push failed: %s", e)
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
except Exception as e: except Exception as e:
logger.warning("Test push failed: %s", e) logger.warning("Test push failed: %s", e)
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
# ── Global push conversation management ────────────────────────────────── # ── Global push conversation management ──────────────────────────────────
+63 -15
View File
@@ -101,6 +101,18 @@ class RadioConfigResponse(BaseModel):
default=False, default=False,
description="Whether the radio sends an extra direct ACK transmission", description="Whether the radio sends an extra direct ACK transmission",
) )
telemetry_mode_base: int = Field(
default=0,
description="Base telemetry sharing mode (0=deny, 1=per-contact, 2=allow-all)",
)
telemetry_mode_loc: int = Field(
default=0,
description="Location telemetry sharing mode (0=deny, 1=per-contact, 2=allow-all)",
)
telemetry_mode_env: int = Field(
default=0,
description="Environment sensor sharing mode (0=deny, 1=per-contact, 2=allow-all)",
)
class RadioConfigUpdate(BaseModel): class RadioConfigUpdate(BaseModel):
@@ -123,6 +135,15 @@ class RadioConfigUpdate(BaseModel):
default=None, default=None,
description="Whether the radio sends an extra direct ACK transmission", description="Whether the radio sends an extra direct ACK transmission",
) )
telemetry_mode_base: int | None = Field(
default=None, ge=0, le=2, description="Base telemetry sharing mode"
)
telemetry_mode_loc: int | None = Field(
default=None, ge=0, le=2, description="Location telemetry sharing mode"
)
telemetry_mode_env: int | None = Field(
default=None, ge=0, le=2, description="Environment sensor sharing mode"
)
class PrivateKeyUpdate(BaseModel): class PrivateKeyUpdate(BaseModel):
@@ -338,7 +359,7 @@ async def get_radio_config() -> RadioConfigResponse:
info = mc.self_info info = mc.self_info
if not info: if not info:
raise HTTPException(status_code=503, detail="Radio info not available") raise HTTPException(status_code=423, detail="Radio info not available")
adv_loc_policy = info.get("adv_loc_policy", 1) adv_loc_policy = info.get("adv_loc_policy", 1)
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current" advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
@@ -360,6 +381,9 @@ async def get_radio_config() -> RadioConfigResponse:
path_hash_mode_supported=radio_manager.path_hash_mode_supported, path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source, advert_location_source=advert_location_source,
multi_acks_enabled=bool(info.get("multi_acks", 0)), multi_acks_enabled=bool(info.get("multi_acks", 0)),
telemetry_mode_base=info.get("telemetry_mode_base", 0),
telemetry_mode_loc=info.get("telemetry_mode_loc", 0),
telemetry_mode_env=info.get("telemetry_mode_env", 0),
) )
@@ -380,11 +404,35 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
except PathHashModeUnsupportedError as exc: except PathHashModeUnsupportedError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except RadioCommandRejectedError as exc: except RadioCommandRejectedError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
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."""
@@ -406,7 +454,7 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
export_and_store_private_key_fn=export_and_store_private_key, export_and_store_private_key_fn=export_and_store_private_key,
) )
except (RadioCommandRejectedError, KeystoreRefreshError) as exc: except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
return {"status": "ok"} return {"status": "ok"}
@@ -430,7 +478,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
success = await do_send_advertisement(mc, force=True, mode=mode) success = await do_send_advertisement(mc, force=True, mode=mode)
if not success: if not success:
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement") raise HTTPException(status_code=422, detail=f"Failed to send {mode} advertisement")
return {"status": "ok"} return {"status": "ok"}
@@ -462,7 +510,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
tag=tag, tag=tag,
) )
if send_result is None or send_result.type == EventType.ERROR: if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to start mesh discovery") raise HTTPException(status_code=422, detail="Failed to start mesh discovery")
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
results_by_key: dict[str, RadioDiscoveryResult] = {} results_by_key: dict[str, RadioDiscoveryResult] = {}
@@ -514,7 +562,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc: async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower() local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
if len(local_public_key) != 64: if len(local_public_key) != 64:
raise HTTPException(status_code=503, detail="Local radio public key is unavailable") raise HTTPException(status_code=423, detail="Local radio public key is unavailable")
local_name = (mc.self_info or {}).get("name") local_name = (mc.self_info or {}).get("name")
response_task = asyncio.create_task( response_task = asyncio.create_task(
@@ -531,13 +579,13 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
flags=trace_flags, flags=trace_flags,
) )
if send_result is None or send_result.type == EventType.ERROR: if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to send trace") raise HTTPException(status_code=422, detail="Failed to send trace")
timeout_seconds = _trace_timeout_seconds(send_result) timeout_seconds = _trace_timeout_seconds(send_result)
try: try:
event = await asyncio.wait_for(response_task, timeout=timeout_seconds) event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
except TimeoutError as exc: except TimeoutError as exc:
raise HTTPException(status_code=504, detail="No trace response heard") from exc raise HTTPException(status_code=408, detail="No trace response heard") from exc
finally: finally:
if not response_task.done(): if not response_task.done():
response_task.cancel() response_task.cancel()
@@ -545,12 +593,12 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
await response_task await response_task
if event is None: if event is None:
raise HTTPException(status_code=504, detail="No trace response heard") raise HTTPException(status_code=408, detail="No trace response heard")
payload = event.payload if isinstance(event.payload, dict) else {} payload = event.payload if isinstance(event.payload, dict) else {}
path_len = payload.get("path_len") path_len = payload.get("path_len")
if not isinstance(path_len, int): if not isinstance(path_len, int):
raise HTTPException(status_code=500, detail="Trace response was malformed") raise HTTPException(status_code=422, detail="Trace response was malformed")
raw_path = payload.get("path") raw_path = payload.get("path")
path_nodes = raw_path if isinstance(raw_path, list) else [] path_nodes = raw_path if isinstance(raw_path, list) else []
@@ -564,7 +612,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
if len(hashed_nodes) < len(trace_nodes): if len(hashed_nodes) < len(trace_nodes):
raise HTTPException(status_code=500, detail="Trace response was incomplete") raise HTTPException(status_code=422, detail="Trace response was incomplete")
nodes: list[RadioTraceNode] = [] nodes: list[RadioTraceNode] = []
for index, trace_node in enumerate(trace_nodes): for index, trace_node in enumerate(trace_nodes):
@@ -617,13 +665,13 @@ async def _attempt_reconnect() -> dict:
except Exception as e: except Exception as e:
logger.exception("Post-connect setup failed after reconnect") logger.exception("Post-connect setup failed after reconnect")
raise HTTPException( raise HTTPException(
status_code=503, status_code=423,
detail=f"Radio connected but setup failed: {e}", detail=f"Radio connected but setup failed: {e}",
) from e ) from e
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=503, detail="Failed to reconnect. Check radio connection and power." status_code=423, detail="Failed to reconnect. Check radio connection and power."
) )
return {"status": "ok", "message": "Reconnected successfully", "connected": True} return {"status": "ok", "message": "Reconnected successfully", "connected": True}
@@ -678,14 +726,14 @@ async def reconnect_radio() -> dict:
logger.info("Radio connected but setup incomplete, retrying setup") logger.info("Radio connected but setup incomplete, retrying setup")
try: try:
if not await _prepare_connected(broadcast_on_success=True): if not await _prepare_connected(broadcast_on_success=True):
raise HTTPException(status_code=503, detail="Radio connection is paused") raise HTTPException(status_code=423, detail="Radio connection is paused")
return {"status": "ok", "message": "Setup completed", "connected": True} return {"status": "ok", "message": "Setup completed", "connected": True}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.exception("Post-connect setup failed") logger.exception("Post-connect setup failed")
raise HTTPException( raise HTTPException(
status_code=503, status_code=423,
detail=f"Radio connected but setup failed: {e}", detail=f"Radio connected but setup failed: {e}",
) from e ) from e
+3 -2
View File
@@ -113,7 +113,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e) logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e)
if status is None: if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater") raise HTTPException(status_code=408, detail="No status response from repeater")
response = RepeaterStatusResponse( response = RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0, battery_volts=status.get("bat", 0) / 1000.0,
@@ -133,6 +133,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
flood_dups=status.get("flood_dups", 0), flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0), direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0), full_events=status.get("full_evts", 0),
recv_errors=status.get("recv_errors"),
) )
# Record to telemetry history as a JSON blob (best-effort) # Record to telemetry history as a JSON blob (best-effort)
@@ -221,7 +222,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
) )
if telemetry is None: if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from repeater") raise HTTPException(status_code=408, detail="No telemetry response from repeater")
sensors: list[LppSensor] = [] sensors: list[LppSensor] = []
for entry in telemetry: for entry in telemetry:
+3 -2
View File
@@ -58,7 +58,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5) status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
if status is None: if status is None:
raise HTTPException(status_code=504, detail="No status response from room server") raise HTTPException(status_code=408, detail="No status response from room server")
return RepeaterStatusResponse( return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0, battery_volts=status.get("bat", 0) / 1000.0,
@@ -78,6 +78,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
flood_dups=status.get("flood_dups", 0), flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0), direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0), full_events=status.get("full_evts", 0),
recv_errors=status.get("recv_errors"),
) )
@@ -97,7 +98,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
) )
if telemetry is None: if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from room server") raise HTTPException(status_code=408, detail="No telemetry response from room server")
sensors = [ sensors = [
LppSensor( LppSensor(
+1 -1
View File
@@ -291,7 +291,7 @@ async def send_contact_cli_command(
if send_result.type == EventType.ERROR: if send_result.type == EventType.ERROR:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}" status_code=422, detail=f"Failed to send command: {send_result.payload}"
) )
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12]) response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
+142 -5
View File
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
MAX_TRACKED_TELEMETRY_REPEATERS = 8 MAX_TRACKED_TELEMETRY_REPEATERS = 8
MAX_TRACKED_TELEMETRY_CONTACTS = 8
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):
@@ -73,6 +74,13 @@ class AppSettingsUpdate(BaseModel):
"based on the current tracked-repeater count." "based on the current tracked-repeater count."
), ),
) )
telemetry_routed_hourly: bool | None = Field(
default=None,
description=(
"When enabled, tracked repeaters with a direct or routed (non-flood) "
"path are polled every hour instead of on the normal scheduled interval."
),
)
class BlockKeyRequest(BaseModel): class BlockKeyRequest(BaseModel):
@@ -126,7 +134,18 @@ class TelemetrySchedule(BaseModel):
max_tracked: int = Field(description="Maximum number of repeaters that can be tracked") max_tracked: int = Field(description="Maximum number of repeaters that can be tracked")
next_run_at: int | None = Field( next_run_at: int | None = Field(
default=None, default=None,
description="Unix timestamp (UTC seconds) of the next scheduled cycle", description="Unix timestamp (UTC seconds) of the next scheduled flood cycle",
)
routed_hourly: bool = Field(
default=False,
description="Whether hourly routed/direct-path telemetry is enabled",
)
next_routed_run_at: int | None = Field(
default=None,
description=(
"Unix timestamp (UTC seconds) of the next hourly routed/direct check, "
"or None when routed_hourly is off or no repeaters are tracked"
),
) )
@@ -140,20 +159,27 @@ class TrackedTelemetryResponse(BaseModel):
schedule: TelemetrySchedule = Field(description="Current scheduling state") schedule: TelemetrySchedule = Field(description="Current scheduling state")
def _build_schedule(tracked_count: int, preferred_hours: int | None) -> TelemetrySchedule: def _build_schedule(
tracked_count: int,
preferred_hours: int | None,
routed_hourly: bool = False,
) -> TelemetrySchedule:
pref = ( pref = (
preferred_hours preferred_hours
if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS
else DEFAULT_TELEMETRY_INTERVAL_HOURS else DEFAULT_TELEMETRY_INTERVAL_HOURS
) )
effective = clamp_telemetry_interval(pref, tracked_count) effective = clamp_telemetry_interval(pref, tracked_count)
has_tracked = tracked_count > 0
return TelemetrySchedule( return TelemetrySchedule(
preferred_hours=pref, preferred_hours=pref,
effective_hours=effective, effective_hours=effective,
options=legal_interval_options(tracked_count), options=legal_interval_options(tracked_count),
tracked_count=tracked_count, tracked_count=tracked_count,
max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS, max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS,
next_run_at=next_run_timestamp_utc(effective) if tracked_count > 0 else None, next_run_at=next_run_timestamp_utc(effective) if has_tracked else None,
routed_hourly=routed_hourly,
next_routed_run_at=(next_run_timestamp_utc(1) if has_tracked and routed_hourly else None),
) )
@@ -216,6 +242,11 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating telemetry_interval_hours to %d", raw_interval) logger.info("Updating telemetry_interval_hours to %d", raw_interval)
kwargs["telemetry_interval_hours"] = raw_interval kwargs["telemetry_interval_hours"] = raw_interval
# Telemetry routed hourly
if update.telemetry_routed_hourly is not None:
logger.info("Updating telemetry_routed_hourly to %s", update.telemetry_routed_hourly)
kwargs["telemetry_routed_hourly"] = update.telemetry_routed_hourly
# Flood scope # Flood scope
flood_scope_changed = False flood_scope_changed = False
if update.flood_scope is not None: if update.flood_scope is not None:
@@ -328,7 +359,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
return TrackedTelemetryResponse( return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list, tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list), names=await _resolve_names(new_list),
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours), schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
) )
# Validate it's a repeater # Validate it's a repeater
@@ -355,7 +390,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
return TrackedTelemetryResponse( return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list, tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list), names=await _resolve_names(new_list),
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours), schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
) )
@@ -366,9 +405,107 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
The UI uses this to render the interval dropdown (legal options), The UI uses this to render the interval dropdown (legal options),
surface saved-vs-effective when they differ, and show the next-run-at surface saved-vs-effective when they differ, and show the next-run-at
timestamp so users know when the next cycle will fire. timestamp so users know when the next cycle will fire.
""" """
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
return _build_schedule( return _build_schedule(
len(app_settings.tracked_telemetry_repeaters), len(app_settings.tracked_telemetry_repeaters),
app_settings.telemetry_interval_hours, app_settings.telemetry_interval_hours,
app_settings.telemetry_routed_hourly,
)
# ---------------------------------------------------------------------------
# Tracked contact telemetry (non-repeater LPP telemetry collection)
# ---------------------------------------------------------------------------
class TrackedTelemetryContactsResponse(BaseModel):
tracked_telemetry_contacts: list[str] = Field(
description="Current list of tracked contact public keys"
)
names: dict[str, str] = Field(
description="Map of public key to display name for tracked contacts"
)
schedule: TelemetrySchedule = Field(description="Current scheduling state")
@router.post("/tracked-telemetry-contacts/toggle", response_model=TrackedTelemetryContactsResponse)
async def toggle_tracked_telemetry_contact(
request: TrackedTelemetryRequest,
) -> TrackedTelemetryContactsResponse:
"""Toggle periodic LPP telemetry collection for any contact.
Max 8 contacts may be tracked. The daily check ceiling is shared with
tracked repeaters.
"""
key = request.public_key.lower()
settings = await AppSettingsRepository.get()
current = settings.tracked_telemetry_contacts
async def _resolve_names(keys: list[str]) -> dict[str, str]:
names: dict[str, str] = {}
for k in keys:
contact = await ContactRepository.get_by_key(k)
names[k] = contact.name if contact and contact.name else k[:12]
return names
if key in current:
# Remove
new_list = [k for k in current if k != key]
logger.info("Removing contact %s from tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
return TrackedTelemetryContactsResponse(
tracked_telemetry_contacts=new_list,
names=await _resolve_names(new_list),
schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
)
# Validate contact exists and is not a repeater (repeaters use tracked_telemetry_repeaters)
contact = await ContactRepository.get_by_key(key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
if contact.type == CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail="Repeaters use the dedicated repeater telemetry tracking list",
)
if len(current) >= MAX_TRACKED_TELEMETRY_CONTACTS:
names = await _resolve_names(current)
raise HTTPException(
status_code=409,
detail={
"message": f"Limit of {MAX_TRACKED_TELEMETRY_CONTACTS} tracked contacts reached",
"tracked_telemetry_contacts": current,
"names": names,
},
)
new_list = current + [key]
logger.info("Adding contact %s to tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_contacts=new_list)
return TrackedTelemetryContactsResponse(
tracked_telemetry_contacts=new_list,
names=await _resolve_names(new_list),
schedule=_build_schedule(
len(new_list),
settings.telemetry_interval_hours,
settings.telemetry_routed_hourly,
),
)
@router.get("/tracked-telemetry-contacts/schedule", response_model=TelemetrySchedule)
async def get_contact_telemetry_schedule() -> TelemetrySchedule:
"""Return the current telemetry scheduling derivation for contacts."""
app_settings = await AppSettingsRepository.get()
return _build_schedule(
len(app_settings.tracked_telemetry_contacts),
app_settings.telemetry_interval_hours,
app_settings.telemetry_routed_hourly,
) )
+23 -17
View File
@@ -159,7 +159,7 @@ async def send_channel_message_with_effective_scope(
override_result.payload, override_result.payload,
) )
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail=( detail=(
f"Failed to apply regional override {override_scope!r} before {action_label}: " f"Failed to apply regional override {override_scope!r} before {action_label}: "
f"{override_result.payload}" f"{override_result.payload}"
@@ -189,7 +189,7 @@ async def send_channel_message_with_effective_scope(
phm_result.payload, phm_result.payload,
) )
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail=( detail=(
f"Failed to apply path hash mode override before {action_label}: " f"Failed to apply path hash mode override before {action_label}: "
f"{phm_result.payload}" f"{phm_result.payload}"
@@ -233,7 +233,7 @@ async def send_channel_message_with_effective_scope(
set_result.payload, set_result.payload,
) )
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail=f"Failed to configure channel on radio before {action_label}", detail=f"Failed to configure channel on radio before {action_label}",
) )
radio_manager.note_channel_slot_loaded(channel_key, channel_slot) radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
@@ -256,8 +256,14 @@ async def send_channel_message_with_effective_scope(
action_label, action_label,
channel.name, channel.name,
) )
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) raise HTTPException(status_code=408, 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)
@@ -592,10 +598,10 @@ async def send_direct_message_to_contact(
"No response from radio after direct send to %s; send outcome is unknown", "No response from radio after direct send to %s; send outcome is unknown",
contact.public_key[:12], contact.public_key[:12],
) )
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR: if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") raise HTTPException(status_code=422, detail=f"Failed to send message: {result.payload}")
message = await create_outgoing_direct_message( message = await create_outgoing_direct_message(
conversation_key=contact.public_key.lower(), conversation_key=contact.public_key.lower(),
@@ -607,7 +613,7 @@ async def send_direct_message_to_contact(
) )
if message is None: if message is None:
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail="Failed to store outgoing message - unexpected duplicate", detail="Failed to store outgoing message - unexpected duplicate",
) )
finally: finally:
@@ -620,7 +626,7 @@ async def send_direct_message_to_contact(
) )
if sent_at is None or sender_timestamp is None or message is None or result is None: if sent_at is None or sender_timestamp is None or message is None or result is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message") raise HTTPException(status_code=422, detail="Failed to store outgoing message")
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at) await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
@@ -785,7 +791,7 @@ async def send_channel_message_to_channel(
) )
if outgoing_message is None: if outgoing_message is None:
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail="Failed to store outgoing message - unexpected duplicate", detail="Failed to store outgoing message - unexpected duplicate",
) )
@@ -807,11 +813,11 @@ async def send_channel_message_to_channel(
"No response from radio after channel send to %s; send outcome is unknown", "No response from radio after channel send to %s; send outcome is unknown",
channel.name, channel.name,
) )
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR: if result.type == EventType.ERROR:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}" status_code=422, detail=f"Failed to send message: {result.payload}"
) )
except Exception: except Exception:
if outgoing_message is not None: if outgoing_message is not None:
@@ -828,7 +834,7 @@ async def send_channel_message_to_channel(
) )
if sent_at is None or sender_timestamp is None or outgoing_message is None: if sent_at is None or sender_timestamp is None or outgoing_message is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message") raise HTTPException(status_code=422, detail="Failed to store outgoing message")
outgoing_message = await build_stored_outgoing_channel_message( outgoing_message = await build_stored_outgoing_channel_message(
message_id=outgoing_message.id, message_id=outgoing_message.id,
@@ -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
@@ -922,7 +928,7 @@ async def resend_channel_message_record(
) )
if new_message is None: if new_message is None:
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail="Failed to store resent message - unexpected duplicate", detail="Failed to store resent message - unexpected duplicate",
) )
@@ -943,10 +949,10 @@ async def resend_channel_message_record(
"No response from radio after channel resend to %s; send outcome is unknown", "No response from radio after channel resend to %s; send outcome is unknown",
channel.name, channel.name,
) )
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR: if result.type == EventType.ERROR:
raise HTTPException( raise HTTPException(
status_code=500, status_code=422,
detail=f"Failed to resend message: {result.payload}", detail=f"Failed to resend message: {result.payload}",
) )
except Exception: except Exception:
@@ -965,7 +971,7 @@ async def resend_channel_message_record(
if new_timestamp: if new_timestamp:
if sent_at is None or new_message is None: if sent_at is None or new_message is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp") raise HTTPException(status_code=422, detail="Failed to assign resend timestamp")
new_message = await build_stored_outgoing_channel_message( new_message = await build_stored_outgoing_channel_message(
message_id=new_message.id, message_id=new_message.id,
+24
View File
@@ -51,6 +51,30 @@ async def apply_radio_config_update(
if result is not None and result.type == EventType.ERROR: if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}") raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
if update.telemetry_mode_base is not None:
logger.info("Setting telemetry_mode_base to %d", update.telemetry_mode_base)
result = await mc.commands.set_telemetry_mode_base(update.telemetry_mode_base)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set telemetry mode (base): {result.payload}"
)
if update.telemetry_mode_loc is not None:
logger.info("Setting telemetry_mode_loc to %d", update.telemetry_mode_loc)
result = await mc.commands.set_telemetry_mode_loc(update.telemetry_mode_loc)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set telemetry mode (location): {result.payload}"
)
if update.telemetry_mode_env is not None:
logger.info("Setting telemetry_mode_env to %d", update.telemetry_mode_env)
result = await mc.commands.set_telemetry_mode_env(update.telemetry_mode_env)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set telemetry mode (environment): {result.payload}"
)
if update.name is not None: if update.name is not None:
logger.info("Setting radio name to %s", update.name) logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name) await mc.commands.set_name(update.name)
+3 -3
View File
@@ -52,12 +52,12 @@ class RadioRuntime:
def require_connected(self): def require_connected(self):
"""Return MeshCore when available, mirroring existing HTTP semantics.""" """Return MeshCore when available, mirroring existing HTTP semantics."""
if self.is_setup_in_progress: if self.is_setup_in_progress:
raise HTTPException(status_code=503, detail="Radio is initializing") raise HTTPException(status_code=423, detail="Radio is initializing")
if not self.is_connected: if not self.is_connected:
raise HTTPException(status_code=503, detail="Radio not connected") raise HTTPException(status_code=423, detail="Radio not connected")
mc = self.meshcore mc = self.meshcore
if mc is None: if mc is None:
raise HTTPException(status_code=503, detail="Radio not connected") raise HTTPException(status_code=423, detail="Radio not connected")
return mc return mc
@asynccontextmanager @asynccontextmanager
+1
View File
@@ -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
+8 -9
View File
@@ -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/
@@ -139,7 +141,8 @@ frontend/src/
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery │ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation │ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD │ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label │ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists
│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats │ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links │ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ ├── ThemeSelector.tsx # Color theme picker │ │ ├── ThemeSelector.tsx # Color theme picker
@@ -178,7 +181,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
@@ -322,7 +324,7 @@ Supported routes:
- `#contact/{publicKey}` - `#contact/{publicKey}`
- `#contact/{publicKey}/{label}` - `#contact/{publicKey}/{label}`
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`. Where `{section}` is one of `radio`, `local`, `radio-app`, `database`, `fanout`, `statistics`, or `about`.
Legacy name-based channel/contact hashes are still accepted for compatibility. Legacy name-based channel/contact hashes are still accepted for compatibility.
@@ -350,10 +352,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:
@@ -364,7 +362,7 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types` - `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters` - `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel` - `auto_resend_channel`
- `telemetry_interval_hours` - `telemetry_interval_hours`
@@ -385,6 +383,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Header: avatar, name, public key, type badge, on-radio badge - Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops - Info grid: last seen, first heard, last contacted, distance, hops
- GPS location (clickable → map) - GPS location (clickable → map)
- On-demand LPP telemetry: "Request" button fetches `POST /contacts/{key}/telemetry`, displays sensor readings via `LppSensorRow`, optional GPS mini-map (Leaflet), and history chart (Recharts). Opt-in tracking toggle uses `POST /settings/tracked-telemetry-contacts/toggle`.
- Favorite toggle - Favorite toggle
- Name history ("Also Known As") — shown only when the contact has used multiple names - Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count - Message stats: DM count, channel message count
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"version": "3.12.0", "version": "3.12.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"version": "3.12.0", "version": "3.12.3",
"dependencies": { "dependencies": {
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
@@ -53,7 +53,7 @@
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"postcss": "^8.5.6", "postcss": "^8.5.10",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.6.3", "typescript": "^5.6.3",
@@ -5619,9 +5619,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"private": true, "private": true,
"version": "3.12.0", "version": "3.14.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -61,7 +61,7 @@
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"postcss": "^8.5.6", "postcss": "^8.5.10",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.6.3", "typescript": "^5.6.3",
+5
View File
@@ -166,6 +166,7 @@ export function App() {
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry, handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
} = useAppSettings(); } = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback // Keep user's name in ref for mention detection in WebSocket callback
@@ -715,6 +716,8 @@ export function App() {
}, },
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [], trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry, onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
}; };
const crackerProps = { const crackerProps = {
packets: rawPackets, packets: rawPackets,
@@ -748,6 +751,8 @@ export function App() {
onToggleBlockedName: handleBlockName, onToggleBlockedName: handleBlockName,
blockedKeys: appSettings?.blocked_keys ?? [], blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [], blockedNames: appSettings?.blocked_names ?? [],
trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [],
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
}; };
const channelInfoPaneProps = { const channelInfoPaneProps = {
channelKey: infoPaneChannelKey, channelKey: infoPaneChannelKey,
+22 -2
View File
@@ -8,6 +8,7 @@ import type {
Contact, Contact,
ContactAnalytics, ContactAnalytics,
ContactAdvertPathSummary, ContactAdvertPathSummary,
ContactTelemetryResponse,
FanoutConfig, FanoutConfig,
HealthStatus, HealthStatus,
MaintenanceResult, MaintenanceResult,
@@ -35,6 +36,7 @@ import type {
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry, TelemetryHistoryEntry,
TelemetrySchedule, TelemetrySchedule,
TrackedTelemetryContactsResponse,
TrackedTelemetryResponse, TrackedTelemetryResponse,
StatisticsResponse, StatisticsResponse,
TraceResponse, TraceResponse,
@@ -96,6 +98,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 +160,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`, {
@@ -336,6 +339,16 @@ export const api = {
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'), getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
// Tracked contact telemetry
toggleTrackedTelemetryContact: (publicKey: string) =>
fetchJson<TrackedTelemetryContactsResponse>('/settings/tracked-telemetry-contacts/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
getContactTelemetrySchedule: () =>
fetchJson<TelemetrySchedule>('/settings/tracked-telemetry-contacts/schedule'),
// Favorites // Favorites
toggleFavorite: (type: 'channel' | 'contact', id: string) => toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', { fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
@@ -431,6 +444,13 @@ export const api = {
}), }),
repeaterTelemetryHistory: (publicKey: string) => repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`), fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
// Contact telemetry (universal, any contact type)
requestContactTelemetry: (publicKey: string) =>
fetchJson<ContactTelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
}),
contactTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/telemetry-history`),
roomLogin: (publicKey: string, password: string) => roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, { fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST', method: 'POST',
+35 -2
View File
@@ -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>
); );
} }
+374 -2
View File
@@ -1,6 +1,8 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react'; import { Activity, Ban, ChevronDown, ChevronRight, Search, Star } from 'lucide-react';
import { import {
AreaChart,
Area,
LineChart, LineChart,
Line, Line,
XAxis, XAxis,
@@ -10,6 +12,8 @@ import {
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
} from 'recharts'; } from 'recharts';
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { api, isAbortError } from '../api'; import { api, isAbortError } from '../api';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
import { import {
@@ -31,6 +35,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash'; import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext'; import { useDistanceUnit } from '../contexts/DistanceUnitContext';
@@ -41,7 +46,10 @@ import type {
ContactAnalytics, ContactAnalytics,
ContactAnalyticsHourlyBucket, ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket, ContactAnalyticsWeeklyBucket,
LppSensor,
RadioConfig, RadioConfig,
TelemetryHistoryEntry,
TelemetryLppSensor,
} from '../types'; } from '../types';
const CONTACT_TYPE_LABELS: Record<number, string> = { const CONTACT_TYPE_LABELS: Record<number, string> = {
@@ -73,6 +81,8 @@ interface ContactInfoPaneProps {
blockedNames?: string[]; blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void; onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
} }
export function ContactInfoPane({ export function ContactInfoPane({
@@ -89,6 +99,8 @@ export function ContactInfoPane({
blockedNames = [], blockedNames = [],
onToggleBlockedKey, onToggleBlockedKey,
onToggleBlockedName, onToggleBlockedName,
trackedTelemetryContacts = [],
onToggleTrackedTelemetryContact,
}: ContactInfoPaneProps) { }: ContactInfoPaneProps) {
const { distanceUnit } = useDistanceUnit(); const { distanceUnit } = useDistanceUnit();
const isNameOnly = contactKey?.startsWith('name:') ?? false; const isNameOnly = contactKey?.startsWith('name:') ?? false;
@@ -96,6 +108,8 @@ export function ContactInfoPane({
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null); const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
// Get live contact data from contacts array (real-time via WS) // Get live contact data from contacts array (real-time via WS)
const liveContact = const liveContact =
@@ -133,6 +147,41 @@ export function ContactInfoPane({
}; };
}, [contactKey, isNameOnly, nameOnlyValue]); }, [contactKey, isNameOnly, nameOnlyValue]);
// Load telemetry history when pane opens for a contact
useEffect(() => {
if (!contactKey || isNameOnly) {
setTelemetryHistory([]);
return;
}
let cancelled = false;
api
.contactTelemetryHistory(contactKey)
.then((data) => {
if (!cancelled) setTelemetryHistory(data);
})
.catch(() => {
if (!cancelled) setTelemetryHistory([]);
});
return () => {
cancelled = true;
};
}, [contactKey, isNameOnly]);
const handleFetchTelemetry = useCallback(async () => {
if (!contactKey || isNameOnly) return;
setTelemetryLoading(true);
try {
const result = await api.requestContactTelemetry(contactKey);
setTelemetryHistory(result.telemetry_history);
} catch (err) {
if (!isAbortError(err)) {
toast.error(err instanceof Error ? err.message : 'Failed to fetch telemetry');
}
} finally {
setTelemetryLoading(false);
}
}, [contactKey, isNameOnly]);
// Use live contact data where available, fall back to analytics snapshot // Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null; const contact = liveContact ?? analytics?.contact ?? null;
@@ -371,6 +420,16 @@ export function ContactInfoPane({
</div> </div>
)} )}
{/* Contact Telemetry */}
<ContactTelemetrySection
contact={contact}
loading={telemetryLoading}
onFetch={handleFetchTelemetry}
telemetryHistory={telemetryHistory}
isTracked={trackedTelemetryContacts.includes(contact.public_key)}
onToggleTracked={onToggleTrackedTelemetryContact}
/>
{/* Favorite toggle */} {/* Favorite toggle */}
<div className="px-5 py-3 border-b border-border"> <div className="px-5 py-3 border-b border-border">
<button <button
@@ -909,3 +968,316 @@ function InfoItem({ label, value }: { label: string; value: ReactNode }) {
</div> </div>
); );
} }
// Stable color rotation for dynamic LPP sensors in the history chart
const LPP_CHART_COLORS = ['#22c55e', '#8b5cf6', '#0ea5e9', '#ef4444', '#f59e0b', '#ec4899'];
function ContactTelemetrySection({
contact,
loading,
onFetch,
telemetryHistory,
isTracked,
onToggleTracked,
}: {
contact: Contact;
loading: boolean;
onFetch: () => void;
telemetryHistory: TelemetryHistoryEntry[];
isTracked: boolean;
onToggleTracked?: (publicKey: string) => Promise<void>;
}) {
const { distanceUnit } = useDistanceUnit();
const [expanded, setExpanded] = useState(true);
const [mapExpanded, setMapExpanded] = useState(false);
const [chartExpanded, setChartExpanded] = useState(false);
const [toggling, setToggling] = useState(false);
// Latest telemetry snapshot from history
const latestEntry =
telemetryHistory.length > 0 ? telemetryHistory[telemetryHistory.length - 1] : null;
const sensors: LppSensor[] = useMemo(() => {
if (!latestEntry?.data?.lpp_sensors) return [];
return latestEntry.data.lpp_sensors.map((s: TelemetryLppSensor) => ({
channel: s.channel,
type_name: s.type_name,
value: s.value,
}));
}, [latestEntry]);
const fetchedAt = latestEntry?.timestamp ?? null;
// Extract GPS from sensors
const gpsSensor = sensors.find(
(s) => s.type_name === 'gps' && typeof s.value === 'object' && s.value !== null
);
const gpsValue = gpsSensor?.value as Record<string, number> | undefined;
const hasGps =
gpsValue != null &&
typeof gpsValue.latitude === 'number' &&
typeof gpsValue.longitude === 'number';
// Non-GPS sensors for display
const displaySensors = sensors.filter((s) => s.type_name !== 'gps');
// Build disambiguated labels
const labels = useMemo(() => {
const counts = new Map<string, number>();
return displaySensors.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) + (n > 1 ? ` (${n})` : '');
});
}, [displaySensors]);
// Discover unique LPP sensor series from history for charting
const sensorSeries = useMemo(() => {
const seen = new Map<string, { type_name: string; channel: number }>();
for (const entry of telemetryHistory) {
for (const s of entry.data?.lpp_sensors ?? []) {
if (typeof s.value !== 'number') continue;
const key = `${s.type_name}_ch${s.channel}`;
if (!seen.has(key)) seen.set(key, { type_name: s.type_name, channel: s.channel });
}
}
return Array.from(seen.entries()).map(([key, info], i) => ({
key,
label: formatLppLabel(info.type_name),
color: LPP_CHART_COLORS[i % LPP_CHART_COLORS.length],
...info,
}));
}, [telemetryHistory]);
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
const activeMetric = selectedMetric ?? (sensorSeries.length > 0 ? sensorSeries[0].key : null);
// Build chart data for selected metric
const chartData = useMemo(() => {
if (!activeMetric) return [];
const series = sensorSeries.find((s) => s.key === activeMetric);
if (!series) return [];
return telemetryHistory
.filter((e) => e.data?.lpp_sensors)
.map((e) => {
const sensor = (e.data.lpp_sensors ?? []).find(
(s: TelemetryLppSensor) =>
s.type_name === series.type_name && s.channel === series.channel
);
return {
time: e.timestamp,
value: sensor && typeof sensor.value === 'number' ? sensor.value : null,
};
})
.filter((d) => d.value !== null);
}, [telemetryHistory, activeMetric, sensorSeries]);
const activeSeries = sensorSeries.find((s) => s.key === activeMetric);
return (
<div className="px-5 py-3 border-b border-border">
<div className="flex items-center justify-between">
<button
type="button"
className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Telemetry
</button>
<button
type="button"
onClick={onFetch}
disabled={loading}
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-accent disabled:opacity-50 transition-colors flex items-center gap-1"
>
<Activity className="h-3 w-3" />
{loading ? 'Fetching...' : 'Request'}
</button>
</div>
{expanded && (
<div className="mt-2">
{sensors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
{fetchedAt ? 'No sensor data in last response' : 'Not yet fetched'}
</p>
) : (
<>
<div className="space-y-0.5">
{displaySensors.map((sensor, i) => (
<LppSensorRow
key={`${sensor.type_name}-${sensor.channel}-${i}`}
sensor={sensor}
unitPref={distanceUnit}
label={labels[i]}
/>
))}
</div>
{hasGps && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setMapExpanded(!mapExpanded)}
>
{mapExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
GPS: {gpsValue!.latitude.toFixed(5)}, {gpsValue!.longitude.toFixed(5)}
</button>
{mapExpanded && (
<div className="mt-1 h-48 rounded border border-border overflow-hidden">
<MapContainer
center={[gpsValue!.latitude, gpsValue!.longitude]}
zoom={13}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<CircleMarker
center={[gpsValue!.latitude, gpsValue!.longitude]}
radius={7}
pathOptions={{
color: '#1d4ed8',
fillColor: '#3b82f6',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<span className="text-sm">
{contact.name ?? contact.public_key.slice(0, 12)}
</span>
</Popup>
</CircleMarker>
</MapContainer>
</div>
)}
</div>
)}
{fetchedAt && (
<p className="text-[0.6875rem] text-muted-foreground mt-1.5">
Fetched {formatTime(fetchedAt)}
</p>
)}
</>
)}
{/* History chart */}
{telemetryHistory.length > 1 && sensorSeries.length > 0 && (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
onClick={() => setChartExpanded(!chartExpanded)}
>
{chartExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
History ({telemetryHistory.length} samples)
</button>
{chartExpanded && (
<div className="mt-1">
<div className="flex flex-wrap gap-1 mb-2">
{sensorSeries.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSelectedMetric(s.key)}
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded transition-colors ${
activeMetric === s.key
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{s.label}
</button>
))}
</div>
{chartData.length > 1 && activeSeries && (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="time"
tickFormatter={(t: number) => {
const d = new Date(t * 1000);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`;
}}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
width={40}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
labelFormatter={(t) => new Date(Number(t) * 1000).toLocaleString()}
/>
<Area
type="monotone"
dataKey="value"
name={activeSeries.label}
stroke={activeSeries.color}
fill={activeSeries.color}
fillOpacity={0.15}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
)}
</div>
)}
{/* Tracking toggle */}
{onToggleTracked && (
<div className="mt-2 pt-2 border-t border-border/50">
<button
type="button"
disabled={toggling}
onClick={async () => {
setToggling(true);
try {
await onToggleTracked(contact.public_key);
} finally {
setToggling(false);
}
}}
className={`text-xs px-2 py-1 rounded border transition-colors w-full ${
isTracked
? 'border-destructive/50 text-destructive hover:bg-destructive/10'
: 'border-green-600/50 text-green-600 hover:bg-green-600/10'
} disabled:opacity-50`}
>
{toggling
? 'Updating...'
: isTracked
? 'Stop Tracking Telemetry'
: 'Track Telemetry on Interval'}
</button>
</div>
)}
</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}
+65 -15
View File
@@ -4,14 +4,20 @@ import {
useImperativeHandle, useImperativeHandle,
forwardRef, forwardRef,
useRef, useRef,
useEffect,
useMemo, useMemo,
type ChangeEvent,
type FormEvent, type FormEvent,
type KeyboardEvent, type KeyboardEvent,
} from 'react'; } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
getTextReplaceEnabled,
getTextReplaceMapJson,
applyTextReplacements,
} from '../utils/textReplace';
// MeshCore message size limits (empirically determined from LoRa packet constraints) // MeshCore message size limits (empirically determined from LoRa packet constraints)
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth. // Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
@@ -53,19 +59,32 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
) { ) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
/** Resize textarea to fit content, clamped between 1 row and ~6 rows. */
const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
// Clamp: min 40px (≈1 row), max 160px (≈6 rows)
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}, []);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
appendText: (appendedText: string) => { appendText: (appendedText: string) => {
setText((prev) => prev + appendedText); setText((prev) => prev + appendedText);
// Focus the input after appending textareaRef.current?.focus();
inputRef.current?.focus();
}, },
focus: () => { focus: () => {
inputRef.current?.focus(); textareaRef.current?.focus();
}, },
})); }));
// Re-measure height whenever text changes (covers programmatic updates like appendText)
useEffect(() => {
autoResize();
}, [text, autoResize]);
// Calculate character limits based on conversation type // Calculate character limits based on conversation type
const limits = useMemo(() => { const limits = useMemo(() => {
if (conversationType === 'contact') { if (conversationType === 'contact') {
@@ -133,18 +152,44 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
} finally { } finally {
setSending(false); setSending(false);
} }
// Refocus after React re-enables the input // Refocus after React re-enables the textarea
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => textareaRef.current?.focus(), 0);
}, },
[text, sending, disabled, onSend] [text, sending, disabled, onSend]
); );
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const input = e.target;
const raw = input.value;
// Skip replacement during IME / dead-key composition to avoid garbling interim input
if (!e.nativeEvent || (e.nativeEvent as InputEvent).isComposing) {
setText(raw);
return;
}
if (getTextReplaceEnabled()) {
const result = applyTextReplacements(
raw,
input.selectionStart ?? raw.length,
getTextReplaceMapJson()
);
if (result) {
setText(result.text);
// Schedule cursor restore after React flushes the new value
const pos = result.cursor;
requestAnimationFrame(() => input.setSelectionRange(pos, pos));
return;
}
}
setText(raw);
}, []);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => { (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSubmit(e as unknown as FormEvent); handleSubmit(e as unknown as FormEvent);
} }
// Shift+Enter falls through naturally and inserts a newline
}, },
[handleSubmit] [handleSubmit]
); );
@@ -162,22 +207,27 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
onSubmit={handleSubmit} onSubmit={handleSubmit}
autoComplete="off" autoComplete="off"
> >
<div className="flex gap-2"> <div className="flex gap-2 items-end">
<Input <textarea
ref={inputRef} ref={textareaRef}
type="text"
autoComplete="off"
name="chat-message-input" name="chat-message-input"
aria-label={placeholder || 'Type a message'} aria-label={placeholder || 'Type a message'}
data-lpignore="true" data-lpignore="true"
data-1p-ignore="true" data-1p-ignore="true"
data-bwignore="true" data-bwignore="true"
rows={1}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'} placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending} disabled={disabled || sending}
className="flex-1 min-w-0" className={cn(
'flex-1 min-w-0 resize-none overflow-y-auto',
'rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background',
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50 md:text-sm'
)}
style={{ minHeight: '40px', maxHeight: '160px' }}
/> />
<Button <Button
type="submit" type="submit"
+17 -1
View File
@@ -1,6 +1,21 @@
import { MapContainer, TileLayer, CircleMarker, Popup, Polyline } from 'react-leaflet'; import { useEffect } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, Polyline, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
/** Watches the map container for size changes and tells Leaflet to re-tile. */
function InvalidateOnResize() {
const map = useMap();
useEffect(() => {
const container = map.getContainer();
const ro = new ResizeObserver(() => {
map.invalidateSize();
});
ro.observe(container);
return () => ro.disconnect();
}, [map]);
return null;
}
interface Neighbor { interface Neighbor {
lat: number | null; lat: number | null;
lon: number | null; lon: number | null;
@@ -40,6 +55,7 @@ export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: P
className="h-full w-full" className="h-full w-full"
style={{ background: '#1a1a2e' }} style={{ background: '#1a1a2e' }}
> >
<InvalidateOnResize />
<TileLayer <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+22 -2
View File
@@ -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">
@@ -166,6 +166,10 @@ function formatPathMode(hashSize: number | undefined, hopCount: number): string
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`; return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
} }
function formatTransportCodes(codes: [number, number]): string {
return codes.map((c) => `0x${c.toString(16).padStart(4, '0')}`).join(', ');
}
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] { function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
return channels.map((channel) => ({ return channels.map((channel) => ({
key: channel.key, key: channel.key,
@@ -647,7 +651,14 @@ export function RawPacketInspectionPanel({
) : null} ) : null}
</section> </section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3"> <section
className={cn(
'grid gap-2 lg:grid-cols-1',
inspection.decoded?.transportCodes
? 'sm:grid-cols-2 xl:grid-cols-4'
: 'sm:grid-cols-3 xl:grid-cols-3'
)}
>
<CompactMetaCard <CompactMetaCard
label="Packet" label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`} primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
@@ -658,6 +669,13 @@ export function RawPacketInspectionPanel({
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`} primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`} secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/> />
{inspection.decoded?.transportCodes ? (
<CompactMetaCard
label="Scope"
primary="Regional"
secondary={formatTransportCodes(inspection.decoded.transportCodes)}
/>
) : null}
{(() => { {(() => {
const sig = formatSignal(packet, signalOverride); const sig = formatSignal(packet, signalOverride);
return ( return (
+38 -9
View File
@@ -31,7 +31,29 @@ import { createDecoderOptions } from '../utils/rawPacketInspector';
import { getContactDisplayName } from '../utils/pubkey'; import { getContactDisplayName } from '../utils/pubkey';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
/**
* Build a stable namecolor mapping so the same type always gets the same
* color regardless of sort order or appearance order.
*/
function buildColorMap(names: readonly string[]): Map<string, string> {
const map = new Map<string, string>();
for (let i = 0; i < names.length; i++) {
map.set(names[i], TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]);
}
return map;
}
function colorForIndex(index: number, colorMap?: Map<string, string>, name?: string): string {
if (colorMap && name && colorMap.has(name)) {
return colorMap.get(name)!;
}
return TIMELINE_FILL_COLORS[index % TIMELINE_FILL_COLORS.length];
}
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES); const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
const PAYLOAD_TYPE_COLOR_MAP = buildColorMap(KNOWN_PAYLOAD_TYPES);
function getPacketTypeName( function getPacketTypeName(
packet: RawPacket, packet: RawPacket,
@@ -74,8 +96,6 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
session: 'Session', session: 'Session',
}; };
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
function formatTimestamp(timestampMs: number): string { function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toLocaleString([], { return new Date(timestampMs).toLocaleString([], {
month: 'short', month: 'short',
@@ -245,11 +265,13 @@ function RankedBars({
items, items,
emptyLabel, emptyLabel,
formatter, formatter,
colorMap,
}: { }: {
title: string; title: string;
items: RankedPacketStat[]; items: RankedPacketStat[];
emptyLabel: string; emptyLabel: string;
formatter?: (item: RankedPacketStat) => string; formatter?: (item: RankedPacketStat) => string;
colorMap?: Map<string, string>;
}) { }) {
const data = items.map((item) => ({ const data = items.map((item) => ({
name: item.label, name: item.label,
@@ -289,8 +311,8 @@ function RankedBars({
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]} formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
/> />
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}> <Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
{data.map((_, i) => ( {data.map((entry, i) => (
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} /> <Cell key={i} fill={colorForIndex(i, colorMap, entry.name)} />
))} ))}
</Bar> </Bar>
</BarChart> </BarChart>
@@ -367,7 +389,13 @@ function NeighborList({
); );
} }
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) { function TimelineChart({
bins,
colorMap,
}: {
bins: PacketTimelineBin[];
colorMap: Map<string, string>;
}) {
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice( const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
0, 0,
TIMELINE_FILL_COLORS.length TIMELINE_FILL_COLORS.length
@@ -386,11 +414,11 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3> <h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground"> <div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
{typeOrder.map((type, i) => ( {typeOrder.map((type) => (
<span key={type} className="inline-flex items-center gap-1"> <span key={type} className="inline-flex items-center gap-1">
<span <span
className="h-2 w-2 rounded-full" className="h-2 w-2 rounded-full"
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }} style={{ backgroundColor: colorMap.get(type) ?? TIMELINE_FILL_COLORS[0] }}
/> />
<span>{type}</span> <span>{type}</span>
</span> </span>
@@ -422,7 +450,7 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
key={type} key={type}
dataKey={type} dataKey={type}
stackId="packets" stackId="packets"
fill={TIMELINE_FILL_COLORS[i]} fill={colorMap.get(type) ?? TIMELINE_FILL_COLORS[0]}
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined} radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
/> />
))} ))}
@@ -747,7 +775,7 @@ export function RawPacketFeedView({
</div> </div>
<div className="mt-4"> <div className="mt-4">
<TimelineChart bins={stats.timeline} /> <TimelineChart bins={stats.timeline} colorMap={PAYLOAD_TYPE_COLOR_MAP} />
</div> </div>
<div className="md:columns-2 md:gap-4"> <div className="md:columns-2 md:gap-4">
@@ -755,6 +783,7 @@ export function RawPacketFeedView({
title="Packet Types" title="Packet Types"
items={stats.payloadBreakdown} items={stats.payloadBreakdown}
emptyLabel="No packets in this window yet." emptyLabel="No packets in this window yet."
colorMap={PAYLOAD_TYPE_COLOR_MAP}
/> />
<RankedBars <RankedBars
@@ -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">
+92 -23
View File
@@ -61,38 +61,107 @@ function createInitialPaneStates(): RoomPaneStates {
}; };
} }
function createInitialPaneData(): RoomPaneData {
return { status: null, acl: null, lppTelemetry: null };
}
// ---------------------------------------------------------------------------
// In-memory LRU cache so room login state survives conversation switches
// ---------------------------------------------------------------------------
interface RoomCacheEntry {
authenticated: boolean;
loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: RoomPaneData;
paneStates: RoomPaneStates;
consoleHistory: ConsoleEntry[];
}
const MAX_CACHED_ROOMS = 8;
const roomCache = new Map<string, RoomCacheEntry>();
function getCachedRoom(publicKey: string): RoomCacheEntry | null {
const cached = roomCache.get(publicKey);
if (!cached) return null;
// Touch for LRU
roomCache.delete(publicKey);
roomCache.set(publicKey, cached);
return {
...cached,
paneData: { ...cached.paneData },
paneStates: {
status: { ...cached.paneStates.status, loading: false },
acl: { ...cached.paneStates.acl, loading: false },
lppTelemetry: { ...cached.paneStates.lppTelemetry, loading: false },
},
consoleHistory: cached.consoleHistory.map((e) => ({ ...e })),
};
}
function setCachedRoom(publicKey: string, entry: RoomCacheEntry) {
roomCache.delete(publicKey);
roomCache.set(publicKey, {
...entry,
paneData: { ...entry.paneData },
paneStates: {
status: { ...entry.paneStates.status, loading: false },
acl: { ...entry.paneStates.acl, loading: false },
lppTelemetry: { ...entry.paneStates.lppTelemetry, loading: false },
},
consoleHistory: entry.consoleHistory.map((e) => ({ ...e })),
});
if (roomCache.size > MAX_CACHED_ROOMS) {
const lruKey = roomCache.keys().next().value as string | undefined;
if (lruKey) roomCache.delete(lruKey);
}
}
export function resetRoomCacheForTests() {
roomCache.clear();
}
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) { export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('room', contact.public_key); useRememberedServerPassword('room', contact.public_key);
const cached = useMemo(() => getCachedRoom(contact.public_key), [contact.public_key]);
const [loginLoading, setLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(cached?.loginError ?? null);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(cached?.authenticated ?? false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null); const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
cached?.lastLoginAttempt ?? null
);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({ const [paneData, setPaneData] = useState<RoomPaneData>(cached?.paneData ?? createInitialPaneData);
status: null, const [paneStates, setPaneStates] = useState<RoomPaneStates>(
acl: null, cached?.paneStates ?? createInitialPaneStates
lppTelemetry: null, );
}); const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates); cached?.consoleHistory ?? []
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]); );
const [consoleLoading, setConsoleLoading] = useState(false); const [consoleLoading, setConsoleLoading] = useState(false);
// Persist to cache on every state change
useEffect(() => { useEffect(() => {
setLoginLoading(false); setCachedRoom(contact.public_key, {
setLoginError(null); authenticated,
setAuthenticated(false); loginError,
setLastLoginAttempt(null); lastLoginAttempt,
setAdvancedOpen(false); paneData,
setPaneData({ paneStates,
status: null, consoleHistory,
acl: null,
lppTelemetry: null,
}); });
setPaneStates(createInitialPaneStates()); }, [
setConsoleHistory([]); contact.public_key,
setConsoleLoading(false); authenticated,
}, [contact.public_key]); loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
]);
useEffect(() => { useEffect(() => {
onAuthenticatedChange?.(authenticated); onAuthenticatedChange?.(authenticated);
+36 -8
View File
@@ -20,6 +20,7 @@ import {
import { SettingsRadioSection } from './settings/SettingsRadioSection'; import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection'; import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsRadioAppSection } from './settings/SettingsRadioAppSection';
import { SettingsFanoutSection } from './settings/SettingsFanoutSection'; import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection'; import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection'; import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
@@ -54,6 +55,8 @@ interface SettingsModalBaseProps {
onBulkDeleteContacts?: (deletedKeys: string[]) => void; onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[]; trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>; onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
} }
export type SettingsModalProps = SettingsModalBaseProps & export type SettingsModalProps = SettingsModalBaseProps &
@@ -92,6 +95,8 @@ export function SettingsModal(props: SettingsModalProps) {
onBulkDeleteContacts, onBulkDeleteContacts,
trackedTelemetryRepeaters, trackedTelemetryRepeaters,
onToggleTrackedTelemetry, onToggleTrackedTelemetry,
trackedTelemetryContacts,
onToggleTrackedTelemetryContact,
} = props; } = props;
const externalSidebarNav = props.externalSidebarNav === true; const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -106,6 +111,7 @@ export function SettingsModal(props: SettingsModalProps) {
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
radio: false, radio: false,
local: false, local: false,
'radio-app': false,
fanout: false, fanout: false,
database: false, database: false,
statistics: false, statistics: false,
@@ -239,6 +245,36 @@ export function SettingsModal(props: SettingsModalProps) {
</section> </section>
)} )}
{shouldRenderSection('radio-app') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('radio-app')}
{isSectionVisible('radio-app') &&
(appSettings ? (
<SettingsRadioAppSection
appSettings={appSettings}
onSaveAppSettings={onSaveAppSettings}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
trackedTelemetryContacts={trackedTelemetryContacts}
onToggleTrackedTelemetryContact={onToggleTrackedTelemetryContact}
className={sectionContentClass}
/>
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Loading app settings...
</div>
</div>
))}
</section>
)}
{shouldRenderSection('database') && ( {shouldRenderSection('database') && (
<section className={sectionWrapperClass}> <section className={sectionWrapperClass}>
{renderSectionHeader('database')} {renderSectionHeader('database')}
@@ -249,14 +285,6 @@ export function SettingsModal(props: SettingsModalProps) {
health={health} health={health}
onSaveAppSettings={onSaveAppSettings} onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh} onHealthRefresh={onHealthRefresh}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
className={sectionContentClass} className={sectionContentClass}
/> />
) : ( ) : (
@@ -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>
)} )}
@@ -17,7 +17,12 @@ import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../t
const MAX_TRACKED = 8; const MAX_TRACKED = 8;
type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds'; type BuiltinMetric =
| 'battery_volts'
| 'noise_floor_dbm'
| 'packets'
| 'recv_errors'
| 'uptime_seconds';
interface MetricConfig { interface MetricConfig {
label: string; label: string;
@@ -29,6 +34,7 @@ const BUILTIN_METRIC_CONFIG: Record<BuiltinMetric, MetricConfig> = {
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' }, battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' }, noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
packets: { label: 'Packets', unit: '', color: '#0ea5e9' }, packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
recv_errors: { label: 'RX Errors', unit: '', color: '#ef4444' },
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' }, uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
}; };
@@ -37,9 +43,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
// Stable color rotation for dynamic LPP sensors // 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 +108,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 +120,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,
@@ -139,18 +154,25 @@ export function TelemetryHistoryPane({
const chartData = useMemo(() => { const chartData = useMemo(() => {
return entries.map((e) => { return entries.map((e) => {
const d = e.data; const d = e.data;
const recvErrors = d.recv_errors ?? undefined;
const packetsReceived = d.packets_received;
const point: Record<string, number | undefined> = { const point: Record<string, number | undefined> = {
timestamp: e.timestamp, timestamp: e.timestamp,
battery_volts: d.battery_volts, battery_volts: d.battery_volts,
noise_floor_dbm: d.noise_floor_dbm, noise_floor_dbm: d.noise_floor_dbm,
packets_received: d.packets_received, packets_received: packetsReceived,
packets_sent: d.packets_sent, packets_sent: d.packets_sent,
recv_errors: recvErrors,
recv_error_pct:
recvErrors != null && packetsReceived != null && packetsReceived + recvErrors > 0
? +((recvErrors / (packetsReceived + recvErrors)) * 100).toFixed(2)
: undefined,
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;
@@ -158,7 +180,11 @@ export function TelemetryHistoryPane({
}, [entries, distanceUnit]); }, [entries, distanceUnit]);
const dataKeys = const dataKeys =
activeMetric === 'packets' ? ['packets_received', 'packets_sent'] : [activeMetric]; activeMetric === 'packets'
? ['packets_received', 'packets_sent']
: activeMetric === 'recv_errors'
? ['recv_errors', 'recv_error_pct']
: [activeMetric];
const yDomain = useMemo<[number, number] | undefined>(() => { const yDomain = useMemo<[number, number] | undefined>(() => {
if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined; if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined;
@@ -169,6 +195,20 @@ export function TelemetryHistoryPane({
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)]; return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
}, [activeMetric, chartData]); }, [activeMetric, chartData]);
const yDomainPct = useMemo<[number, number]>(() => {
const MIN_SPAN = 5;
const values = chartData.map((d) => d.recv_error_pct).filter((v) => v != null) as number[];
if (values.length === 0) return [0, MIN_SPAN];
const lo = Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo;
if (span >= MIN_SPAN)
return [Math.max(0, Math.floor(lo - span * 0.1)), Math.ceil(hi + span * 0.1)];
const pad = (MIN_SPAN - span) / 2;
const bottom = Math.max(0, Math.floor(lo - pad));
return [bottom, Math.ceil(bottom + MIN_SPAN)];
}, [chartData]);
const handleToggle = async () => { const handleToggle = async () => {
setToggling(true); setToggling(true);
try { try {
@@ -205,16 +245,16 @@ export function TelemetryHistoryPane({
via the repeater pane, API calls to the endpoint ( via the repeater pane, API calls to the endpoint (
<code className="text-[0.6875rem]">POST /api/contacts/&lt;key&gt;/repeater/status</code> <code className="text-[0.6875rem]">POST /api/contacts/&lt;key&gt;/repeater/status</code>
), or when the repeater is opted into interval telemetry polling, in which case the ), or when the repeater is opted into interval telemetry polling, in which case the
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
into this flow in the{' '}
<a <a
href="#settings/database" href="#settings/radio-app"
className="underline text-primary hover:text-primary/80 transition-colors" className="underline text-primary hover:text-primary/80 transition-colors"
> >
Database &amp; Messaging Settings &rarr; Radio-App Management
</a>{' '} </a>
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake , where you can also see which repeaters are currently opted in. A maximum of{' '}
of keeping mesh congestion reasonable. {MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
reasonable.
</p> </p>
{isTracked ? ( {isTracked ? (
@@ -243,7 +283,7 @@ export function TelemetryHistoryPane({
disabled={toggling} disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10" className="border-green-600/50 text-green-600 hover:bg-green-600/10"
> >
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'} {toggling ? 'Updating...' : 'Opt Repeater into Interval Metrics Tracking'}
</Button> </Button>
)} )}
</div> </div>
@@ -290,7 +330,15 @@ export function TelemetryHistoryPane({
</p> </p>
) : ( ) : (
<ResponsiveContainer width="100%" height={180}> <ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}> <AreaChart
data={chartData}
margin={{
top: 4,
right: activeMetric === 'recv_errors' ? 8 : 4,
bottom: 0,
left: -8,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis <XAxis
dataKey="timestamp" dataKey="timestamp"
@@ -302,6 +350,7 @@ export function TelemetryHistoryPane({
tickFormatter={formatTime} tickFormatter={formatTime}
/> />
<YAxis <YAxis
yAxisId="left"
domain={yDomain} domain={yDomain}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }} tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false} tickLine={false}
@@ -310,6 +359,17 @@ export function TelemetryHistoryPane({
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}` activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
} }
/> />
{activeMetric === 'recv_errors' && (
<YAxis
yAxisId="right"
orientation="right"
domain={yDomainPct}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}%`}
/>
)}
<RechartsTooltip <RechartsTooltip
{...TOOLTIP_STYLE} {...TOOLTIP_STYLE}
cursor={{ cursor={{
@@ -321,6 +381,10 @@ export function TelemetryHistoryPane({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => { formatter={(value: any, name: any) => {
const numVal = typeof value === 'number' ? value : Number(value); const numVal = typeof value === 'number' ? value : Number(value);
if (activeMetric === 'recv_errors') {
if (name === 'recv_error_pct') return [`${numVal}%`, 'Error Rate'];
return [`${value}`, 'RX Errors'];
}
const display = const display =
activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`; activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
const suffix = const suffix =
@@ -338,51 +402,44 @@ export function TelemetryHistoryPane({
return [`${display}${suffix}`, label]; return [`${display}${suffix}`, label];
}} }}
/> />
{dataKeys.map((key, i) => ( {dataKeys.map((key, i) => {
<Area const color =
key={key} activeMetric === 'packets'
type="linear" ? i === 0
dataKey={key} ? '#0ea5e9'
stroke={ : '#f43f5e'
activeMetric === 'packets' : activeMetric === 'recv_errors'
? i === 0 ? i === 0
? '#0ea5e9' ? '#ef4444'
: '#f43f5e' : '#f59e0b'
: activeConfig.color : activeConfig.color;
} return (
fill={ <Area
activeMetric === 'packets' key={key}
? i === 0 type="linear"
? '#0ea5e9' dataKey={key}
: '#f43f5e' yAxisId={
: activeConfig.color activeMetric === 'recv_errors' && key === 'recv_error_pct' ? 'right' : 'left'
} }
fillOpacity={0.15} stroke={color}
strokeWidth={1.5} fill={color}
dot={{ fillOpacity={0.15}
r: 4, strokeWidth={1.5}
fill: dot={{
activeMetric === 'packets' r: 4,
? i === 0 fill: color,
? '#0ea5e9' strokeWidth: 1.5,
: '#f43f5e' stroke: 'hsl(var(--popover))',
: activeConfig.color, }}
strokeWidth: 1.5, activeDot={{
stroke: 'hsl(var(--popover))', r: 6,
}} fill: color,
activeDot={{ strokeWidth: 2,
r: 6, stroke: 'hsl(var(--popover))',
fill: }}
activeMetric === 'packets' />
? i === 0 );
? '#0ea5e9' })}
: '#f43f5e'
: activeConfig.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
))}
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
@@ -91,6 +91,26 @@ export function TelemetryPane({
label="Duplicates" label="Duplicates"
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`} value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
/> />
{data.recv_errors != null && (
<KvRow
label="RX Errors"
value={
<>
{data.recv_errors.toLocaleString()}
{data.packets_received > 0 && (
<Secondary>
(
{(
(data.recv_errors / (data.packets_received + data.recv_errors)) *
100
).toFixed(2)}
%)
</Secondary>
)}
</>
}
/>
)}
<Separator className="my-1" /> <Separator className="my-1" />
<KvRow label="TX Queue" value={data.tx_queue_len} /> <KvRow label="TX Queue" value={data.tx_queue_len} />
<KvRow label="Debug Flags" value={data.full_events} /> <KvRow label="Debug Flags" value={data.full_events} />
@@ -212,11 +212,11 @@ export const LPP_UNIT_MAP: Record<string, string> = {
humidity: '%', humidity: '%',
barometer: 'hPa', barometer: 'hPa',
voltage: 'V', voltage: 'V',
current: 'mA', current: 'A',
luminosity: 'lux', luminosity: 'lux',
altitude: 'm', altitude: 'm',
power: 'W', power: 'W',
distance: 'mm', distance: 'm',
energy: 'kWh', energy: 'kWh',
direction: '°', direction: '°',
concentration: 'ppm', concentration: 'ppm',
@@ -235,6 +235,9 @@ export function lppDisplayUnit(
if (typeName === 'temperature' && unitPref === 'imperial') { if (typeName === 'temperature' && unitPref === 'imperial') {
return { unit: '°F', value: (value * 9) / 5 + 32 }; return { unit: '°F', value: (value * 9) / 5 + 32 };
} }
if (typeName === 'current') {
if (value <= 1) return { unit: 'mA', value: value * 1000 };
}
return { unit: LPP_UNIT_MAP[typeName] ?? '', value }; return { unit: LPP_UNIT_MAP[typeName] ?? '', value };
} }
@@ -242,8 +245,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>
@@ -6,113 +6,32 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner'; import { toast } from '../ui/sonner';
import { api } from '../../api'; import { api } from '../../api';
import { formatTime } from '../../utils/messageParser'; import { formatTime } from '../../utils/messageParser';
import { lppDisplayUnit } from '../repeater/repeaterPaneShared'; import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
TelemetryHistoryEntry,
TelemetrySchedule,
} from '../../types';
export function SettingsDatabaseSection({ export function SettingsDatabaseSection({
appSettings, appSettings,
health, health,
onSaveAppSettings, onSaveAppSettings,
onHealthRefresh, onHealthRefresh,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
className, className,
}: { }: {
appSettings: AppSettings; appSettings: AppSettings;
health: HealthStatus | null; health: HealthStatus | null;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>; onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onHealthRefresh: () => Promise<void>; onHealthRefresh: () => Promise<void>;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
className?: string; className?: string;
}) { }) {
const { distanceUnit } = useDistanceUnit();
const [retentionDays, setRetentionDays] = useState('14'); const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false); const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
// Serialization chain for every auto-persisted control on this page.
// Without this, rapid successive toggles (or mixed dropdown + checkbox
// interactions) can dispatch overlapping PATCHes that land out of order
// on HTTP/2 — a stale write then wins, reverting the user's last click.
// Each call awaits the previous one before sending its request, so the
// server sees updates in the order the user made them.
const saveChainRef = useRef<Promise<void>>(Promise.resolve()); const saveChainRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => { useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
setIntervalDraft(appSettings.telemetry_interval_hours);
}, [appSettings]); }, [appSettings]);
// Re-fetch the scheduler derivation whenever the tracked list changes or
// the stored preference changes. Cheap: single GET, no radio lock.
useEffect(() => {
let cancelled = false;
api
.getTelemetrySchedule()
.then((s) => {
if (!cancelled) setSchedule(s);
})
.catch(() => {
// Non-critical: dropdown falls back to the unfiltered menu.
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters.length, appSettings.telemetry_interval_hours]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryRepeaters.map((key) =>
api.repeaterTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters]);
const handleCleanup = async () => { const handleCleanup = async () => {
const days = parseInt(retentionDays, 10); const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) { if (isNaN(days) || days < 1) {
@@ -159,12 +78,6 @@ export function SettingsDatabaseSection({
} }
}; };
/**
* Apply an AppSettings PATCH after any already-queued saves finish, and
* revert local state if the save fails. Every auto-persist control on
* this page routes through here so the user-visible order of clicks is
* the order the backend sees, regardless of network reordering.
*/
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => { const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
const chained = saveChainRef.current.then(async () => { const chained = saveChainRef.current.then(async () => {
try { try {
@@ -291,280 +204,6 @@ export function SettingsDatabaseSection({
contact sends an advertisement. This may cause brief delays on large packet backlogs. contact sends an advertisement. This may cause brief delays on large packet backlogs.
</p> </p>
</div> </div>
<Separator />
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters so fewer tracked repeaters allows shorter intervals, and more tracked
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
</p>
{/* Interval picker. Legal options depend on current tracked count;
we list only those. If the saved preference is no longer legal,
the effective interval is shown below so the user knows what the
scheduler is actually using. */}
<div className="space-y-1.5">
<Label htmlFor="telemetry-interval" className="text-sm">
Collection interval
</Label>
<div className="flex items-center gap-2">
<select
id="telemetry-interval"
value={intervalDraft}
onChange={(e) => {
const nextValue = Number(e.target.value);
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
const prevValue = intervalDraft;
setIntervalDraft(nextValue);
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
setIntervalDraft(prevValue)
);
}}
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
<option key={hrs} value={hrs}>
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
</option>
))}
</select>
</div>
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
<p className="text-xs text-warning">
Saved preference is {schedule.preferred_hours} hour
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
{schedule.tracked_count === 1 ? '' : 's'}{' '}
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
restored if you drop back to a supported count.
</p>
)}
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
Next run at {formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
</div>
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const snap = latestTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
<span>{d.battery_volts?.toFixed(2)}V</span>
<span>noise {d.noise_floor_dbm} dBm</span>
<span>
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
</span>
<span>
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
</span>
{d.lpp_sensors?.map((s) => {
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
{/* Block discovery of new node types */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked
? prev.filter((t) => t !== typeCode)
: [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
{/* Blocked contacts list */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Bulk delete */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
</div> </div>
); );
} }
@@ -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,10 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
config: { config: {
urls: '', urls: '',
preserve_identity: true, preserve_identity: true,
include_path: true, markdown_format: 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' },
}, },
@@ -1533,12 +1545,29 @@ function MqttCommunityConfigEditor({
<option value="none">None</option> <option value="none">None</option>
<option value="password">Username / Password</option> <option value="password">Username / Password</option>
</select> </select>
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
</div> </div>
</div> </div>
<p className="text-[0.8125rem] text-muted-foreground"> {((config.transport as string) || DEFAULT_COMMUNITY_TRANSPORT) === 'websockets' && (
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>. <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
</p> <div className="space-y-2">
<Label htmlFor="fanout-comm-ws-path">WebSocket Path</Label>
<Input
id="fanout-comm-ws-path"
type="text"
placeholder="/"
value={(config.websocket_path as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, websocket_path: e.target.value })}
/>
<p className="text-[0.8125rem] text-muted-foreground">
Defaults to <code>/</code> use <code>/mqtt</code> for brokers that require a path
</p>
</div>
</div>
)}
{authMode === 'token' && ( {authMode === 'token' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -2376,6 +2405,116 @@ 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_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]';
const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]';
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, italic, code). */
function appriseRenderMarkdown(s: string): ReactNode[] {
const nodes: ReactNode[] = [];
let key = 0;
// Split on **bold**, __bold__, *italic*, _italic_, and `code` spans.
// Longer delimiters first so ** and __ match before * and _.
const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g);
for (const part of parts) {
if (
(part.startsWith('**') && part.endsWith('**')) ||
(part.startsWith('__') && part.endsWith('__'))
) {
nodes.push(
<strong key={key++} className="font-bold">
{part.slice(2, -2)}
</strong>
);
} else if (
(part.startsWith('*') && part.endsWith('*')) ||
(part.startsWith('_') && part.endsWith('_'))
) {
nodes.push(
<em key={key++} className="italic">
{part.slice(1, -1)}
</em>
);
} 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,
markdown = true,
}: {
format: string;
vars: Record<string, string>;
markdown?: boolean;
}) {
const raw = appriseApplyFormat(format, vars);
return (
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
{markdown && (
<div>
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Rendered (Discord, Slack, Telegram)
</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">
{markdown ? 'Raw (email, SMS)' : 'Preview'}
</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 +2526,12 @@ 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 markdown = config.markdown_format !== false;
const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN;
const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN;
const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm;
const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan;
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,16 +2590,145 @@ function AppriseConfigEditor({
</div> </div>
</label> </label>
<Separator />
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={config.include_path !== false} checked={markdown}
onChange={(e) => onChange({ ...config, include_path: e.target.checked })} onChange={(e) => {
const md = e.target.checked;
const updates: Record<string, unknown> = { ...config, markdown_format: md };
const curDm = ((config.body_format_dm as string) || '').trim();
const curChan = ((config.body_format_channel as string) || '').trim();
if (md) {
if (!curDm || curDm === APPRISE_DEFAULT_DM_PLAIN)
updates.body_format_dm = APPRISE_DEFAULT_DM;
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL_PLAIN)
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL;
} else {
if (!curDm || curDm === APPRISE_DEFAULT_DM)
updates.body_format_dm = APPRISE_DEFAULT_DM_PLAIN;
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL)
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL_PLAIN;
}
onChange(updates);
}}
className="h-4 w-4 rounded border-border" className="h-4 w-4 rounded border-border"
/> />
<span className="text-sm">Include routing path in notifications</span> <div>
<span className="text-sm">Markdown formatting</span>
<p className="text-[0.8125rem] text-muted-foreground">
If notifications fail on services like Telegram due to special characters in sender
names, disable this option.
</p>
</div>
</label> </label>
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
Available variables
</summary>
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
<span className="text-muted-foreground">Message body</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{sender_name}'}
</code>
<span className="text-muted-foreground">Sender display name</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{sender_key}'}
</code>
<span className="text-muted-foreground">Sender public key (hex)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{channel_name}'}
</code>
<span className="text-muted-foreground">Channel name (channel messages only)</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
{'{conversation_key}'}
</code>
<span className="text-muted-foreground">
Contact pubkey (DM) or channel key (channel)
</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
<span className="text-muted-foreground">PRIV or CHAN</span>
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
<span className="text-muted-foreground">
Comma-separated hop IDs, or &quot;direct&quot;
</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, defaultDm) && (
<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: defaultDm })}
>
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={defaultDm}
value={(config.body_format_dm as string) ?? ''}
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
rows={2}
/>
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} markdown={markdown} />
</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, defaultChan) && (
<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: defaultChan })}
>
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={defaultChan}
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} markdown={markdown} />
</div>
<Separator /> <Separator />
<ScopeSelector scope={scope} onChange={onScopeChange} /> <ScopeSelector scope={scope} onChange={onScopeChange} />
@@ -3055,9 +3329,11 @@ export function SettingsFanoutSection({
</div> </div>
)} )}
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}> <div className="flex flex-wrap gap-2">
Add Integration <Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
</Button> Add Integration
</Button>
</div>
<CreateIntegrationDialog <CreateIntegrationDialog
open={createDialogOpen} open={createDialogOpen}
@@ -33,6 +33,13 @@ import {
setSavedFontScale, setSavedFontScale,
} from '../../utils/fontScale'; } from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput'; import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
import {
getTextReplaceEnabled,
setTextReplaceEnabled as saveTextReplaceEnabled,
getTextReplaceMapJson,
setTextReplaceMapJson,
DEFAULT_MAP_JSON,
} from '../../utils/textReplace';
import { import {
BATTERY_DISPLAY_CHANGE_EVENT, BATTERY_DISPLAY_CHANGE_EVENT,
getShowBatteryPercent, getShowBatteryPercent,
@@ -232,6 +239,9 @@ export function SettingsLocalSection({
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent); const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage); const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled); const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled);
const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson);
const [textReplaceError, setTextReplaceError] = useState<string | null>(null);
const [fontScale, setFontScale] = useState(getSavedFontScale); const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale); const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale())); const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -439,6 +449,63 @@ export function SettingsLocalSection({
</p> </p>
</div> </div>
</div> </div>
<div className="rounded-md border border-border/60 p-3 space-y-2">
<div className="flex items-start gap-3">
<Checkbox
id="text-replace"
checked={textReplaceEnabled}
onCheckedChange={(checked) => {
const v = checked === true;
setTextReplaceEnabled(v);
saveTextReplaceEnabled(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="text-replace">Replace as you Type</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Automatically replace characters as you type in the message input. Define
replacements as a JSON object mapping source strings to their replacements.
</p>
</div>
</div>
{textReplaceEnabled && (
<div className="space-y-2 pl-7">
<textarea
value={textReplaceJson}
onChange={(e) => {
const val = e.target.value;
setTextReplaceJson(val);
setTextReplaceError(setTextReplaceMapJson(val));
}}
spellCheck={false}
rows={10}
className={cn(
'w-full rounded-md border bg-background px-3 py-2 text-sm font-mono',
textReplaceError ? 'border-destructive' : 'border-input'
)}
aria-label="Text replacement map (JSON)"
/>
{textReplaceError && (
<p className="text-xs text-destructive">
{textReplaceError} Changes are not saved until this is resolved.
</p>
)}
<button
type="button"
onClick={() => {
setTextReplaceJson(DEFAULT_MAP_JSON);
setTextReplaceMapJson(DEFAULT_MAP_JSON);
setTextReplaceError(null);
}}
className="inline-flex h-8 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Reset to Default
</button>
</div>
)}
</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -0,0 +1,555 @@
import { useState, useEffect, useRef } from 'react';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
TelemetryHistoryEntry,
TelemetrySchedule,
} from '../../types';
export function SettingsRadioAppSection({
appSettings,
onSaveAppSettings,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
trackedTelemetryContacts = [],
onToggleTrackedTelemetryContact,
className,
}: {
appSettings: AppSettings;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
trackedTelemetryContacts?: string[];
onToggleTrackedTelemetryContact?: (publicKey: string) => Promise<void>;
className?: string;
}) {
const { distanceUnit } = useDistanceUnit();
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
const [latestContactTelemetry, setLatestContactTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const contactTelemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
setIntervalDraft(appSettings.telemetry_interval_hours);
}, [appSettings]);
useEffect(() => {
let cancelled = false;
api
.getTelemetrySchedule()
.then((s) => {
if (!cancelled) setSchedule(s);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [
trackedTelemetryRepeaters.length,
trackedTelemetryContacts.length,
appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly,
]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryRepeaters.map((key) =>
api.repeaterTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters]);
useEffect(() => {
if (trackedTelemetryContacts.length === 0 || contactTelemetryFetchedRef.current) return;
contactTelemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryContacts.map((key) =>
api.contactTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestContactTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryContacts]);
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
const chained = saveChainRef.current.then(async () => {
try {
await onSaveAppSettings(update);
} catch (err) {
console.error('Failed to save radio-app settings:', err);
revert();
toast.error('Failed to save setting', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
});
saveChainRef.current = chained;
return chained;
};
return (
<div className={className}>
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters so fewer tracked repeaters allows shorter intervals, and more tracked
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
</p>
<div className="space-y-1.5">
<Label htmlFor="telemetry-interval" className="text-sm">
Collection interval
</Label>
<div className="flex items-center gap-2">
<select
id="telemetry-interval"
value={intervalDraft}
onChange={(e) => {
const nextValue = Number(e.target.value);
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
const prevValue = intervalDraft;
setIntervalDraft(nextValue);
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
setIntervalDraft(prevValue)
);
}}
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
<option key={hrs} value={hrs}>
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
</option>
))}
</select>
</div>
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
<p className="text-xs text-warning">
Saved preference is {schedule.preferred_hours} hour
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
{schedule.tracked_count === 1 ? '' : 's'}{' '}
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
restored if you drop back to a supported count.
</p>
)}
</div>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={appSettings.telemetry_routed_hourly}
onChange={() => {
const next = !appSettings.telemetry_routed_hourly;
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
}}
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
/>
<div>
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
every hour instead of on the scheduled interval above. Flood-only repeaters still
follow the normal schedule.
</p>
</div>
</label>
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
{formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
{schedule?.next_routed_run_at != null && (
<p className="text-xs text-muted-foreground">
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
</p>
)}
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
<span>{d.battery_volts?.toFixed(2)}V</span>
<span>noise {d.noise_floor_dbm} dBm</span>
<span>
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
</span>
<span>
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
</span>
{d.lpp_sensors?.map((s) => {
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Tracked Contact Telemetry ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Tracked Contact Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP
telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily
check ceiling is shared with tracked repeaters adding contacts may clamp the interval
upward.
</p>
{trackedTelemetryContacts.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No contacts are being tracked. Enable tracking from a contact&apos;s info pane.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryContacts.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestContactTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetryContact && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetryContact(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
{d.lpp_sensors?.map((s) => {
if (typeof s.value !== 'number') return null;
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
const val =
typeof display.value === 'number'
? display.value % 1 === 0
? display.value
: display.value.toFixed(1)
: display.value;
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
return (
<span key={`${s.type_name}-${s.channel}`}>
{label} {val}
{display.unit ? ` ${display.unit}` : ''}
</span>
);
})}
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked
? prev.filter((t) => t !== typeCode)
: [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
</div>
);
}
@@ -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,
@@ -66,6 +183,9 @@ export function SettingsRadioSection({
const [pathHashMode, setPathHashMode] = useState('0'); const [pathHashMode, setPathHashMode] = useState('0');
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current'); const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false); const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
const [telemetryModeBase, setTelemetryModeBase] = useState(0);
const [telemetryModeLoc, setTelemetryModeLoc] = useState(0);
const [telemetryModeEnv, setTelemetryModeEnv] = useState(0);
const [gettingLocation, setGettingLocation] = useState(false); const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false); const [rebooting, setRebooting] = useState(false);
@@ -101,6 +221,9 @@ export function SettingsRadioSection({
setPathHashMode(String(config.path_hash_mode)); setPathHashMode(String(config.path_hash_mode));
setAdvertLocationSource(config.advert_location_source ?? 'current'); setAdvertLocationSource(config.advert_location_source ?? 'current');
setMultiAcksEnabled(config.multi_acks_enabled ?? false); setMultiAcksEnabled(config.multi_acks_enabled ?? false);
setTelemetryModeBase(config.telemetry_mode_base ?? 0);
setTelemetryModeLoc(config.telemetry_mode_loc ?? 0);
setTelemetryModeEnv(config.telemetry_mode_env ?? 0);
}, [config]); }, [config]);
useEffect(() => { useEffect(() => {
@@ -196,6 +319,15 @@ export function SettingsRadioSection({
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false) ...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
? { multi_acks_enabled: multiAcksEnabled } ? { multi_acks_enabled: multiAcksEnabled }
: {}), : {}),
...(telemetryModeBase !== (config.telemetry_mode_base ?? 0)
? { telemetry_mode_base: telemetryModeBase }
: {}),
...(telemetryModeLoc !== (config.telemetry_mode_loc ?? 0)
? { telemetry_mode_loc: telemetryModeLoc }
: {}),
...(telemetryModeEnv !== (config.telemetry_mode_env ?? 0)
? { telemetry_mode_env: telemetryModeEnv }
: {}),
radio: { radio: {
freq: parsedFreq, freq: parsedFreq,
bw: parsedBw, bw: parsedBw,
@@ -279,11 +411,6 @@ export function SettingsRadioSection({
try { try {
const update: AppSettingsUpdate = {}; const update: AppSettingsUpdate = {};
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
update.advert_interval = newAdvertInterval;
}
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) { if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
update.flood_scope = floodScope; update.flood_scope = floodScope;
} }
@@ -302,6 +429,27 @@ export function SettingsRadioSection({
} }
}; };
const [advertIntervalBusy, setAdvertIntervalBusy] = useState(false);
const [advertIntervalError, setAdvertIntervalError] = useState<string | null>(null);
const handleSaveAdvertInterval = async () => {
setAdvertIntervalError(null);
setAdvertIntervalBusy(true);
try {
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
await onSaveAppSettings({ advert_interval: newAdvertInterval });
}
toast.success('Advertising interval saved');
} catch (err) {
setAdvertIntervalError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setAdvertIntervalBusy(false);
}
};
const handleAdvertise = async (mode: RadioAdvertMode) => { const handleAdvertise = async (mode: RadioAdvertMode) => {
setAdvertisingMode(mode); setAdvertisingMode(mode);
try { try {
@@ -320,6 +468,182 @@ 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,
telemetry_mode_base: config.telemetry_mode_base ?? 0,
telemetry_mode_loc: config.telemetry_mode_loc ?? 0,
telemetry_mode_env: config.telemetry_mode_env ?? 0,
});
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);
if (typeof data.telemetry_mode_base === 'number')
setTelemetryModeBase(data.telemetry_mode_base);
if (typeof data.telemetry_mode_loc === 'number') setTelemetryModeLoc(data.telemetry_mode_loc);
if (typeof data.telemetry_mode_env === 'number') setTelemetryModeEnv(data.telemetry_mode_env);
};
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 (typeof data.telemetry_mode_base === 'number')
update.telemetry_mode_base = data.telemetry_mode_base as number;
if (typeof data.telemetry_mode_loc === 'number')
update.telemetry_mode_loc = data.telemetry_mode_loc as number;
if (typeof data.telemetry_mode_env === 'number')
update.telemetry_mode_env = data.telemetry_mode_env as number;
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 +738,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"
@@ -655,6 +982,66 @@ export function SettingsRadioSection({
</div> </div>
</div> </div>
<Separator />
{/* ── Telemetry Sharing ── */}
<div className="space-y-3">
<h3 className="text-base font-semibold tracking-tight">Telemetry Sharing</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Controls what this radio shares when other nodes request its telemetry. &ldquo;Deny&rdquo;
blocks all requests, &ldquo;Per-Contact&rdquo; uses per-contact permission flags on the
radio, and &ldquo;Allow All&rdquo; shares with any requester.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="telemetry-mode-base" className="text-sm">
Battery &amp; Base
</Label>
<select
id="telemetry-mode-base"
value={telemetryModeBase}
onChange={(e) => setTelemetryModeBase(Number(e.target.value))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value={0}>Deny</option>
<option value={1}>Per-Contact</option>
<option value={2}>Allow All</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telemetry-mode-loc" className="text-sm">
Location
</Label>
<select
id="telemetry-mode-loc"
value={telemetryModeLoc}
onChange={(e) => setTelemetryModeLoc(Number(e.target.value))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value={0}>Deny</option>
<option value={1}>Per-Contact</option>
<option value={2}>Allow All</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telemetry-mode-env" className="text-sm">
Environment Sensors
</Label>
<select
id="telemetry-mode-env"
value={telemetryModeEnv}
onChange={(e) => setTelemetryModeEnv(Number(e.target.value))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value={0}>Deny</option>
<option value={1}>Per-Contact</option>
<option value={2}>Allow All</option>
</select>
</div>
</div>
</div>
{error && ( {error && (
<div className="text-sm text-destructive" role="alert"> <div className="text-sm text-destructive" role="alert">
{error} {error}
@@ -678,6 +1065,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 &amp; Reboot
</Button>
<input
ref={importInputRef}
type="file"
accept=".json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImportConfig(file);
}}
/>
</div>
<p className="text-[0.8125rem] text-muted-foreground">
Export saves the current server config to a JSON file. Import loads a config file, applies
it, and reboots the radio.
</p>
<Separator /> <Separator />
{/* ── Messaging ── */} {/* ── Messaging ── */}
@@ -733,9 +1151,9 @@ export function SettingsRadioSection({
placeholder="MyRegion" placeholder="MyRegion"
/> />
<p className="text-[0.8125rem] text-muted-foreground"> <p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
that region can forward the traffic, while repeaters configured to deny other regions may region can forward the traffic, while repeaters configured to deny other regions may drop
drop it. Leave empty to disable. it. Leave empty to disable.
</p> </p>
</div> </div>
@@ -795,6 +1213,18 @@ export function SettingsRadioSection({
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour. How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher. Recommended: 24 hours or higher.
</p> </p>
{advertIntervalError && (
<div className="text-sm text-destructive" role="alert">
{advertIntervalError}
</div>
)}
<Button
onClick={handleSaveAdvertInterval}
disabled={advertIntervalBusy}
className="w-full"
>
{advertIntervalBusy ? 'Saving...' : 'Save Advertising Interval'}
</Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -907,6 +1337,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&apos;s
identity &mdash; 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 &amp; Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -5,16 +5,25 @@ import {
MonitorCog, MonitorCog,
RadioTower, RadioTower,
Share2, Share2,
SlidersHorizontal,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about'; export type SettingsSection =
| 'radio'
| 'local'
| 'radio-app'
| 'database'
| 'fanout'
| 'statistics'
| 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio', 'radio',
'local', 'local',
'database',
'fanout', 'fanout',
'radio-app',
'database',
'statistics', 'statistics',
'about', 'about',
]; ];
@@ -22,7 +31,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = { export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: 'Radio', radio: 'Radio',
local: 'Local Configuration', local: 'Local Configuration',
database: 'Database & Messaging', 'radio-app': 'Radio-App Management',
database: 'Database',
fanout: 'MQTT & Automation', fanout: 'MQTT & Automation',
statistics: 'Statistics', statistics: 'Statistics',
about: 'About', about: 'About',
@@ -31,6 +41,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = { export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = {
radio: RadioTower, radio: RadioTower,
local: MonitorCog, local: MonitorCog,
'radio-app': SlidersHorizontal,
database: Database, database: Database,
fanout: Share2, fanout: Share2,
statistics: BarChart3, statistics: BarChart3,
+34
View File
@@ -113,6 +113,39 @@ export function useAppSettings() {
} }
}, []); }, []);
const handleToggleTrackedTelemetryContact = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_contacts ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_contacts: optimistic };
});
try {
const result = await api.toggleTrackedTelemetryContact(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_contacts: result.tracked_telemetry_contacts } : prev
);
} catch (err) {
console.error('Failed to toggle tracked contact telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked contact telemetry');
}
}
}, []);
// Legacy favorites migration: if pre-server-side favorites exist in // Legacy favorites migration: if pre-server-side favorites exist in
// localStorage, toggle each one via the existing API and clear the key. // localStorage, toggle each one via the existing API and clear the key.
useEffect(() => { useEffect(() => {
@@ -153,5 +186,6 @@ export function useAppSettings() {
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry, handleToggleTrackedTelemetry,
handleToggleTrackedTelemetryContact,
}; };
} }
+2 -2
View File
@@ -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);
+41 -4
View File
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
return arr; return arr;
} }
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`${label} timed out — the service worker may have failed to install. ` +
'Mobile browsers require a trusted TLS certificate for service workers, ' +
'even if the page itself loads with a self-signed cert.'
)
),
ms
);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
}
);
});
}
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean { function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
if (!a || a.length !== b.length) return false; if (!a || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]); const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
// Check if THIS browser has an active push subscription and match it // Check if THIS browser has an active push subscription and match it
// to a backend record. // to a backend record. Use a timeout so we don't hang forever when the
navigator.serviceWorker.ready // service worker failed to install (e.g. mobile + self-signed cert).
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
.then((reg) => reg.pushManager.getSubscription()) .then((reg) => reg.pushManager.getSubscription())
.then(async (sub) => { .then(async (sub) => {
const existing = await subsPromise; const existing = await subsPromise;
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
const refreshSubscriptions = useCallback(async () => { const refreshSubscriptions = useCallback(async () => {
try { try {
const subs = await api.getPushSubscriptions(); const subs = await api.getPushSubscriptions();
const reg = await navigator.serviceWorker.ready; const reg = await withTimeout(
navigator.serviceWorker.ready,
10_000,
'Service worker activation'
);
const sub = await reg.pushManager.getSubscription(); const sub = await reg.pushManager.getSubscription();
reconcileCurrentSubscription(subs, sub?.endpoint ?? null); reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
return subs; return subs;
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
vapidKeyRef.current = resp.public_key; vapidKeyRef.current = resp.public_key;
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key); const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
const reg = await navigator.serviceWorker.ready; const reg = await withTimeout(
navigator.serviceWorker.ready,
3_000,
'Service worker activation'
);
let pushSub = await reg.pushManager.getSubscription(); let pushSub = await reg.pushManager.getSubscription();
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey); const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
const requiresRecreate = const requiresRecreate =
@@ -188,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
console.error('Push subscribe failed:', err); console.error('Push subscribe failed:', err);
toast.error('Failed to enable push notifications', { toast.error('Failed to enable push notifications', {
description: err instanceof Error ? err.message : 'Check that notifications are allowed', description: err instanceof Error ? err.message : 'Check that notifications are allowed',
duration: 8_000,
}); });
return null; return null;
} finally { } finally {
+3 -1
View File
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
// Register service worker for Web Push (requires secure context) // Register service worker for Web Push (requires secure context)
if ('serviceWorker' in navigator && window.isSecureContext) { if ('serviceWorker' in navigator && window.isSecureContext) {
navigator.serviceWorker.register('./sw.js').catch(() => {}); navigator.serviceWorker.register('./sw.js').catch((err) => {
console.warn('Service worker registration failed:', err);
});
} }
+4 -1
View File
@@ -6,7 +6,10 @@
padding: 0; padding: 0;
} }
html, html {
height: 100dvh;
}
body, body,
#root { #root {
height: 100%; height: 100%;
+3 -2
View File
@@ -149,11 +149,12 @@ vi.mock('../components/SettingsModal', () => ({
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => ( SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div> <div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
), ),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
SETTINGS_SECTION_LABELS: { SETTINGS_SECTION_LABELS: {
radio: '📻 Radio', radio: '📻 Radio',
local: '🖥️ Local Configuration', local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging', 'radio-app': '🗄️ Radio-App Management',
database: '🗄️ Database',
bot: '🤖 Bot', bot: '🤖 Bot',
}, },
})); }));
+3 -2
View File
@@ -92,11 +92,12 @@ vi.mock('../components/SettingsModal', () => ({
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => ( SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div> <div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
), ),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'],
SETTINGS_SECTION_LABELS: { SETTINGS_SECTION_LABELS: {
radio: 'Radio', radio: 'Radio',
local: 'Local Configuration', local: 'Local Configuration',
database: 'Database & Messaging', 'radio-app': 'Radio-App Management',
database: 'Database',
bot: 'Bot', bot: 'Bot',
}, },
})); }));
+13 -1
View File
@@ -4,14 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ContactInfoPane } from '../components/ContactInfoPane'; import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactAnalytics } from '../types'; import type { Contact, ContactAnalytics } from '../types';
const { getContactAnalytics } = vi.hoisted(() => ({ const { getContactAnalytics, contactTelemetryHistory } = vi.hoisted(() => ({
getContactAnalytics: vi.fn(), getContactAnalytics: vi.fn(),
contactTelemetryHistory: vi.fn(),
})); }));
vi.mock('../api', () => ({ vi.mock('../api', () => ({
api: { api: {
getContactAnalytics, getContactAnalytics,
contactTelemetryHistory,
}, },
isAbortError: () => false,
})); }));
vi.mock('../components/ui/sheet', () => ({ vi.mock('../components/ui/sheet', () => ({
@@ -26,6 +29,13 @@ vi.mock('../components/ContactAvatar', () => ({
ContactAvatar: () => <div data-testid="contact-avatar" />, ContactAvatar: () => <div data-testid="contact-avatar" />,
})); }));
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
vi.mock('../components/ui/sonner', () => ({ vi.mock('../components/ui/sonner', () => ({
toast: { toast: {
error: vi.fn(), error: vi.fn(),
@@ -99,6 +109,8 @@ const baseProps = {
describe('ContactInfoPane', () => { describe('ContactInfoPane', () => {
beforeEach(() => { beforeEach(() => {
getContactAnalytics.mockReset(); getContactAnalytics.mockReset();
contactTelemetryHistory.mockReset();
contactTelemetryHistory.mockResolvedValue([]);
baseProps.onSearchMessagesByKey = vi.fn(); baseProps.onSearchMessagesByKey = vi.fn();
baseProps.onSearchMessagesByName = vi.fn(); baseProps.onSearchMessagesByName = vi.fn();
}); });
+4
View File
@@ -109,8 +109,10 @@ beforeEach(() => {
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: [], tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false, auto_resend_channel: false,
telemetry_interval_hours: 8, telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
}); });
mockedApi.getRadioConfig.mockResolvedValue({ mockedApi.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32), public_key: 'aa'.repeat(32),
@@ -1048,8 +1050,10 @@ describe('SettingsFanoutSection', () => {
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: ['cc'.repeat(32)], tracked_telemetry_repeaters: ['cc'.repeat(32)],
tracked_telemetry_contacts: [],
auto_resend_channel: false, auto_resend_channel: false,
telemetry_interval_hours: 8, telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
}); });
renderSection(); renderSection();
+1 -1
View File
@@ -51,7 +51,7 @@ describe('MessageInput', () => {
} }
function getInput() { function getInput() {
return screen.getByPlaceholderText('Type a message...') as HTMLInputElement; return screen.getByPlaceholderText('Type a message...') as HTMLTextAreaElement;
} }
function getSendButton() { function getSendButton() {
+1 -1
View File
@@ -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();
}); });
@@ -39,6 +39,19 @@ const BOT_PACKET: RawPacket = {
decrypted_info: null, decrypted_info: null,
}; };
// TransportFlood ACK: header 0C (route=0 TransportFlood, type=3 ACK, ver=0),
// transport codes 3412 7856 (LE: 0x1234, 0x5678), path_len 00, ACK checksum AABBCCDD
const SCOPED_PACKET: RawPacket = {
id: 2,
timestamp: 1_700_000_000,
data: '0C3412785600AABBCCDD',
decrypted: false,
payload_type: 'Ack',
rssi: -80,
snr: 3.0,
decrypted_info: null,
};
describe('RawPacketDetailModal', () => { describe('RawPacketDetailModal', () => {
it('copies the full packet hex to the clipboard', async () => { it('copies the full packet hex to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined); const writeText = vi.fn().mockResolvedValue(undefined);
@@ -77,4 +90,18 @@ describe('RawPacketDetailModal', () => {
fireEvent.mouseLeave(pathFieldBox as HTMLElement); fireEvent.mouseLeave(pathFieldBox as HTMLElement);
expect(pathRun.className).toBe(idleClassName); expect(pathRun.className).toBe(idleClassName);
}); });
it('shows scope card with transport codes for scoped packets', () => {
render(<RawPacketDetailModal packet={SCOPED_PACKET} channels={[]} onClose={vi.fn()} />);
expect(screen.getByText('Scope')).toBeInTheDocument();
expect(screen.getByText('Regional')).toBeInTheDocument();
expect(screen.getByText('0x1234, 0x5678')).toBeInTheDocument();
});
it('does not show scope card for non-transport packets', () => {
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
expect(screen.queryByText('Scope')).not.toBeInTheDocument();
});
}); });
+8 -2
View File
@@ -94,6 +94,8 @@ describe('buildRawPacketStatsSnapshot', () => {
sender: 'Alpha', sender: 'Alpha',
channel_key: null, channel_key: null,
contact_key: '0a'.repeat(32), contact_key: '0a'.repeat(32),
sender_timestamp: null,
message: null,
}, },
}; };
@@ -145,7 +147,9 @@ describe('buildRawPacketStatsSnapshot', () => {
'2-5', '2-5',
'6-10', '6-10',
'11-15', '11-15',
'16+', '16-20',
'21-31',
'32+',
]); ]);
expect(stats.hopProfile).toEqual( expect(stats.hopProfile).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -154,7 +158,9 @@ describe('buildRawPacketStatsSnapshot', () => {
expect.objectContaining({ label: '2-5', count: 1 }), expect.objectContaining({ label: '2-5', count: 1 }),
expect.objectContaining({ label: '6-10', count: 0 }), expect.objectContaining({ label: '6-10', count: 0 }),
expect.objectContaining({ label: '11-15', count: 0 }), expect.objectContaining({ label: '11-15', count: 0 }),
expect.objectContaining({ label: '16+', count: 0 }), expect.objectContaining({ label: '16-20', count: 0 }),
expect.objectContaining({ label: '21-31', count: 0 }),
expect.objectContaining({ label: '32+', count: 0 }),
]) ])
); );
expect(stats.hopByteWidthProfile).toEqual( expect(stats.hopByteWidthProfile).toEqual(
@@ -438,6 +438,7 @@ describe('RepeaterDashboard', () => {
flood_dups: 1, flood_dups: 1,
direct_dups: 0, direct_dups: 0,
full_events: 0, full_events: 0,
recv_errors: 5,
telemetry_history: [], telemetry_history: [],
}; };
@@ -707,6 +708,7 @@ describe('RepeaterDashboard', () => {
flood_dups: 1, flood_dups: 1,
direct_dups: 0, direct_dups: 0,
full_events: 0, full_events: 0,
recv_errors: null,
telemetry_history: [liveEntry], telemetry_history: [liveEntry],
}; };
@@ -742,6 +744,7 @@ describe('RepeaterDashboard', () => {
flood_dups: 1, flood_dups: 1,
direct_dups: 0, direct_dups: 0,
full_events: 0, full_events: 0,
recv_errors: null,
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }], telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
}; };
+2 -1
View File
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { RoomServerPanel } from '../components/RoomServerPanel'; import { RoomServerPanel, resetRoomCacheForTests } from '../components/RoomServerPanel';
import type { Contact } from '../types'; import type { Contact } from '../types';
vi.mock('../api', () => ({ vi.mock('../api', () => ({
@@ -50,6 +50,7 @@ describe('RoomServerPanel', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
resetRoomCacheForTests();
}); });
it('keeps room controls available when login is not confirmed', async () => { it('keeps room controls available when login is not confirmed', async () => {
+74 -3
View File
@@ -5,6 +5,7 @@ import { SettingsModal } from '../components/SettingsModal';
import type { import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
Contact,
HealthStatus, HealthStatus,
RadioAdvertMode, RadioAdvertMode,
RadioConfig, RadioConfig,
@@ -69,8 +70,10 @@ const baseSettings: AppSettings = {
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: [], tracked_telemetry_repeaters: [],
tracked_telemetry_contacts: [],
auto_resend_channel: false, auto_resend_channel: false,
telemetry_interval_hours: 8, telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
}; };
function renderModal(overrides?: { function renderModal(overrides?: {
@@ -89,6 +92,8 @@ function renderModal(overrides?: {
meshDiscovery?: RadioDiscoveryResponse | null; meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null; meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>; onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
contacts?: Contact[];
trackedTelemetryRepeaters?: string[];
open?: boolean; open?: boolean;
pageMode?: boolean; pageMode?: boolean;
externalSidebarNav?: boolean; externalSidebarNav?: boolean;
@@ -127,6 +132,8 @@ function renderModal(overrides?: {
onDiscoverMesh, onDiscoverMesh,
onHealthRefresh: vi.fn(async () => {}), onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings, onRefreshAppSettings,
contacts: overrides?.contacts,
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
}; };
const view = overrides?.externalSidebarNav const view = overrides?.externalSidebarNav
@@ -171,7 +178,7 @@ function setMatchMedia(matches: boolean) {
} }
function openRadioSection() { function openRadioSection() {
const radioToggle = screen.getByRole('button', { name: /Radio/i }); const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
fireEvent.click(radioToggle); fireEvent.click(radioToggle);
} }
@@ -244,7 +251,7 @@ describe('SettingsModal', () => {
it('shows radio-unavailable message when config is null', () => { it('shows radio-unavailable message when config is null', () => {
renderModal({ config: null }); renderModal({ config: null });
const radioToggle = screen.getByRole('button', { name: /Radio/i }); const radioToggle = screen.getByRole('button', { name: /^Radio$/i });
expect(radioToggle).not.toBeDisabled(); expect(radioToggle).not.toBeDisabled();
fireEvent.click(radioToggle); fireEvent.click(radioToggle);
@@ -493,7 +500,7 @@ describe('SettingsModal', () => {
renderModal({ renderModal({
externalSidebarNav: true, externalSidebarNav: true,
desktopSection: 'database', desktopSection: 'radio-app',
onSaveAppSettings, onSaveAppSettings,
}); });
@@ -794,4 +801,68 @@ describe('SettingsModal', () => {
expect(screen.getByText('Network')).toBeInTheDocument(); expect(screen.getByText('Network')).toBeInTheDocument();
}); });
}); });
it('renders routed hourly checkbox and calls save on toggle', async () => {
const onSaveAppSettings = vi.fn(async () => {});
renderModal({
externalSidebarNav: true,
desktopSection: 'radio-app',
onSaveAppSettings,
});
const checkbox = screen.getByRole('checkbox', {
name: /Poll direct\/routed-path repeaters hourly/i,
}) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({ telemetry_routed_hourly: true })
);
});
});
it('shows route badge per tracked repeater', async () => {
const directKey = 'bb'.repeat(32);
renderModal({
externalSidebarNav: true,
desktopSection: 'radio-app',
appSettings: {
...baseSettings,
tracked_telemetry_repeaters: [directKey],
},
trackedTelemetryRepeaters: [directKey],
contacts: [
{
public_key: directKey,
name: 'DirectRepeater',
type: 2,
flags: 0,
direct_path: 'aabb',
direct_path_len: 1,
direct_path_hash_mode: 1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
effective_route_source: 'direct',
},
],
});
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
expect(screen.getByText('direct')).toBeInTheDocument();
});
}); });
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getTextReplaceEnabled,
setTextReplaceEnabled,
getTextReplaceMapJson,
setTextReplaceMapJson,
applyTextReplacements,
DEFAULT_MAP_JSON,
} from '../utils/textReplace';
beforeEach(() => {
localStorage.clear();
});
describe('enabled toggle', () => {
it('defaults to disabled', () => {
expect(getTextReplaceEnabled()).toBe(false);
});
it('persists enabled state', () => {
setTextReplaceEnabled(true);
expect(getTextReplaceEnabled()).toBe(true);
setTextReplaceEnabled(false);
expect(getTextReplaceEnabled()).toBe(false);
});
});
describe('map JSON persistence', () => {
it('returns default map when nothing stored', () => {
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
});
it('persists valid JSON and returns null', () => {
const json = '{"a":"b"}';
expect(setTextReplaceMapJson(json)).toBeNull();
expect(getTextReplaceMapJson()).toBe(json);
});
it('rejects invalid JSON with error string', () => {
const err = setTextReplaceMapJson('not json');
expect(err).toBeTypeOf('string');
// localStorage unchanged — still returns default
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
});
it('rejects arrays', () => {
expect(setTextReplaceMapJson('["a","b"]')).toBeTypeOf('string');
});
it('rejects non-string values', () => {
expect(setTextReplaceMapJson('{"a":123}')).toBeTypeOf('string');
});
it('rejects null', () => {
expect(setTextReplaceMapJson('null')).toBeTypeOf('string');
});
it('accepts empty object', () => {
expect(setTextReplaceMapJson('{}')).toBeNull();
});
});
describe('re-expansion validation', () => {
it('rejects when a key appears in its own replacement', () => {
const err = setTextReplaceMapJson(JSON.stringify({ a: 'aa' }));
expect(err).toBeTypeOf('string');
expect(err).toContain('"a"');
expect(err).toContain('"aa"');
});
it('rejects when a key appears in another replacement', () => {
const err = setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'ab' }));
expect(err).toBeTypeOf('string');
expect(err).toContain('"a"');
expect(err).toContain('"ab"');
});
it('allows replacements that do not contain any key', () => {
expect(setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'Y' }))).toBeNull();
});
it('allows the default Cyrillic map', () => {
expect(setTextReplaceMapJson(DEFAULT_MAP_JSON)).toBeNull();
});
it('does not check empty keys for re-expansion', () => {
// Empty key is silently skipped by buildReplacements, so it should not
// cause a re-expansion rejection for other entries.
expect(setTextReplaceMapJson(JSON.stringify({ '': 'x', b: 'Y' }))).toBeNull();
});
});
describe('applyTextReplacements', () => {
const simpleMap = JSON.stringify({ a: 'X', b: 'Y' });
it('returns null when no replacements match', () => {
expect(applyTextReplacements('hello', 5, simpleMap)).toBeNull();
});
it('returns null for empty map', () => {
expect(applyTextReplacements('abc', 3, '{}')).toBeNull();
});
it('returns null for invalid JSON', () => {
expect(applyTextReplacements('abc', 3, 'broken')).toBeNull();
});
it('replaces a single character with cursor at end', () => {
const result = applyTextReplacements('a', 1, simpleMap);
expect(result).toEqual({ text: 'X', cursor: 1 });
});
it('replaces multiple characters in one pass', () => {
const result = applyTextReplacements('ab', 2, simpleMap);
expect(result).toEqual({ text: 'XY', cursor: 2 });
});
it('adjusts cursor when replacement is longer than needle', () => {
const map = JSON.stringify({ ':)': 'smiley' });
// "hello :)" cursor at end (8)
const result = applyTextReplacements('hello :)', 8, map);
expect(result).toEqual({ text: 'hello smiley', cursor: 12 });
});
it('adjusts cursor when replacement is shorter than needle', () => {
const map = JSON.stringify({ abc: 'Z' });
// "abcdef" cursor at end (6)
const result = applyTextReplacements('abcdef', 6, map);
expect(result).toEqual({ text: 'Zdef', cursor: 4 });
});
it('preserves cursor position when replacement is before cursor', () => {
const map = JSON.stringify({ a: 'XX' });
// "a_b" cursor at 2 (on 'b'), 'a' replaced with 'XX'
const result = applyTextReplacements('a_b', 2, map);
expect(result).toEqual({ text: 'XX_b', cursor: 3 });
});
it('does not adjust cursor for replacements after cursor', () => {
const map = JSON.stringify({ b: 'YY' });
// "ab" cursor at 1 (after 'a'), 'b' is after cursor
const result = applyTextReplacements('ab', 1, map);
expect(result).toEqual({ text: 'aYY', cursor: 1 });
});
it('places cursor after replacement when cursor is inside a multi-char match', () => {
const map = JSON.stringify({ abc: 'Z' });
// "abc" cursor at 2 (inside the match)
const result = applyTextReplacements('abc', 2, map);
expect(result).toEqual({ text: 'Z', cursor: 1 });
});
it('handles multiple replacements with cursor tracking', () => {
const map = JSON.stringify({ ':)': 'S' });
// ":):)" cursor at end (4) — two replacements, each shrinks by 1
const result = applyTextReplacements(':):)', 4, map);
expect(result).toEqual({ text: 'SS', cursor: 2 });
});
it('cursor between two replacements stays correct', () => {
const map = JSON.stringify({ ':)': 'S' });
// ":):)" cursor at 2 (between the two smileys)
const result = applyTextReplacements(':):)', 2, map);
expect(result).toEqual({ text: 'SS', cursor: 1 });
});
it('uses longest match first', () => {
const map = JSON.stringify({ ab: 'LONG', a: 'X' });
const result = applyTextReplacements('ab', 2, map);
expect(result).toEqual({ text: 'LONG', cursor: 4 });
});
it('ignores empty-string keys (no infinite loop)', () => {
const map = JSON.stringify({ '': 'oops', a: 'X' });
const result = applyTextReplacements('abc', 3, map);
expect(result).toEqual({ text: 'Xbc', cursor: 3 });
});
it('works with the default Cyrillic map', () => {
// "Привет" — П has no mapping, р→p, и has no mapping, в has no mapping, е→e, т has no mapping
const result = applyTextReplacements('Привет', 6, DEFAULT_MAP_JSON);
expect(result).not.toBeNull();
expect(result!.text).toBe('Пpивeт');
expect(result!.cursor).toBe(6);
});
it('handles paste with many replacements', () => {
const map = JSON.stringify({ А: 'A', В: 'B', С: 'C' });
const result = applyTextReplacements('АВС', 3, map);
expect(result).toEqual({ text: 'ABC', cursor: 3 });
});
});
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
expect(result.current.allSubscriptions).toEqual([]); expect(result.current.allSubscriptions).toEqual([]);
}); });
it('times out and shows a toast when service worker never activates', async () => {
// Replace serviceWorker.ready with a promise that never resolves
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
ready: new Promise(() => {}),
},
});
const { result } = renderHook(() => usePushSubscription());
await waitFor(() => {
expect(result.current.isSupported).toBe(true);
});
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
await act(async () => {
await result.current.subscribe();
});
expect(result.current.loading).toBe(false);
expect(mocks.toast.error).toHaveBeenCalledWith(
'Failed to enable push notifications',
expect.objectContaining({
description: expect.stringContaining('trusted TLS certificate for service workers'),
})
);
}, 5_000);
it('recreates a stale browser subscription when the server VAPID key changed', async () => { it('recreates a stale browser subscription when the server VAPID key changed', async () => {
const oldSubscription = activeSubscription; const oldSubscription = activeSubscription;
mocks.api.getPushSubscriptions mocks.api.getPushSubscriptions
+28
View File
@@ -17,6 +17,9 @@ export interface RadioConfig {
path_hash_mode_supported: boolean; path_hash_mode_supported: boolean;
advert_location_source?: 'off' | 'current'; advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean; multi_acks_enabled?: boolean;
telemetry_mode_base?: number;
telemetry_mode_loc?: number;
telemetry_mode_env?: number;
} }
export interface RadioConfigUpdate { export interface RadioConfigUpdate {
@@ -28,6 +31,9 @@ export interface RadioConfigUpdate {
path_hash_mode?: number; path_hash_mode?: number;
advert_location_source?: 'off' | 'current'; advert_location_source?: 'off' | 'current';
multi_acks_enabled?: boolean; multi_acks_enabled?: boolean;
telemetry_mode_base?: number;
telemetry_mode_loc?: number;
telemetry_mode_env?: number;
} }
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all'; export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
@@ -66,6 +72,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;
@@ -341,6 +349,8 @@ export interface RawPacket {
sender: string | null; sender: string | null;
channel_key: string | null; channel_key: string | null;
contact_key: string | null; contact_key: string | null;
sender_timestamp: number | null;
message: string | null;
} | null; } | null;
} }
@@ -355,8 +365,10 @@ export interface AppSettings {
blocked_names: string[]; blocked_names: string[];
discovery_blocked_types: number[]; discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[]; tracked_telemetry_repeaters: string[];
tracked_telemetry_contacts: string[];
auto_resend_channel: boolean; auto_resend_channel: boolean;
telemetry_interval_hours: number; telemetry_interval_hours: number;
telemetry_routed_hourly: boolean;
} }
export interface AppSettingsUpdate { export interface AppSettingsUpdate {
@@ -369,6 +381,7 @@ export interface AppSettingsUpdate {
blocked_names?: string[]; blocked_names?: string[];
discovery_blocked_types?: number[]; discovery_blocked_types?: number[];
telemetry_interval_hours?: number; telemetry_interval_hours?: number;
telemetry_routed_hourly?: boolean;
} }
export interface TelemetrySchedule { export interface TelemetrySchedule {
@@ -378,6 +391,8 @@ export interface TelemetrySchedule {
tracked_count: number; tracked_count: number;
max_tracked: number; max_tracked: number;
next_run_at: number | null; next_run_at: number | null;
routed_hourly: boolean;
next_routed_run_at: number | null;
} }
export interface TrackedTelemetryResponse { export interface TrackedTelemetryResponse {
@@ -436,6 +451,7 @@ export interface RepeaterStatusResponse {
flood_dups: number; flood_dups: number;
direct_dups: number; direct_dups: number;
full_events: number; full_events: number;
recv_errors: number | null;
telemetry_history: TelemetryHistoryEntry[]; telemetry_history: TelemetryHistoryEntry[];
} }
@@ -483,6 +499,18 @@ export interface RepeaterLppTelemetryResponse {
sensors: LppSensor[]; sensors: LppSensor[];
} }
export interface ContactTelemetryResponse {
sensors: LppSensor[];
fetched_at: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface TrackedTelemetryContactsResponse {
tracked_telemetry_contacts: string[];
names: Record<string, string>;
schedule: TelemetrySchedule;
}
export type PaneName = export type PaneName =
| 'status' | 'status'
| 'nodeInfo' | 'nodeInfo'
+7 -4
View File
@@ -11,16 +11,19 @@ export const RADIO_PRESETS: RadioPreset[] = [
{ name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, { name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
{ name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 }, { name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 },
{ name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 }, { name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 },
{ name: 'Australia (Mid)', freq: 915.075, bw: 125, sf: 9, cr: 5 },
{ name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 }, { name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 }, { name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 },
{ name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 }, { name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 },
{ name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 }, { name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU/UK/Switzerland Long Range', freq: 869.525, bw: 250, sf: 11, cr: 5 }, { name: 'EU/UK (Narrow)', freq: 869.618, bw: 62.5, sf: 8, cr: 8 },
{ name: 'EU/UK/Switzerland Medium Range', freq: 869.525, bw: 250, sf: 10, cr: 5 }, { name: 'EU/UK (Deprecated)', freq: 869.525, bw: 250, sf: 11, cr: 5 },
{ name: 'EU/UK/Switzerland Narrow', freq: 869.618, bw: 62.5, sf: 8, cr: 8 }, { name: 'Switzerland', freq: 869.618, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 }, { name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 }, { name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 },
{ name: 'EU 433MHz (Narrow)', freq: 433.65, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 }, { name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 },
{ name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 }, { name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 },
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 }, { name: 'Vietnam (Narrow)', freq: 920.25, bw: 62.5, sf: 8, cr: 5 },
{ name: 'Vietnam (Deprecated)', freq: 920.25, bw: 250, sf: 11, cr: 5 },
]; ];
+50 -45
View File
@@ -324,51 +324,56 @@ export function inspectRawPacketWithOptions(
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte) createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
); );
const enrichedPayloadFields = const enrichedPayloadFields = payloadFields.map((field) => {
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded if (!decoded?.isValid || field.name !== 'Ciphertext') {
? payloadFields.map((field) => { return field;
if (field.name !== 'Ciphertext') { }
return field;
} const withStructure = {
const payload = decoded.payload.decoded as { ...field,
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string }; description: describeCiphertextStructure(
}; decoded.payloadType,
if (!payload.decrypted?.message) { field.endByte - field.startByte + 1,
return field; field.description
} ),
const detailLines = [ };
payload.decrypted.timestamp != null
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}` // GroupText: client-side decoder has the decrypted content
: null, if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
payload.decrypted.flags != null const payload = decoded.payload.decoded as {
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}` decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
: null, };
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null, if (!payload.decrypted?.message) {
`Message: ${payload.decrypted.message}`, return withStructure;
].filter((line): line is string => line !== null); }
return { const detailLines = [
...field, payload.decrypted.timestamp != null
description: describeCiphertextStructure( ? `Sent (packet): ${formatUnixTimestamp(payload.decrypted.timestamp)}`
decoded.payloadType, : null,
field.endByte - field.startByte + 1, payload.decrypted.flags != null
field.description ? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
), : null,
decryptedMessage: detailLines.join('\n'), payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
}; `Message: ${payload.decrypted.message}`,
}) ].filter((line): line is string => line !== null);
: payloadFields.map((field) => { return { ...withStructure, decryptedMessage: detailLines.join('\n') };
if (!decoded?.isValid || field.name !== 'Ciphertext') { }
return field;
} // TextMessage (DM): server-side decryption via decrypted_info
return { if (decoded.payloadType === PayloadType.TextMessage && packet.decrypted_info?.message) {
...field, const info = packet.decrypted_info;
description: describeCiphertextStructure( const detailLines = [
decoded.payloadType, info.sender_timestamp != null
field.endByte - field.startByte + 1, ? `Sent (packet): ${formatUnixTimestamp(info.sender_timestamp)}`
field.description : null,
), info.sender ? `Sender: ${info.sender}` : null,
}; `Message: ${info.message}`,
}); ].filter((line): line is string => line !== null);
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
}
return withStructure;
});
return { return {
decoded, decoded,
+10 -2
View File
@@ -322,7 +322,13 @@ function getHopProfileBucket(pathTokenCount: number): string {
if (pathTokenCount <= 15) { if (pathTokenCount <= 15) {
return '11-15'; return '11-15';
} }
return '16+'; if (pathTokenCount <= 20) {
return '16-20';
}
if (pathTokenCount <= 31) {
return '21-31';
}
return '32+';
} }
export function buildRawPacketStatsSnapshot( export function buildRawPacketStatsSnapshot(
@@ -354,7 +360,9 @@ export function buildRawPacketStatsSnapshot(
['2-5', 0], ['2-5', 0],
['6-10', 0], ['6-10', 0],
['11-15', 0], ['11-15', 0],
['16+', 0], ['16-20', 0],
['21-31', 0],
['32+', 0],
]); ]);
const hopByteWidthCounts = new Map<string, number>([ const hopByteWidthCounts = new Map<string, number>([
['No path', 0], ['No path', 0],
+142
View File
@@ -0,0 +1,142 @@
const ENABLED_KEY = 'remoteterm-text-replace-enabled';
const MAP_KEY = 'remoteterm-text-replace-map';
const DEFAULT_MAP: Record<string, string> = {
А: 'A',
В: 'B',
Е: 'E',
Ё: 'E',
З: '3',
К: 'K',
М: 'M',
Н: 'H',
О: 'O',
Р: 'P',
С: 'C',
Т: 'T',
Х: 'X',
Ь: 'b',
а: 'a',
е: 'e',
ё: 'e',
о: 'o',
р: 'p',
с: 'c',
у: 'y',
х: 'x',
};
export const DEFAULT_MAP_JSON = JSON.stringify(DEFAULT_MAP, null, 2);
export function getTextReplaceEnabled(): boolean {
try {
return localStorage.getItem(ENABLED_KEY) === 'true';
} catch {
return false;
}
}
export function setTextReplaceEnabled(enabled: boolean): void {
try {
if (enabled) {
localStorage.setItem(ENABLED_KEY, 'true');
} else {
localStorage.removeItem(ENABLED_KEY);
}
} catch {
// localStorage may be unavailable
}
}
export function getTextReplaceMapJson(): string {
try {
const raw = localStorage.getItem(MAP_KEY);
if (raw !== null) return raw;
} catch {
// fall through
}
return DEFAULT_MAP_JSON;
}
/** Persist the map JSON only if it's valid. Returns null on success or an error string. */
export function setTextReplaceMapJson(json: string): string | null {
try {
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
return 'Must be a JSON object.';
const rawEntries = Object.entries(parsed);
for (const [k, v] of rawEntries) {
if (typeof k !== 'string' || typeof v !== 'string')
return 'All keys and values must be strings.';
}
const entries = rawEntries as [string, string][];
// Check for re-expansion: no key may appear as a substring of any replacement value.
for (const [needle] of entries) {
if (needle.length === 0) continue;
for (const [, replacement] of entries) {
if (replacement.includes(needle)) {
return `Key "${needle}" appears inside replacement "${replacement}" and would re-expand on every keystroke.`;
}
}
}
localStorage.setItem(MAP_KEY, json);
return null;
} catch {
return 'Invalid JSON.';
}
}
/** Build a sorted-by-length-desc array of [needle, replacement] for efficient matching. */
function buildReplacements(json: string): [string, string][] {
try {
const parsed = JSON.parse(json) as Record<string, string>;
return Object.entries(parsed)
.filter(([k]) => k.length > 0)
.sort((a, b) => b[0].length - a[0].length);
} catch {
return [];
}
}
/**
* Apply text replacements and compute the adjusted cursor position.
* Returns null if nothing changed.
*/
export function applyTextReplacements(
text: string,
cursorPos: number,
mapJson: string
): { text: string; cursor: number } | null {
const replacements = buildReplacements(mapJson);
if (replacements.length === 0) return null;
let result = '';
let newCursor = cursorPos;
let i = 0;
while (i < text.length) {
let matched = false;
for (const [needle, replacement] of replacements) {
if (text.startsWith(needle, i)) {
result += replacement;
// Adjust cursor if this match is before or spans the cursor
if (i + needle.length <= cursorPos) {
newCursor += replacement.length - needle.length;
} else if (i < cursorPos) {
// Cursor is inside this match — place it after the replacement
newCursor = result.length;
}
i += needle.length;
matched = true;
break;
}
}
if (!matched) {
result += text[i];
i++;
}
}
if (result === text) return null;
return { text: result, cursor: newCursor };
}
+1
View File
@@ -16,6 +16,7 @@ interface ParsedHashConversation {
const SETTINGS_SECTIONS: SettingsSection[] = [ const SETTINGS_SECTIONS: SettingsSection[] = [
'radio', 'radio',
'local', 'local',
'radio-app',
'fanout', 'fanout',
'database', 'database',
'statistics', 'statistics',
-1
View File
@@ -15,7 +15,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
+8
View File
@@ -22,6 +22,14 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },
'/docs': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/openapi.json': {
target: 'http://localhost:8000',
changeOrigin: true,
},
}, },
watch: { watch: {
usePolling: true, usePolling: true,
+4
View File
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "remoteterm-meshcore" name = "remoteterm-meshcore"
version = "3.12.0" version = "3.14.0"
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"
@@ -12,7 +12,7 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"pycryptodome>=3.20.0", "pycryptodome>=3.20.0",
"pynacl>=1.5.0", "pynacl>=1.5.0",
"meshcore==2.3.2", "meshcore==2.3.7",
"aiomqtt>=2.0", "aiomqtt>=2.0",
"apprise>=1.9.8", "apprise>=1.9.8",
"boto3>=1.38.0", "boto3>=1.38.0",
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"pip-licenses>=5.0.0", "pip-licenses>=5.0.0",
"pytest>=9.0.2", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pytest-xdist>=3.0", "pytest-xdist>=3.0",
"ruff>=0.8.0", "ruff>=0.8.0",

Some files were not shown because too many files have changed in this diff Show More