Compare commits

...

71 Commits

Author SHA1 Message Date
Jack Kingsman 53a4d8186a Updating changelog + build for 3.11.0 2026-04-10 16:12:27 -07:00
Jack Kingsman 70e1669113 Improve test coverage 2026-04-10 16:04:02 -07:00
Jack Kingsman 3b1a292507 Docs updates and be consistent about node >=20 2026-04-10 15:57:47 -07:00
Jack Kingsman 4f19e1ec9a Fix races and stale things 2026-04-10 15:54:03 -07:00
Jack Kingsman 59601bb98e Assume that a same-second same-message same-first-byte-key DM is more likely an echo than them sending the same message, and multi-retry for flood scope restoration 2026-04-10 15:50:45 -07:00
Jack Kingsman f6b0fd21fb Don't consume DM resend attempt on busy radio 2026-04-10 15:46:19 -07:00
Jack Kingsman 8a4858a313 Don't consume DM resend attempt on busy radio 2026-04-10 15:44:50 -07:00
Jack Kingsman 442c2fad20 Fix some frontend display/quality/doc issues 2026-04-10 15:43:08 -07:00
Jack Kingsman 8cc542ce23 Fix same-second same-message collision in room servers with per-sender disambiguation at DB level 2026-04-10 15:36:53 -07:00
Jack Kingsman a7258c120e Merge pull request #177 from YourSandwich/feature/battery-status
Add optional battery display to status bar
2026-04-10 14:55:39 -07:00
Jack Kingsman 8752320f52 Add some tests and move the helpers into their own TS file 2026-04-10 14:53:57 -07:00
Jack Kingsman f9f046a05f Fix inversion of const definition location 2026-04-10 14:51:19 -07:00
Jack Kingsman 390c0624ea IIFE => memo for battery color/styling conversion 2026-04-10 14:49:05 -07:00
YourSandwich 2f55d11b0b Add battery display toggles to Local Configuration 2026-04-10 23:38:29 +02:00
YourSandwich fa0be24990 Add battery indicator to status bar 2026-04-10 23:38:29 +02:00
Jack Kingsman 1e22a21445 Add radio health &c. to fanout bus 2026-04-10 14:31:45 -07:00
YourSandwich e09a3a01f7 Add localStorage helpers for battery display settings 2026-04-10 22:25:17 +02:00
Jack Kingsman 3bd756ee4e Pluck in HA radio stats into the WS fanout endpoint 2026-04-10 12:39:37 -07:00
Jack Kingsman 43c5e0f67d Improve e2e testing posture to make it sliiiightly less unfriendly for others to get working 2026-04-10 11:36:26 -07:00
Jack Kingsman c0fc5fbba2 Add AUR download and test script 2026-04-10 11:30:05 -07:00
Jack Kingsman c7248222dd Updating changelog + build for 3.10.0 2026-04-10 11:16:16 -07:00
Jack Kingsman 1e18a91f12 Merge pull request #172 from YourSandwich/aur-install-instructions
Add Arch Linux (AUR) packaging infrastructure
2026-04-10 10:54:49 -07:00
Jack Kingsman 18db6e4dd8 Make test script executable 2026-04-10 10:49:49 -07:00
Jack Kingsman 2393dadf1b Unload the service on uninstall 2026-04-10 10:48:38 -07:00
Jack Kingsman fd26576e0d Use correct email 2026-04-10 10:47:21 -07:00
Sandwich cb5a76eb5f Replace manual user/group creation with sysusers.d and tmpfiles.d 2026-04-10 19:23:01 +02:00
Jack Kingsman 7f5dde119f Update AGENTS.md 2026-04-10 00:15:57 -07:00
Jack Kingsman 799a721761 Be more defensive about systemd detection 2026-04-10 00:10:53 -07:00
Jack Kingsman 152a584f35 Fix TCP host 2026-04-10 00:10:41 -07:00
Jack Kingsman 5cc0476426 Fix port numbering 2026-04-10 00:06:22 -07:00
Jack Kingsman e468c6c161 Change command palette shortcut 2026-04-09 23:45:16 -07:00
Jack Kingsman e33537018b Fix AUR username 2026-04-09 23:11:02 -07:00
Jack Kingsman 0727793560 Add test script 2026-04-09 23:08:32 -07:00
Jack Kingsman 5c4e04e024 Skip daemon reload if systemctl isn't around 2026-04-09 23:08:26 -07:00
Jack Kingsman 967269ef7d Initial AUR work 2026-04-09 23:08:22 -07:00
Jack Kingsman 1903797d0d Fix broken statistics pane e2e test 2026-04-09 22:30:12 -07:00
Jack Kingsman bb5af5ba82 Bump apprise to 1.9.9. Closes #173. 2026-04-09 17:20:57 -07:00
Sandwich 424da7e232 Add Arch Linux (AUR) install instructions to README
Adds "Install Path 3: Arch Linux (AUR)" section covering both AUR
helper and manual makepkg installation, linking to the published
remoteterm-meshcore AUR package.

Closes #171
2026-04-09 03:51:39 +02:00
Jack Kingsman 159df1ec5b Revert "Add debug lines for fav click"
This reverts commit 8e2e039985.
2026-04-08 16:33:44 -07:00
Jack Kingsman 8e2e039985 Add debug lines for fav click 2026-04-08 16:18:46 -07:00
Jack Kingsman 01c86a486e Add packet feed filters; closes #169. 2026-04-08 14:44:41 -07:00
Jack Kingsman 7d5cfdec26 Add note about startup on windows 2026-04-07 22:07:31 -07:00
Jack Kingsman 5fe0ac0ad4 Be more memory concious on recent contact fetch 2026-04-07 16:41:34 -07:00
Jack Kingsman b98102ccac Add 72hr packet density view 2026-04-07 16:26:01 -07:00
Jack Kingsman a02c3cae9e Updating changelog + build for 3.9.0 2026-04-06 22:10:06 -07:00
Jack Kingsman ca7349a1a8 Add autofocus to text boxes 2026-04-06 21:59:46 -07:00
Jack Kingsman eeaa11b8b0 Fix lint bugs 2026-04-06 20:36:47 -07:00
Jack Kingsman 08eaf090b2 Be more guarded in the radio validity checks (and get outta here, you random repeaters I never favorited!) 2026-04-06 20:34:16 -07:00
Jack Kingsman 2f43420235 Add command palette 2026-04-06 20:27:55 -07:00
Jack Kingsman af74663518 Add guard for favorites sync 2026-04-06 20:12:58 -07:00
Jack Kingsman b7981c0450 Getting all Cal Raleigh up in here 2026-04-06 19:09:48 -07:00
Jack Kingsman 0f4976b9ee Merge pull request #167 from jkingsman/migrate-favorites
Add favorites as contact field (dug)
2026-04-05 22:19:01 -07:00
Jack Kingsman 1991f2515b Support relative URLs. Closes #165. 2026-04-05 22:11:12 -07:00
Jack Kingsman a351c86ccb Add favorites as contact field (dug) 2026-04-05 20:50:27 -07:00
Jack Kingsman c2e1a3cbe6 Import radio favorites as favorites 2026-04-05 18:15:04 -07:00
jkingsman c2d1339256 Default stale node pruning for visualizer to ON 2026-04-05 15:55:47 -07:00
jkingsman cb7139a7e1 Always offer basic auth, move docker-not-found warning to the top 2026-04-05 15:41:02 -07:00
Jack Kingsman 6332387704 Define a better y domain for repeater battery voltage 2026-04-05 12:45:52 -07:00
Jack Kingsman 3f2b8e2a1f Refocus CLI textbox after command completion. Closes #164. 2026-04-05 11:55:52 -07:00
Jack Kingsman 40c37745b6 Massage the Readme a bit more 2026-04-05 11:55:31 -07:00
Jack Kingsman 9edac47aa2 Add clearer warning about RemoteTerm taking over the radio and governing contacts/channels loading. Closes #163. 2026-04-05 11:49:57 -07:00
Jack Kingsman 44f8aafb66 Retain recent traces and make them click-to-trace. Closes #160. 2026-04-04 16:43:12 -07:00
Jack Kingsman 9e3805f5d0 Use receipt time not sender time for display 2026-04-04 16:24:36 -07:00
Jack Kingsman 457799d8df Calm down clock skew loggings 2026-04-04 15:31:30 -07:00
Jack Kingsman de3ad2d51f Calm it down on sync logs 2026-04-04 15:10:45 -07:00
Jack Kingsman ad83bc7979 Show telemetry inline 2026-04-04 14:29:31 -07:00
Jack Kingsman 9ebf63491c Have tests use prod regexes 2026-04-04 13:13:37 -07:00
Jack Kingsman b19585db6d Go crazy style on systemd escaping. Closes #159. 2026-04-04 12:24:36 -07:00
Jack Kingsman c28d22379e Be a little gentler; call it a room finder rather than a cracker 2026-04-04 12:06:28 -07:00
Jack Kingsman 1e5ccf6c29 Add clearer issue identification for missing HTTPS context for channel finder 2026-04-04 12:03:07 -07:00
Jack Kingsman 81f5bde287 Add hop counts to width selection 2026-04-03 22:06:00 -07:00
157 changed files with 5507 additions and 1117 deletions
+73
View File
@@ -0,0 +1,73 @@
name: Publish AUR package
# Pushes the contents of pkg/aur/ to the remoteterm-meshcore AUR repository
# whenever a GitHub release is published. Can also be triggered manually for
# testing or out-of-band republishes.
#
# Required secrets:
# AUR_SSH_PRIVATE_KEY Private SSH key registered with the AUR maintainer
# account that owns the remoteterm-meshcore package.
# AUR_COMMIT_EMAIL Email used for the AUR git commit identity.
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to publish (no v prefix, e.g. 3.9.1)'
required: true
concurrency:
# Serialize publishes so a fast back-to-back release sequence cannot race
# two pushes against the AUR repo. The later one wins by virtue of being
# the final state.
group: publish-aur
cancel-in-progress: false
jobs:
publish-aur:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve version from event
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${{ github.event.release.tag_name }}"
fi
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing AUR package for version $VERSION"
- name: Stamp pkgver into PKGBUILD
run: |
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" pkg/aur/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" pkg/aur/PKGBUILD
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
with:
pkgname: remoteterm-meshcore
pkgbuild: pkg/aur/PKGBUILD
assets: |
pkg/aur/remoteterm-meshcore.install
pkg/aur/remoteterm-meshcore.service
pkg/aur/remoteterm-meshcore.sysusers
pkg/aur/remoteterm-meshcore.tmpfiles
pkg/aur/remoteterm.env
commit_username: jackkingsman
commit_email: ${{ secrets.AUR_COMMIT_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update to ${{ steps.version.outputs.version }}"
# Recompute sha256sums from the live release tarball + the bundled
# service/env files. The committed PKGBUILD has SKIP placeholders.
updpkgsums: true
# Validate the PKGBUILD parses and sources download, but skip the
# actual build (which would run uv sync + npm install for several
# minutes of CI time on every release).
test: true
test_flags: --clean --cleanbuild --nodeps --nobuild
+5 -3
View File
@@ -209,6 +209,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── pkg/aur/ # AUR package files (PKGBUILD, systemd service, env, install hooks)
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── build/
│ │ ├── collect_licenses.sh # Gather third-party license attributions
@@ -216,7 +217,8 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ ├── quality/
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
│ │ ├── e2e.sh # End-to-end test runner
│ │ ── extended_quality.sh # Quality gate plus e2e and Docker matrix
│ │ ── extended_quality.sh # Quality gate plus e2e and Docker matrix
│ │ └── test_aur_package.sh # Build + install AUR package in Arch Docker containers
│ └── setup/
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
│ └── install_service.sh # Install/configure Linux systemd service
@@ -371,7 +373,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
@@ -478,7 +480,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
**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`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `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`, and `auto_resend_channel`. `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.
+44 -7
View File
@@ -1,3 +1,44 @@
## [3.11.0] - 2026-04-10
* Feature: Radio health and contact data accessible on fanout bus
* Feature: Local node radio stats (voltage etc.) on WS health bus
* Feature: Battery indicator optional in status bar (configured in Local Settings)
* Bugfix: Fix same-second same-message collision in room servers
* Bugfix: Don't consume DM resend attempt if the radio was just busy
* Bugfix: Assume that a same-second same-message same-first-byte-key DM is more likely an echo than them sending the same message
* Bugfix: Multi-retry for flood scope restoration
* Misc: Testing & documentation improvements
## [3.10.0] - 2026-04-10
* Feature: Add Arch AUR package
* Feature: 72hr packet density view in statistics
* Feature: Add warnings for event loop selection for MQTT on Windows startup
* Bugfix: Bump Apprise to 1.9.9 to fix Matrix bug
* Misc: More memory-conscious on recent contact fetch
* Misc: Fix statistics pane e2e test
## [3.9.0] - 2026-04-06
* Feature: Add hop counts to hop-width selection options
* Feature: Show cached repeater telemetry inline in settings
* Feature: Retain recent traces and make them click-to-re-run
* Feature: Autofocus channel/DM textbox on desktop
* Feature: Favorites on the radio are now imported as favorites
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
* Bugfix: Don't use sender timestamp for message sequence display
* Bugfix: Function on subdomains happily
* Misc: Be gentler, room s/cracker/finder/
* Misc: Test and frontend correctness & test fixes
* Misc: Don't repeat clock sync failure logs
* Misc: Make warning in readme clearer about taking over the radio
* Misc: Improve readme phrasings
* Misc: Better y-axis selection for battery read-out
* Misc: Provide clearer warning on docker setup without docker installed
* Misc: Default visualizer stale pruning to on/5 minutes
* Misc: Migrate favorites to better storage pattern
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
## [3.8.0] - 2026-04-03
* Feature: Per-channel hop width override
@@ -115,7 +156,7 @@
* Bugfix: Fix Apprise duplicate names
* Bugfix: Be better about identity resolution in the stats pane
* Misc: Docs, test, and performance enhancements
* Misc: Don't prompt "Are you sure" when leaving an unedited interation
* Misc: Don't prompt "Are you sure" when leaving an unedited integration
* Misc: Log node time on startup
* Misc: Improve community MQTT error bubble-up
* Misc: Unread DMs always have a red unread counter
@@ -142,7 +183,7 @@
## [3.3.0] - 2026-03-13
* Feature: Use dashed lines to show collapsed ambiguous router results
* Feature: Jump to unred
* Feature: Jump to unread
* Feature: Local channel management to prevent need to reload channel every time
* Feature: Debug endpoint
* Feature: Force-singleton channel management
@@ -205,7 +246,7 @@
* Feature: Massive codebase refactor and overhaul
* Bugfix: Fix packet parsing for trace packets
* Bugfix: Refetch channels on reconnect
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
* Bugfix: Load All on repeater pane on mobile doesn't extend into lower text
* Bugfix: Timestamps in logs
* Bugfix: Correct wrong clock sync command
* Misc: Improve bot error bubble up
@@ -222,10 +263,6 @@
* Bugfix: Don't obscure new integration dropdown on session boundary
## [2.7.8] - 2026-03-08
## [2.7.8] - 2026-03-08
* Bugfix: Improve frontend asset resolution and fixup the build/push script
+98 -4
View File
@@ -70,17 +70,111 @@ npm run test:run
npm run build
```
## Quality + Publishing Scripts
<details>
<summary>scripts/quality/</summary>
| Script | Purpose |
|--------|---------|
| `all_quality.sh` | Repo-standard gate: autofix (ruff, eslint, prettier), then pyright, pytest, vitest, and frontend build. Run before finishing any code change. |
| `extended_quality.sh` | `all_quality.sh` plus e2e tests and Docker build matrix. Used for release validation. |
| `e2e.sh` | Thin wrapper that runs Playwright e2e tests from `tests/e2e/`. |
| `docker_ci.sh` | Builds the Docker image and runs a smoke test against it. |
| `test_aur_package.sh` | Builds the AUR package in an Arch container, then installs and boots it in a second container with port 8000 exposed (hang finish). |
| `run_aur_with_radio.sh` | Like `test_aur_package.sh` but passes through the host serial device for testing with a real radio (hang finish). |
</details>
<details>
<summary>scripts/build/</summary>
| Script | Purpose |
|--------|---------|
| `publish.sh` | Full release ceremony: quality gate, version bump, changelog, frontend build, Docker multi-arch push, GitHub release. |
| `release_common.sh` | Shared shell helpers (version validation, formatting) sourced by other build scripts. |
| `package_release_artifact.sh` | Builds the prebuilt-frontend release zip attached to GitHub releases. |
| `push_docker_multiarch.sh` | Builds and pushes multi-arch Docker images (amd64 + arm64). |
| `create_github_release.sh` | Creates a GitHub release with changelog notes and the release artifact. |
| `extract_release_notes.sh` | Extracts the latest version's notes from `CHANGELOG.md` for the release body. |
| `collect_licenses.sh` | Gathers third-party license attributions into `LICENSES.md`. |
| `print_frontend_licenses.cjs` | Helper that extracts frontend npm dependency licenses. |
| `dump_api_specs.py` | Dumps the OpenAPI spec from the running backend (developer utility). |
</details>
## E2E Testing
E2E coverage exists, but it is intentionally not part of the normal development path.
E2E tests exercise the full stack (backend + frontend + real radio hardware) via Playwright.
These tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly, an available autodetect-able radio, and a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work.
> [!WARNING]
> E2E tests are **not part of the normal development path** — most contributors will never need to run them. They exist to catch integration issues that unit tests can't and generally only need to be run by maintainers.
### Hardware requirements
- A MeshCore radio connected via serial (auto-detected, or set `MESHCORE_SERIAL_PORT`)
- The radio must be powered on and past its startup sequence before tests begin
### Running
```bash
cd tests/e2e
npm install
npx playwright test # headless
npx playwright test --headed # you can probably guess
npx playwright install chromium # first time only
npx playwright test # headless
npx playwright test --headed # watch it run
```
The test harness starts its own uvicorn instance on port 8001 with a fresh temporary database. Your development server (port 8000) is unaffected.
### Test tiers
**Most tests (22 of 28) are fully self-contained.** They seed their own data via API calls or direct DB writes and need only a connected radio. These cover messaging, pagination, search, favorites, settings, fanout integrations, historical decryption, and all UI-only views.
**Mesh-traffic tests (tagged `@mesh-traffic`)** wait up to 3 minutes for an incoming message from another node on the network. If no traffic arrives, they fail with an advisory that the failure may be RF conditions, not a bug. These are: `incoming-message` and `packet-feed` (second test only).
**The partner-radio DM ACK test (tagged `@partner-radio`)** validates direct-route learning by sending a DM and waiting for an ACK. It requires a second radio in range that has your test radio in its contacts. Configure the partner node's public key and name via `E2E_PARTNER_RADIO_PUBKEY` and `E2E_PARTNER_RADIO_NAME`.
### Making mesh-traffic tests reliable: the echo bot
The most practical way to guarantee incoming traffic is to run an **echo bot on a second radio** monitoring a known channel. When the test suite starts a `@mesh-traffic` test, it sends a trigger message to that channel. If a bot on another radio is listening, it replies — generating the incoming RF packet the test needs within seconds instead of waiting for organic mesh traffic.
The test suite sends `!echo please give incoming message` to the echo channel (default `#flightless`) at the start of each `@mesh-traffic` test. The trigger message is configurable via `E2E_ECHO_TRIGGER_MESSAGE`.
Setup:
1. Set up a second MeshCore radio within RF range of your test radio
2. Run a RemoteTerm instance on the second radio
3. Configure a bot on the second radio that monitors the echo channel and replies when it sees the trigger. Example bot code:
```python
def bot(sender_name, sender_key, message_text, is_dm,
channel_key, channel_name, sender_timestamp, path):
if "!echo" in message_text.lower():
return f"[ECHO] {message_text}"
return None
```
4. The test suite calls `nudgeEchoBot()` automatically — no manual intervention needed
Without the echo bot, `@mesh-traffic` tests rely on organic traffic from other nodes. In a quiet RF environment they will time out.
### Environment variables
All E2E environment configuration is centralized in `tests/e2e/helpers/env.ts` with defaults that work for the maintainer's test rig. Override via environment variables:
| Variable | Default | Purpose |
|----------|---------|---------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for the test radio |
| `E2E_ECHO_CHANNEL` | `#flightless` | Channel the echo bot monitors for traffic generation |
| `E2E_ECHO_TRIGGER_MESSAGE` | `!echo please give incoming message` | Message sent to nudge the echo bot |
| `E2E_PARTNER_RADIO_PUBKEY` | *(maintainer's test node)* | 64-char hex public key of a node that will ACK DMs from your radio |
| `E2E_PARTNER_RADIO_NAME` | *(maintainer's test node)* | Display name of that node (used in UI assertions) |
Example for a contributor with their own two-radio setup:
```bash
E2E_ECHO_CHANNEL="#mytest" \
E2E_PARTNER_RADIO_PUBKEY="abcd1234...full64charhexkey..." \
E2E_PARTNER_RADIO_NAME="MyTestNode" \
npx playwright test
```
## Pull Request Expectations
+33 -2
View File
@@ -56,7 +56,7 @@ SOFTWARE.
</details>
### apprise (1.9.7) — BSD-2-Clause
### apprise (1.9.9) — BSD-2-Clause
<details>
<summary>Full license text</summary>
@@ -64,7 +64,7 @@ SOFTWARE.
```
BSD 2-Clause License
Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
@@ -1188,6 +1188,37 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
</details>
### cmdk (1.1.1) — MIT
<details>
<summary>Full license text</summary>
```
MIT License
Copyright (c) 2022 Paco Coursey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
</details>
### d3-force (3.0.0) — ISC
<details>
+45 -16
View File
@@ -12,25 +12,18 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Use the more recent 1.14 firmwares which support multibyte pathing
* Visualize the mesh as a map or node set, view repeater stats, and more!
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](app_screenshot.png)
## Start Here
Most users should choose one of these paths:
1. Clone and build from source.
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
3. Use Docker if that better matches how you deploy.
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
> [!WARNING]
> RemoteTerm does *full* management of the radio, meaning that once a radio is connected to RemoteTerm, all contacts/channels will be imported and offloaded to RemoteTerm and the contacts actually synced to the device will be governed by RemoteTerm. This means that RemoteTerm can be a poor fit for users who are looking to swap radios in and out, maintaining radio state (favorites, channels, etc.) irrespective of app usage.
## Requirements
- Python 3.10+
- Python 3.11+
- Node.js LTS or current (20, 22, 24, 25) if you're not using a prebuilt release
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore radio connected via USB serial, TCP, or BLE
@@ -71,7 +64,7 @@ usbipd attach --wsl --busid 3-8
```
</details>
## Path 1: Clone And Build
## Install Path 1: Clone And Build
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
@@ -89,10 +82,10 @@ Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
> [!NOTE]
> [!TIP]
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
> [!TIP]
> [!NOTE]
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
>
> ```bash
@@ -101,7 +94,7 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
>
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
## Path 2: Docker
## Install Path 2: Docker
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
@@ -123,6 +116,8 @@ cp docker-compose.example.yml docker-compose.yml
bash scripts/setup/install_docker.sh
```
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
@@ -142,6 +137,8 @@ sudo docker compose pull
sudo docker compose up -d
```
> If you switched to a local build (`build: .` instead of `image:`), use `sudo docker compose up -d --build` instead — `pull` only fetches remote images.
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
```yaml
@@ -168,6 +165,29 @@ To stop:
sudo docker compose down
```
## Install Path 3: Arch Linux (AUR)
A [`remoteterm-meshcore`](https://aur.archlinux.org/packages/remoteterm-meshcore) package is available in the AUR. Install it with an AUR helper or build it manually:
```bash
# with an AUR helper
yay -S remoteterm-meshcore
# or manually
git clone https://aur.archlinux.org/remoteterm-meshcore.git
cd remoteterm-meshcore
makepkg -si
```
Configure your radio connection, then start the service:
```bash
sudo vi /etc/remoteterm-meshcore/remoteterm.env
sudo systemctl enable --now remoteterm-meshcore
```
Access the app at http://localhost:8000.
## Standard Environment Variables
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
@@ -206,6 +226,15 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
> [!WARNING]
> **Windows + MQTT fanout:** Python's default Windows event loop (ProactorEventLoop) is not compatible with the MQTT libraries used by RemoteTerm. If you configure any MQTT integration, add `--loop none` to your uvicorn command:
>
> ```powershell
> uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --loop none
> ```
>
> If you forget, the app will start normally but MQTT connections will fail and you'll see a toast in the UI with this same guidance.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
## Where To Go Next
+9
View File
@@ -19,6 +19,15 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
`__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.
## Sub-Path Reverse Proxy
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
**Requirements:**
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
## HTTPS
WebGPU channel-finding requires a secure context when you are not on `localhost`.
+11 -7
View File
@@ -40,7 +40,7 @@ app/
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
│ ├── radio_commands.py # Radio config/private-key command workflows
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
│ ├── radio_stats.py # In-memory local radio stats sampling and noise-floor history
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
├── radio.py # RadioManager transport/session state + lock management
├── radio_sync.py # Polling, sync, periodic advertisement loop
@@ -161,10 +161,12 @@ app/
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
- Each integration is a `FanoutModule` with scope-based filtering.
- `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).
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch).
- 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.
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
## API Surface (all under `/api`)
@@ -244,7 +246,7 @@ app/
- `POST /settings/favorites/toggle`
- `POST /settings/blocked-keys/toggle`
- `POST /settings/blocked-names/toggle`
- `POST /settings/migrate`
- `POST /settings/tracked-telemetry/toggle`
### Fanout
- `GET /fanout` — list all fanout configs
@@ -286,6 +288,8 @@ Main tables:
- `raw_packets`
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
- `contact_name_history` (tracks name changes over time)
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
- `app_settings`
Contact route state is canonicalized on the backend:
@@ -301,14 +305,14 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
`app_settings` fields in active model:
- `max_radio_contacts`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `last_message_times`
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `auto_resend_channel`
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
+5 -3
View File
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS contacts (
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channels (
@@ -37,7 +38,8 @@ CREATE TABLE IF NOT EXISTS channels (
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT,
path_hash_mode_override INTEGER,
last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
@@ -134,7 +136,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN';
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0), COALESCE(sender_key, ''))
WHERE type = 'PRIV' AND outgoing = 0;
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
CREATE INDEX IF NOT EXISTS idx_messages_pagination
+4 -1
View File
@@ -299,8 +299,11 @@ def parse_advertisement(
timestamp = int.from_bytes(payload[32:36], byteorder="little")
flags = payload[100]
# Parse flags
# Parse flags — clamp device_role to valid range (0-4); corrupted
# advertisements can have junk in the lower nibble.
device_role = flags & 0x0F
if device_role > 4:
device_role = 0
has_location = bool(flags & 0x10)
has_feature1 = bool(flags & 0x20)
has_feature2 = bool(flags & 0x40)
+2 -2
View File
@@ -2,10 +2,10 @@
import json
import logging
from typing import Any, Literal
from typing import Any, Literal, NotRequired
from pydantic import TypeAdapter
from typing_extensions import NotRequired, TypedDict
from typing_extensions import TypedDict
from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast
from app.routers.health import HealthResponse
+61 -7
View File
@@ -1,6 +1,6 @@
# Fanout Bus Architecture
The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
The fanout bus is a unified system for dispatching mesh radio events to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
## Core Concepts
@@ -8,10 +8,15 @@ The fanout bus is a unified system for dispatching mesh radio events (decoded me
Base class that all integration modules extend:
- `__init__(config_id, config, *, name="")` — constructor; receives the config UUID, the type-specific config dict, and the user-assigned name
- `start()` / `stop()` — async lifecycle (e.g. open/close connections)
- `on_message(data)` — receive decoded messages (DM/channel)
- `on_raw(data)` — receive raw RF packets
- `on_message(data)` — receive decoded messages (scope-gated)
- `on_raw(data)` — receive raw RF packets (scope-gated)
- `on_contact(data)` — receive contact upserts; dispatched to all modules
- `on_telemetry(data)` — receive repeater telemetry snapshots; dispatched to all modules
- `on_health(data)` — receive periodic radio health snapshots; dispatched to all modules
- `status` property (**must override**) — return `"connected"`, `"disconnected"`, or `"error"`
All five event hooks are no-ops by default; modules override only the ones they care about.
### FanoutManager (manager.py)
Singleton that owns all active modules and dispatches events:
- `load_from_db()` — startup: load enabled configs, instantiate modules
@@ -19,6 +24,9 @@ Singleton that owns all active modules and dispatches events:
- `remove_config(id)` — delete: stop and remove
- `broadcast_message(data)` — scope-check + dispatch `on_message`
- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
- `broadcast_contact(data)` — dispatch `on_contact` to all modules
- `broadcast_telemetry(data)` — dispatch `on_telemetry` to all modules
- `broadcast_health_fanout(data)` — dispatch `on_health` to all modules
- `stop_all()` — shutdown
- `get_statuses()` — health endpoint data
@@ -33,19 +41,65 @@ Each config has a `scope` JSON blob controlling what events reach it:
```
Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
Scope only gates `on_message` and `on_raw`. The `on_contact`, `on_telemetry`, and `on_health` hooks are dispatched to all modules unconditionally — modules that care about specific contacts or repeaters filter internally based on their own config.
## Event Flow
```
Radio Event -> packet_processor / event_handler
-> broadcast_event("message"|"raw_packet", data, realtime=True)
-> broadcast_event("message"|"raw_packet"|"contact", data, realtime=True)
-> WebSocket broadcast (always)
-> FanoutManager.broadcast_message/raw (only if realtime=True)
-> scope check per module
-> module.on_message / on_raw
-> FanoutManager.broadcast_message/raw/contact (only if realtime=True)
-> scope check per module (message/raw only)
-> module.on_message / on_raw / on_contact
Telemetry collect (radio_sync.py / routers/repeaters.py)
-> RepeaterTelemetryRepository.record(...)
-> FanoutManager.broadcast_telemetry(data)
-> module.on_telemetry (all modules, unconditional)
Health fanout (radio_stats.py, piggybacks on 60s stats sampling loop)
-> FanoutManager.broadcast_health_fanout(data)
-> module.on_health (all modules, unconditional)
```
Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
## Event Payloads
### on_message(data)
`Message.model_dump()` — the full Pydantic message model. Key fields:
- `type` (`"PRIV"` | `"CHAN"`), `conversation_key`, `text`, `sender_name`, `sender_key`
- `outgoing`, `acked`, `paths`, `sender_timestamp`, `received_at`
### on_raw(data)
Raw packet dict from `packet_processor.py`. Key fields:
- `id` (storage row ID), `observation_id` (per-arrival), `raw` (hex), `timestamp`
- `decrypted_info` (optional: `channel_key`, `contact_key`, `text`)
### on_contact(data)
`Contact.model_dump()` — the full Pydantic contact model. Key fields:
- `public_key`, `name`, `type` (0=unknown, 1=client, 2=repeater, 3=room, 4=sensor)
- `lat`, `lon`, `last_seen`, `first_seen`, `on_radio`
### on_telemetry(data)
Repeater telemetry snapshot, broadcast after successful `RepeaterTelemetryRepository.record()`.
Identical shape from both auto-collect (`radio_sync.py`) and manual fetch (`routers/repeaters.py`):
- `public_key`, `name`, `timestamp`
- `battery_volts`, `noise_floor_dbm`, `last_rssi_dbm`, `last_snr_db`
- `packets_received`, `packets_sent`, `airtime_seconds`, `rx_airtime_seconds`
- `uptime_seconds`, `sent_flood`, `sent_direct`, `recv_flood`, `recv_direct`
- `flood_dups`, `direct_dups`, `full_events`, `tx_queue_len`
### on_health(data)
Radio health + stats snapshot, broadcast every 60s by the stats sampling loop in `radio_stats.py`:
- `connected` (bool), `connection_info` (str | None)
- `public_key` (str | None), `name` (str | None)
- `noise_floor_dbm`, `battery_mv`, `uptime_secs` (int | None)
- `last_rssi` (int | None), `last_snr` (float | None)
- `tx_air_secs`, `rx_air_secs` (int | None)
- `packets_recv`, `packets_sent`, `flood_tx`, `direct_tx`, `flood_rx`, `direct_rx` (int | None)
## Current Module Types
### mqtt_private (mqtt_private.py)
+9
View File
@@ -38,6 +38,15 @@ class FanoutModule:
async def on_raw(self, data: dict) -> None:
"""Called for raw RF packets. Override if needed."""
async def on_contact(self, data: dict) -> None:
"""Called for contact upserts (adverts, sync). Override if needed."""
async def on_telemetry(self, data: dict) -> None:
"""Called for repeater telemetry snapshots. Override if needed."""
async def on_health(self, data: dict) -> None:
"""Called for periodic radio health snapshots. Override if needed."""
@property
def status(self) -> str:
"""Return 'connected', 'disconnected', or 'error'."""
+1 -1
View File
@@ -164,7 +164,7 @@ class BotModule(FanoutModule):
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
except TimeoutError:
logger.warning("Bot '%s' execution timed out", self.name)
return
except Exception:
+1 -1
View File
@@ -538,7 +538,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
self._version_event.clear()
try:
await asyncio.wait_for(self._version_event.wait(), timeout=30)
except asyncio.TimeoutError:
except TimeoutError:
pass
return False
return True
+33 -1
View File
@@ -86,6 +86,11 @@ def _scope_matches_raw(scope: dict, _data: dict) -> bool:
return scope.get("raw_packets", "none") == "all"
def _always_match(_scope: dict, _data: dict) -> bool:
"""Match all modules unconditionally (filtering is module-internal)."""
return True
class FanoutManager:
"""Owns all active fanout modules and dispatches events."""
@@ -220,7 +225,7 @@ class FanoutManager:
handler = getattr(module, handler_name)
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
self._clear_module_error(config_id)
except asyncio.TimeoutError:
except TimeoutError:
timeout_error = f"{handler_name} timed out after {_DISPATCH_TIMEOUT_SECONDS:.1f}s"
self._set_module_error(config_id, timeout_error)
logger.error(
@@ -270,6 +275,33 @@ class FanoutManager:
log_label="on_raw",
)
async def broadcast_contact(self, data: dict) -> None:
"""Dispatch a contact upsert to all modules."""
await self._dispatch_matching(
data,
matcher=_always_match,
handler_name="on_contact",
log_label="on_contact",
)
async def broadcast_telemetry(self, data: dict) -> None:
"""Dispatch a repeater telemetry snapshot to all modules."""
await self._dispatch_matching(
data,
matcher=_always_match,
handler_name="on_telemetry",
log_label="on_telemetry",
)
async def broadcast_health_fanout(self, data: dict) -> None:
"""Dispatch a radio health snapshot to all modules."""
await self._dispatch_matching(
data,
matcher=_always_match,
handler_name="on_health",
log_label="on_health",
)
async def stop_all(self) -> None:
"""Shutdown all modules."""
for config_id, (module, _) in list(self._modules.items()):
+31 -2
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import sys
import time
from abc import ABC, abstractmethod
from typing import Any
@@ -195,7 +196,7 @@ class BaseMqttPublisher(ABC):
self._version_event.wait(),
timeout=self._not_configured_timeout,
)
except asyncio.TimeoutError:
except TimeoutError:
continue
except asyncio.CancelledError:
return
@@ -230,7 +231,7 @@ class BaseMqttPublisher(ABC):
self._version_event.clear()
try:
await asyncio.wait_for(self._version_event.wait(), timeout=60)
except asyncio.TimeoutError:
except TimeoutError:
elapsed = time.monotonic() - connect_time
await self._on_periodic_wake(elapsed)
if self._should_break_wait(elapsed):
@@ -252,6 +253,34 @@ class BaseMqttPublisher(ABC):
self._client = None
self._last_error = _format_error_detail(e)
# Windows ProactorEventLoop does not implement add_reader /
# add_writer, which paho-mqtt requires. The failure can
# surface as a direct NotImplementedError (add_writer in
# __aenter__) or as a generic timeout (add_reader fails
# inside an event-loop callback, so paho never hears back).
# Either way, if we're on Windows with Proactor the root
# cause is the same and retrying won't help.
_on_proactor = (
sys.platform == "win32"
and type(asyncio.get_event_loop()).__name__ == "ProactorEventLoop"
)
if _on_proactor:
broadcast_error(
"MQTT unavailable — Windows event loop incompatible",
"The default Windows event loop (ProactorEventLoop) does "
"not support MQTT. Add --loop none to your uvicorn "
"command and restart. See README.md for details.",
)
_broadcast_health()
logger.error(
"%s cannot run: Windows ProactorEventLoop does not "
"implement add_reader/add_writer required by paho-mqtt. "
"Restart uvicorn with '--loop none' to use "
"SelectorEventLoop instead. Giving up (will not retry).",
self._integration_label(),
)
return
title, detail = self._on_error()
broadcast_error(title, detail)
_broadcast_health()
+31 -11
View File
@@ -38,8 +38,17 @@ def _is_index_file(path: Path, index_file: Path) -> bool:
return path == index_file
def _resolve_request_origin(request: Request) -> str:
"""Resolve the external origin, honoring common reverse-proxy headers."""
def _resolve_request_base(request: Request) -> str:
"""Resolve the external base URL, honoring common reverse-proxy headers.
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
so callers can append paths directly.
Recognized headers:
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
by the proxy (e.g. ``/meshcore``).
"""
forwarded_proto = request.headers.get("x-forwarded-proto")
forwarded_host = request.headers.get("x-forwarded-host")
@@ -47,9 +56,20 @@ def _resolve_request_origin(request: Request) -> str:
proto = forwarded_proto.split(",")[0].strip()
host = forwarded_host.split(",")[0].strip()
if proto and host:
return f"{proto}://{host}"
origin = f"{proto}://{host}"
else:
origin = str(request.base_url).rstrip("/")
else:
origin = str(request.base_url).rstrip("/")
return str(request.base_url).rstrip("/")
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy
prefix = (
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
.strip()
.rstrip("/")
)
return f"{origin}{prefix}/"
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
@@ -103,27 +123,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/site.webmanifest")
async def serve_webmanifest(request: Request):
"""Serve a dynamic web manifest using the active request origin."""
origin = _resolve_request_origin(request)
"""Serve a dynamic web manifest using the active request base URL."""
base = _resolve_request_base(request)
manifest = {
"name": "RemoteTerm for MeshCore",
"short_name": "RemoteTerm",
"id": f"{origin}/",
"start_url": f"{origin}/",
"scope": f"{origin}/",
"id": base,
"start_url": base,
"scope": base,
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
"theme_color": "#111419",
"background_color": "#111419",
"icons": [
{
"src": f"{origin}/web-app-manifest-192x192.png",
"src": f"{base}web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable",
},
{
"src": f"{origin}/web-app-manifest-512x512.png",
"src": f"{base}web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
+1 -1
View File
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
NO_EVENT_RECEIVED_GUIDANCE = (
"Radio command channel is unresponsive (no_event_received). Ensure that your firmware is not "
"incompatible, outdated, or wrong-mode (e.g. repeater, not client), and that"
"incompatible, outdated, or wrong-mode (e.g. repeater, not client), and that "
"serial/TCP/BLE connectivity is successful (try another app and see if that one works?). The app cannot proceed because it cannot "
"issue commands to the radio."
)
+40 -4
View File
@@ -1,5 +1,41 @@
import asyncio
import logging
import sys
# ---------------------------------------------------------------------------
# Windows event-loop advisory for MQTT fanout
# ---------------------------------------------------------------------------
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
# We cannot fix this from inside the app — the loop is already created by the
# time this module is imported. Log a prominent warning so Windows operators
# who want MQTT know to add ``--loop none`` to their uvicorn command.
# ---------------------------------------------------------------------------
if sys.platform == "win32":
import asyncio as _asyncio
_loop = _asyncio.get_event_loop()
_is_proactor = type(_loop).__name__ == "ProactorEventLoop"
if _is_proactor:
print(
"\n" + "!" * 78 + "\n"
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
"\n"
" The running event loop is ProactorEventLoop, which is not\n"
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
"\n"
" If you use MQTT integrations, restart with --loop none:\n"
"\n"
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
" [... other options ...]\n"
"\n"
" Everything else works fine as-is.\n"
"\n" + "!" * 78 + "\n",
file=sys.stderr,
flush=True,
)
del _loop, _is_proactor
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
@@ -40,8 +76,8 @@ from app.routers import (
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
from app.services.radio_runtime import radio_runtime as radio_manager
from app.services.radio_stats import start_radio_stats_sampling, stop_radio_stats_sampling
from app.version_info import get_app_build_info
setup_logging()
@@ -72,7 +108,7 @@ async def lifespan(app: FastAPI):
from app.radio_sync import ensure_default_channels
await ensure_default_channels()
await start_noise_floor_sampling()
await start_radio_stats_sampling()
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
@@ -101,7 +137,7 @@ async def lifespan(app: FastAPI):
await radio_manager.stop_connection_monitor()
await stop_background_contact_reconciliation()
await stop_message_polling()
await stop_noise_floor_sampling()
await stop_radio_stats_sampling()
await stop_periodic_advert()
await stop_periodic_sync()
await stop_telemetry_collect()
+138
View File
@@ -413,6 +413,18 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 54)
applied += 1
if version < 55:
logger.info("Applying migration 55: move favorites to per-entity columns")
await _migrate_055_favorites_to_columns(conn)
await set_version(conn, 55)
applied += 1
if version < 56:
logger.info("Applying migration 56: add sender_key to incoming PRIV dedup index")
await _migrate_056_priv_dedup_include_sender_key(conn)
await set_version(conn, 56)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3213,3 +3225,129 @@ async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
)
await conn.commit()
async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None:
"""Move favorites from app_settings JSON blob to per-entity boolean columns.
1. Add ``favorite`` column to contacts and channels tables.
2. Backfill from the ``app_settings.favorites`` JSON array.
3. Drop the ``favorites`` column from app_settings.
"""
import json as _json
# --- Add columns ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
for table in ("contacts", "channels"):
if table not in existing_tables:
continue
col_cursor = await conn.execute(f"PRAGMA table_info({table})")
columns = {row[1] for row in await col_cursor.fetchall()}
if "favorite" not in columns:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0")
await conn.commit()
# --- Backfill from JSON ---
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)")
settings_columns = {row[1] for row in await col_cursor.fetchall()}
if "favorites" not in settings_columns:
await conn.commit()
return
cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
if row and row[0]:
try:
favorites = _json.loads(row[0])
except (ValueError, TypeError):
favorites = []
contact_keys = []
channel_keys = []
for fav in favorites:
if not isinstance(fav, dict):
continue
fav_type = fav.get("type")
fav_id = fav.get("id")
if not fav_id:
continue
if fav_type == "contact":
contact_keys.append(fav_id)
elif fav_type == "channel":
channel_keys.append(fav_id)
if contact_keys:
placeholders = ",".join("?" for _ in contact_keys)
await conn.execute(
f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})",
contact_keys,
)
if channel_keys:
placeholders = ",".join("?" for _ in channel_keys)
await conn.execute(
f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})",
channel_keys,
)
if contact_keys or channel_keys:
logger.info(
"Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings",
len(contact_keys),
len(channel_keys),
)
await conn.commit()
# --- Drop the JSON column ---
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused")
await conn.commit()
else:
raise
async def _migrate_056_priv_dedup_include_sender_key(conn: aiosqlite.Connection) -> None:
"""Add sender_key to the incoming PRIV dedup index.
Room-server posts are stored as PRIV messages sharing one conversation_key
(the room contact). Without sender_key in the uniqueness constraint, two
different room participants sending identical text in the same clock second
collide and the second message is silently dropped.
Adding COALESCE(sender_key, '') is strictly more permissive — no existing
rows can conflict — so the migration only needs to rebuild the index.
"""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
# The index references type, conversation_key, sender_timestamp, outgoing,
# and sender_key. Some migration tests create minimal messages tables that
# lack these columns. Skip gracefully when the schema is too old.
col_cursor = await conn.execute("PRAGMA table_info(messages)")
columns = {row[1] for row in await col_cursor.fetchall()}
required = {"type", "conversation_key", "sender_timestamp", "outgoing", "sender_key"}
if not required.issubset(columns):
await conn.commit()
return
await conn.execute("DROP INDEX IF EXISTS idx_messages_incoming_priv_dedup")
await conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0),
COALESCE(sender_key, ''))
WHERE type = 'PRIV' AND outgoing = 0"""
)
await conn.commit()
+29 -17
View File
@@ -4,6 +4,10 @@ from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route, normalize_route_override
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
# Corrupted radio data can produce values outside this range.
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
class ContactRoute(BaseModel):
"""A normalized contact route."""
@@ -59,16 +63,30 @@ class ContactUpsert(BaseModel):
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
),
)
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
# can produce values like 111 or 240 that break downstream branching.
raw_type = radio_data.get("type", 0)
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
# Null out impossible coordinates — the contact is still ingested,
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
lat = radio_data.get("adv_lat")
lon = radio_data.get("adv_lon")
if lat is not None and not (-90 <= lat <= 90):
lat = None
if lon is not None and not (-180 <= lon <= 180):
lon = None
return cls(
public_key=public_key,
name=radio_data.get("adv_name"),
type=radio_data.get("type", 0),
type=contact_type,
flags=radio_data.get("flags", 0),
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
lat=radio_data.get("adv_lat"),
lon=radio_data.get("adv_lon"),
lat=lat,
lon=lon,
last_advert=radio_data.get("last_advert"),
on_radio=on_radio,
)
@@ -91,6 +109,7 @@ class Contact(BaseModel):
lon: float | None = None
last_seen: int | None = None
on_radio: bool = False
favorite: bool = False
last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
@@ -326,6 +345,7 @@ class Channel(BaseModel):
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
last_read_at: int | None = None # Server-side read state tracking
favorite: bool = False
class ChannelMessageCounts(BaseModel):
@@ -756,13 +776,6 @@ class RadioDiscoveryResponse(BaseModel):
)
class Favorite(BaseModel):
"""A favorite conversation."""
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -790,9 +803,6 @@ class AppSettings(BaseModel):
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=True,
description="Whether to attempt historical DM decryption on new contact advertisement",
@@ -867,13 +877,14 @@ class NoiseFloorHistoryStats(BaseModel):
latest_timestamp: int | None = Field(
default=None, description="Unix timestamp of the most recent sample"
)
supported: bool | None = Field(
default=None,
description="Whether the connected radio appears to support radio stats sampling",
)
samples: list[NoiseFloorSample] = Field(default_factory=list)
class PacketsPerHourBucket(BaseModel):
timestamp: int = Field(description="Unix timestamp at the start of the hour")
count: int = Field(description="Number of packets received in that hour")
class StatisticsResponse(BaseModel):
busiest_channels_24h: list[BusyChannel]
contact_count: int
@@ -889,6 +900,7 @@ class StatisticsResponse(BaseModel):
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
packets_per_hour_72h: list[PacketsPerHourBucket]
noise_floor_24h: NoiseFloorHistoryStats
+24 -3
View File
@@ -39,6 +39,7 @@ from app.repository import (
ChannelRepository,
ContactAdvertPathRepository,
ContactRepository,
MessageRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import (
@@ -645,10 +646,30 @@ async def _process_direct_message(
)
if result is not None:
# Successfully decrypted!
# In the ambiguous direction case (both first bytes match), we
# defaulted to incoming. Check if a matching outgoing message
# already exists — if so, this is actually our own outgoing echo
# and should be treated as such instead of creating a duplicate
# incoming row.
effective_outgoing = is_outgoing
if not is_outgoing and dest_hash == src_hash:
existing_outgoing = await MessageRepository.get_by_content(
msg_type="PRIV",
conversation_key=contact.public_key.lower(),
text=result.message,
sender_timestamp=result.timestamp,
outgoing=True,
)
if existing_outgoing is not None:
effective_outgoing = True
logger.debug(
"Ambiguous DM resolved as outgoing echo (matched existing sent msg %d)",
existing_outgoing.id,
)
logger.debug(
"Decrypted DM %s contact %s: %s",
"to" if is_outgoing else "from",
"to" if effective_outgoing else "from",
contact.name or contact.public_key[:12],
result.message[:50] if result.message else "",
)
@@ -664,7 +685,7 @@ async def _process_direct_message(
path_len=packet_info.path_length if packet_info else None,
rssi=rssi,
snr=snr,
outgoing=is_outgoing,
outgoing=effective_outgoing,
)
return {
+4 -1
View File
@@ -118,7 +118,7 @@ async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) ->
return True
return False
except asyncio.TimeoutError:
except TimeoutError:
logger.debug("Device %s timed out", port)
return False
except Exception as e:
@@ -192,6 +192,9 @@ class RadioManager:
if not blocking:
if self._operation_lock.locked():
raise RadioOperationBusyError(f"Radio is busy (operation: {name})")
# In single-threaded asyncio the lock cannot be acquired between the
# check above and the await below (no other coroutine runs until we
# yield). The await returns immediately for an uncontested lock.
await self._operation_lock.acquire()
else:
await self._operation_lock.acquire()
+49 -31
View File
@@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.config import settings
from app.event_handlers import cleanup_expired_acks, on_contact_message
from app.models import Contact, ContactUpsert
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert
from app.radio import RadioOperationBusyError
from app.repository import (
AmbiguousPublicKeyPrefixError,
@@ -307,7 +307,7 @@ async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = Non
except Exception as e:
logger.warning("Error clearing channel %d: %s", idx, e)
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
logger.debug("Synced %d channels, cleared %d from radio", synced, cleared)
except Exception as e:
logger.error("Error during channel sync: %s", e)
@@ -428,7 +428,6 @@ async def ensure_default_channels() -> None:
async def sync_and_offload_all(mc: MeshCore) -> dict:
"""Run fast startup sync, then background contact reconcile."""
logger.info("Starting full radio sync and offload")
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
# cycle so old rows stop claiming radio residency we do not actively track.
@@ -481,7 +480,7 @@ async def drain_pending_messages(mc: MeshCore) -> int:
# Small delay between fetches
await asyncio.sleep(0.1)
except asyncio.TimeoutError:
except TimeoutError:
break
except Exception as e:
logger.warning("Error draining messages: %s", e, exc_info=True)
@@ -519,7 +518,7 @@ async def poll_for_messages(mc: MeshCore) -> int:
# If we got a message, there might be more - drain them
count += await drain_pending_messages(mc)
except asyncio.TimeoutError:
except TimeoutError:
pass
except Exception as e:
logger.warning("Message poll exception: %s", e, exc_info=True)
@@ -944,10 +943,8 @@ async def sync_radio_time(mc: MeshCore) -> bool:
except Exception:
logger.warning("Reboot command failed", exc_info=True)
elif _clock_reboot_attempted:
logger.warning(
"Clock skew persists after reboot — the radio likely has a "
"hardware RTC that preserved the wrong time. A manual "
"'clkreboot' CLI command is needed to reset it."
logger.debug(
"Clock skew persists after reboot (hardware RTC); ignoring until next session."
)
return False
@@ -1057,7 +1054,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
contacts = _normalize_radio_contacts_payload(result.payload)
logger.info("Found %d contacts on radio", len(contacts))
logger.debug("Found %d contacts on radio", len(contacts))
for public_key, contact_data in contacts.items():
await ContactRepository.upsert(
@@ -1071,7 +1068,29 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
)
synced += 1
logger.info("Synced %d contacts from radio snapshot", synced)
logger.debug("Synced %d contacts from radio snapshot", synced)
# Import radio-favorited contacts into app favorites.
# Only trust the favorite bit on contacts with a valid type (0-4);
# garbled radio data can have junk flags with bit 0 set.
radio_fav_keys = [
pk
for pk, data in contacts.items()
if data.get("flags", 0) & 0x01 and data.get("type", -1) in _VALID_CONTACT_TYPES
]
if radio_fav_keys:
try:
imported = 0
for pk in radio_fav_keys:
existing = await ContactRepository.get_by_key(pk)
if existing and not existing.favorite:
await ContactRepository.set_favorite(pk, True)
imported += 1
if imported:
logger.info("Imported %d radio favorite(s) into app favorites", imported)
except Exception as e:
logger.warning("Failed to import radio favorites: %s", e)
return {"synced": synced, "radio_contacts": contacts}
except Exception as e:
logger.error("Error during contact snapshot sync: %s", e)
@@ -1283,26 +1302,9 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
# Favorites first — always loaded up to max_contacts
favorite_contacts_loaded = 0
for favorite in app_settings.favorites:
if favorite.type != "contact":
continue
try:
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
favorite.id,
)
continue
if not contact:
continue
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
favorite.id,
)
continue
for contact in await ContactRepository.get_favorites():
key = contact.public_key.lower()
if key in selected_keys:
continue
@@ -1583,9 +1585,10 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
}
try:
timestamp = int(time.time())
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=int(time.time()),
timestamp=timestamp,
data=data,
)
logger.info(
@@ -1593,6 +1596,21 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
contact.name or contact.public_key[:12],
contact.public_key[:12],
)
# Dispatch to fanout modules (e.g. HA MQTT discovery)
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(
+14 -2
View File
@@ -26,7 +26,7 @@ class ChannelRepository:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
FROM channels
WHERE key = ?
""",
@@ -42,6 +42,7 @@ class ChannelRepository:
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
)
return None
@@ -49,7 +50,7 @@ class ChannelRepository:
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
FROM channels
ORDER BY name
"""
@@ -64,10 +65,21 @@ class ChannelRepository:
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
)
for row in rows
]
@staticmethod
async def set_favorite(key: str, value: bool) -> bool:
"""Set or clear the favorite flag for a channel. Returns True if row was found."""
cursor = await db.conn.execute(
"UPDATE channels SET favorite = ? WHERE key = ?",
(1 if value else 0, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
+30 -4
View File
@@ -170,6 +170,7 @@ class ContactRepository:
lon=row["lon"],
last_seen=row["last_seen"],
on_radio=bool(row["on_radio"]),
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
last_contacted=row["last_contacted"],
last_read_at=row["last_read_at"],
first_seen=row["first_seen"],
@@ -392,6 +393,24 @@ class ContactRepository:
)
await db.conn.commit()
@staticmethod
async def get_favorites() -> list[Contact]:
"""Return all contacts marked as favorite."""
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def set_favorite(public_key: str, value: bool) -> None:
"""Set or clear the favorite flag for a contact."""
await db.conn.execute(
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
(1 if value else 0, public_key.lower()),
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
normalized = public_key.lower()
@@ -673,9 +692,18 @@ class ContactAdvertPathRepository:
cursor = await db.conn.execute(
"""
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY public_key
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
) AS rn
FROM contact_advert_paths
)
WHERE rn <= ?
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
"""
""",
(limit_per_contact,),
)
rows = await cursor.fetchall()
@@ -686,8 +714,6 @@ class ContactAdvertPathRepository:
if paths is None:
paths = []
grouped[key] = paths
if len(paths) >= limit_per_contact:
continue
paths.append(ContactAdvertPathRepository._row_to_path(row))
return [
+4 -3
View File
@@ -557,10 +557,11 @@ class MessageRepository:
@staticmethod
async def increment_ack_count(message_id: int) -> int:
"""Increment ack count and return the new value."""
await db.conn.execute("UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,))
await db.conn.commit()
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
cursor = await db.conn.execute(
"UPDATE messages SET acked = acked + 1 WHERE id = ? RETURNING acked", (message_id,)
)
row = await cursor.fetchone()
await db.conn.commit()
return row["acked"] if row else 1
@staticmethod
+25 -45
View File
@@ -1,16 +1,17 @@
import json
import logging
import time
from typing import Any, Literal
from typing import Any
from app.database import db
from app.models import AppSettings, Favorite
from app.models import AppSettings
from app.path_utils import bucket_path_hash_widths
logger = logging.getLogger(__name__)
SECONDS_1H = 3600
SECONDS_24H = 86400
SECONDS_72H = 259200
SECONDS_7D = 604800
RAW_PACKET_STATS_BATCH_SIZE = 500
@@ -26,7 +27,7 @@ class AppSettingsRepository:
"""
cursor = await db.conn.execute(
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
last_message_times,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
@@ -40,20 +41,6 @@ class AppSettingsRepository:
# Should not happen after migration, but handle gracefully
return AppSettings()
# Parse favorites JSON
favorites = []
if row["favorites"]:
try:
favorites_data = json.loads(row["favorites"])
favorites = [Favorite(**f) for f in favorites_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
e,
row["favorites"][:100] if row["favorites"] else None,
)
favorites = []
# Parse last_message_times JSON
last_message_times: dict[str, int] = {}
if row["last_message_times"]:
@@ -107,7 +94,6 @@ class AppSettingsRepository:
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
last_message_times=last_message_times,
advert_interval=row["advert_interval"] or 0,
@@ -123,7 +109,6 @@ class AppSettingsRepository:
@staticmethod
async def update(
max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
last_message_times: dict[str, int] | None = None,
advert_interval: int | None = None,
@@ -143,11 +128,6 @@ class AppSettingsRepository:
updates.append("max_radio_contacts = ?")
params.append(max_radio_contacts)
if favorites is not None:
updates.append("favorites = ?")
favorites_json = json.dumps([f.model_dump() for f in favorites])
params.append(favorites_json)
if auto_decrypt_dm_on_advert is not None:
updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0)
@@ -195,27 +175,6 @@ class AppSettingsRepository:
return await AppSettingsRepository.get()
@staticmethod
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Add a favorite, avoiding duplicates."""
settings = await AppSettingsRepository.get()
# Check if already favorited
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
return settings
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Remove a favorite."""
settings = await AppSettingsRepository.get()
new_favorites = [
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def toggle_blocked_key(key: str) -> AppSettings:
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
@@ -316,6 +275,25 @@ class StatisticsRepository:
"last_week": row["last_week"] or 0,
}
@staticmethod
async def _packets_per_hour_72h() -> list[dict[str, int]]:
"""Return packet counts bucketed by hour for the last 72 hours."""
now = int(time.time())
cutoff = now - SECONDS_72H
# Bucket timestamps to the start of each hour
cursor = await db.conn.execute(
"""
SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
FROM raw_packets
WHERE timestamp >= ?
GROUP BY hour_ts
ORDER BY hour_ts
""",
(cutoff,),
)
rows = await cursor.fetchall()
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
@staticmethod
async def _path_hash_width_24h() -> dict[str, int | float]:
"""Count parsed raw packets from the last 24h by hop hash width."""
@@ -392,6 +370,7 @@ class StatisticsRepository:
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
known_channels_active = await StatisticsRepository._known_channels_active()
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
return {
"busiest_channels_24h": busiest_channels_24h,
@@ -408,4 +387,5 @@ class StatisticsRepository:
"repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active,
"path_hash_width_24h": path_hash_width_24h,
"packets_per_hour_72h": packets_per_hour_72h,
}
+2 -2
View File
@@ -4,7 +4,7 @@ import os
import platform
import struct
import sys
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any, Literal
from fastapi import APIRouter
@@ -390,7 +390,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
is_reconnecting=is_reconnecting,
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
captured_at=datetime.now(UTC).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
+47
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from app.config import settings
from app.repository import RawPacketRepository
from app.services.radio_runtime import radio_runtime as radio_manager
from app.services.radio_stats import get_latest_radio_stats
from app.version_info import get_app_build_info
router = APIRouter(tags=["health"])
@@ -32,6 +33,28 @@ class FanoutStatusResponse(BaseModel):
last_error: str | None = None
class RadioStatsSnapshot(BaseModel):
"""Latest cached stats from the local radio's periodic 60s poll."""
timestamp: int | None = None
# Core stats
battery_mv: int | None = None
uptime_secs: int | None = None
# Radio stats
noise_floor: int | None = None
last_rssi: int | None = None
last_snr: float | None = None
tx_air_secs: int | None = None
rx_air_secs: int | None = None
# Packet stats
packets_recv: int | None = None
packets_sent: int | None = None
flood_tx: int | None = None
direct_tx: int | None = None
flood_rx: int | None = None
direct_rx: int | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
@@ -40,6 +63,7 @@ class HealthResponse(BaseModel):
connection_info: str | None
app_info: AppInfoResponse | None = None
radio_device_info: RadioDeviceInfoResponse | None = None
radio_stats: RadioStatsSnapshot | None = None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
@@ -122,6 +146,28 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"max_channels": getattr(radio_manager, "max_channels", None),
}
# Local radio stats from the 60s background sampler
raw_stats = get_latest_radio_stats()
radio_stats = None
if raw_stats:
packets = raw_stats.get("packets") or {}
radio_stats = {
"timestamp": raw_stats.get("timestamp"),
"battery_mv": raw_stats.get("battery_mv"),
"uptime_secs": raw_stats.get("uptime_secs"),
"noise_floor": raw_stats.get("noise_floor"),
"last_rssi": raw_stats.get("last_rssi"),
"last_snr": raw_stats.get("last_snr"),
"tx_air_secs": raw_stats.get("tx_air_secs"),
"rx_air_secs": raw_stats.get("rx_air_secs"),
"packets_recv": packets.get("recv"),
"packets_sent": packets.get("sent"),
"flood_tx": packets.get("flood_tx"),
"direct_tx": packets.get("direct_tx"),
"flood_rx": packets.get("flood_rx"),
"direct_rx": packets.get("direct_rx"),
}
return {
"status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected,
@@ -133,6 +179,7 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"commit_hash": app_build_info.commit_hash,
},
"radio_device_info": radio_device_info,
"radio_stats": radio_stats,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses,
+2 -2
View File
@@ -473,7 +473,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
break
try:
event = await asyncio.wait_for(events.get(), timeout=remaining)
except asyncio.TimeoutError:
except TimeoutError:
break
merged = _merge_discovery_result(
@@ -536,7 +536,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
timeout_seconds = _trace_timeout_seconds(send_result)
try:
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
except asyncio.TimeoutError as exc:
except TimeoutError as exc:
raise HTTPException(status_code=504, detail="No trace response heard") from exc
finally:
if not response_task.done():
+15
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
import time
@@ -133,6 +134,20 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
timestamp=now,
data=status_dict,
)
# Dispatch to fanout modules (e.g. HA MQTT discovery)
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": now,
**status_dict,
}
)
)
except Exception as e:
logger.warning("Failed to record telemetry history: %s", e)
+2 -2
View File
@@ -94,7 +94,7 @@ async def fetch_contact_cli_response(
while _monotonic() < deadline:
try:
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
except TimeoutError:
continue
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
@@ -196,7 +196,7 @@ async def prepare_authenticated_contact_connection(
login_future,
timeout=response_timeout,
)
except asyncio.TimeoutError:
except TimeoutError:
logger.warning(
"No login response from %s %s within %.1fs",
contact_label,
+27 -18
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
from app.models import CONTACT_TYPE_REPEATER, AppSettings
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -72,6 +72,12 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key")
class FavoriteToggleResponse(BaseModel):
type: Literal["channel", "contact"]
id: str
favorite: bool
class TrackedTelemetryRequest(BaseModel):
public_key: str = Field(description="Public key of the repeater to toggle tracking")
@@ -157,27 +163,30 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
return await AppSettingsRepository.get()
@router.post("/favorites/toggle", response_model=AppSettings)
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse)
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
"""Toggle a conversation's favorite status."""
settings = await AppSettingsRepository.get()
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
if request.type == "contact":
contact = await ContactRepository.get_by_key(request.id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
new_value = not contact.favorite
await ContactRepository.set_favorite(request.id, new_value)
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When newly favorited, load to radio immediately for DM ACK support
if new_value:
from app.radio_sync import ensure_contact_on_radio
if is_favorited:
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
else:
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.add_favorite(request.type, request.id)
channel = await ChannelRepository.get_by_key(request.id)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
new_value = not channel.favorite
await ChannelRepository.set_favorite(request.id, new_value)
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When a contact is newly favorited, load just that contact to the radio
# immediately so DM ACK support does not wait for the next maintenance cycle.
if request.type == "contact" and not is_favorited:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
return result
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
@router.post("/blocked-keys/toggle", response_model=AppSettings)
+2 -2
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.models import StatisticsResponse
from app.repository import StatisticsRepository
from app.services.radio_noise_floor import get_noise_floor_history
from app.services.radio_stats import get_noise_floor_history
router = APIRouter(prefix="/statistics", tags=["statistics"])
@@ -10,5 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
@router.get("", response_model=StatisticsResponse)
async def get_statistics() -> StatisticsResponse:
data = await StatisticsRepository.get_all()
data["noise_floor_24h"] = await get_noise_floor_history()
data["noise_floor_24h"] = get_noise_floor_history()
return StatisticsResponse(**data)
+45 -26
View File
@@ -264,38 +264,43 @@ async def send_channel_message_with_effective_scope(
return send_result
finally:
if override_scope and override_scope != baseline_scope:
try:
restore_result = await mc.commands.set_flood_scope(
baseline_scope if baseline_scope else ""
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.error(
"Failed to restore baseline flood_scope after sending to %s: %s",
restored = False
for attempt in range(3):
try:
restore_result = await mc.commands.set_flood_scope(
baseline_scope if baseline_scope else ""
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.warning(
"Attempt %d/3: failed to restore flood_scope after sending to %s: %s",
attempt + 1,
channel.name,
restore_result.payload,
)
else:
logger.debug(
"Restored baseline flood_scope after channel send: %r",
baseline_scope or "(disabled)",
)
restored = True
break
except Exception:
logger.exception(
"Attempt %d/3: exception restoring flood_scope after sending to %s",
attempt + 1,
channel.name,
restore_result.payload,
)
error_broadcast_fn(
"Regional override restore failed",
(
f"Sent to {channel.name}, but restoring flood scope failed. "
"The radio may still be region-scoped. Consider rebooting the radio."
),
)
else:
logger.debug(
"Restored baseline flood_scope after channel send: %r",
baseline_scope or "(disabled)",
)
except Exception:
logger.exception(
"Failed to restore baseline flood_scope after sending to %s",
if not restored:
logger.error(
"All 3 attempts to restore flood_scope failed for %s",
channel.name,
)
error_broadcast_fn(
"Regional override restore failed",
(
f"Sent to {channel.name}, but restoring flood scope failed. "
"The radio may still be region-scoped. Consider rebooting the radio."
f"Sent to {channel.name}, but restoring flood scope failed "
f"after 3 attempts. The radio may still be region-scoped. "
f"Consider rebooting the radio."
),
)
@@ -421,7 +426,8 @@ async def _retry_direct_message_until_acked(
message_repository,
) -> None:
next_wait_timeout_ms = wait_timeout_ms
for attempt in range(1, DM_SEND_MAX_ATTEMPTS):
attempt = 1
while attempt < DM_SEND_MAX_ATTEMPTS:
await sleep_fn((next_wait_timeout_ms / 1000) * DM_RETRY_WAIT_MARGIN)
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
return
@@ -463,6 +469,14 @@ async def _retry_direct_message_until_acked(
timestamp=sender_timestamp,
attempt=attempt,
)
except RadioOperationBusyError:
logger.debug(
"Radio busy during DM retry attempt %d/%d for %s, will retry without consuming attempt",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
except Exception:
logger.exception(
"Background DM retry attempt %d/%d failed for %s",
@@ -470,6 +484,7 @@ async def _retry_direct_message_until_acked(
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
attempt += 1
continue
if result is None:
@@ -479,6 +494,7 @@ async def _retry_direct_message_until_acked(
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
attempt += 1
continue
if result.type == EventType.ERROR:
@@ -489,6 +505,7 @@ async def _retry_direct_message_until_acked(
contact.public_key[:12],
result.payload,
)
attempt += 1
continue
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
@@ -516,6 +533,8 @@ async def _retry_direct_message_until_acked(
if ack_count > 0:
return
attempt += 1
async def send_direct_message_to_contact(
*,
+11 -4
View File
@@ -1,6 +1,6 @@
import asyncio
import logging
from datetime import datetime, timezone
from datetime import UTC, datetime
logger = logging.getLogger(__name__)
@@ -193,7 +193,7 @@ async def run_post_connect_setup(radio_manager) -> None:
logger.info(
"Radio clock at connect: epoch=%d utc=%s",
radio_time,
datetime.fromtimestamp(radio_time, timezone.utc).strftime(
datetime.fromtimestamp(radio_time, UTC).strftime(
"%Y-%m-%d %H:%M:%S UTC"
),
)
@@ -215,7 +215,14 @@ async def run_post_connect_setup(radio_manager) -> None:
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
c = result.get("contacts", {})
ch = result.get("channels", {})
logger.info(
"Sync complete: %d contacts synced, %d channels synced, %d channels cleared",
c.get("synced", 0),
ch.get("synced", 0),
ch.get("cleared", 0),
)
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
@@ -267,7 +274,7 @@ async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool =
try:
await radio_manager.post_connect_setup()
break
except asyncio.TimeoutError as exc:
except TimeoutError as exc:
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
logger.warning(
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
-119
View File
@@ -1,119 +0,0 @@
"""In-memory local-radio noise floor history sampling."""
import asyncio
import logging
import time
from collections import deque
from meshcore import EventType
from app.radio import RadioDisconnectedError, RadioOperationBusyError
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
MAX_NOISE_FLOOR_SAMPLES = 300
_noise_floor_task: asyncio.Task | None = None
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
_noise_floor_supported: bool | None = None
_samples_lock = asyncio.Lock()
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
async with _samples_lock:
_noise_floor_samples.append((timestamp, noise_floor_dbm))
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
"""Fetch the current radio noise floor once and record it when available."""
global _noise_floor_supported
if not radio_manager.is_connected:
return
try:
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
event = await mc.commands.get_stats_radio()
except (RadioDisconnectedError, RadioOperationBusyError):
return
except Exception as exc:
logger.debug("Noise floor sampling failed: %s", exc)
return
if event.type == EventType.ERROR:
_noise_floor_supported = False
return
if event.type != EventType.STATS_RADIO:
return
noise_floor = event.payload.get("noise_floor")
if not isinstance(noise_floor, int):
return
_noise_floor_supported = True
await _append_sample(int(time.time()), noise_floor)
async def _noise_floor_sampling_loop() -> None:
while True:
try:
await sample_noise_floor_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Noise floor sampling loop crashed during sample")
try:
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
async def start_noise_floor_sampling() -> None:
global _noise_floor_task
if _noise_floor_task is not None and not _noise_floor_task.done():
return
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
async def stop_noise_floor_sampling() -> None:
global _noise_floor_task
if _noise_floor_task is None:
return
if not _noise_floor_task.done():
_noise_floor_task.cancel()
try:
await _noise_floor_task
except asyncio.CancelledError:
pass
_noise_floor_task = None
async def get_noise_floor_history() -> dict:
"""Return the current 24-hour in-memory noise floor history snapshot."""
now = int(time.time())
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
async with _samples_lock:
samples = [
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
for timestamp, noise_floor_dbm in _noise_floor_samples
if timestamp >= cutoff
]
latest = samples[-1] if samples else None
oldest_timestamp = samples[0]["timestamp"] if samples else None
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
return {
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
"coverage_seconds": coverage_seconds,
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
"latest_timestamp": latest["timestamp"] if latest else None,
"supported": _noise_floor_supported,
"samples": samples,
}
+195
View File
@@ -0,0 +1,195 @@
"""In-memory local-radio stats sampling.
A single 60s loop fetches core, radio, and packet stats from the connected
radio in one radio-lock acquisition. The noise-floor 24h history deque is
maintained as a side effect.
After each sample the loop:
1. Broadcasts a WS ``health`` frame so frontend dashboards refresh.
2. Dispatches a ``broadcast_health_fanout`` event carrying the full stats
snapshot plus radio identity, so fanout modules (e.g. HA MQTT) can
publish sensor state without a second radio poll.
Consumers:
- GET /api/health get_latest_radio_stats() (battery, uptime, etc.)
- GET /api/statistics get_noise_floor_history() (24h noise-floor chart)
- Fanout on_health _build_fanout_payload() (identity + stats)
"""
import asyncio
import logging
import time
from collections import deque
from typing import Any
from meshcore import EventType
from app.radio import RadioDisconnectedError, RadioOperationBusyError
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
STATS_SAMPLE_INTERVAL_SECONDS = 60
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
MAX_NOISE_FLOOR_SAMPLES = 1500 # 24h at 60s intervals = 1440
_stats_task: asyncio.Task | None = None
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
_latest_stats: dict[str, Any] = {}
async def _sample_all_stats() -> dict[str, Any]:
"""Fetch core, radio, and packet stats in one radio operation.
Returns the snapshot dict (may be empty if the radio is disconnected or
all commands errored).
"""
if not radio_manager.is_connected:
return {}
try:
async with radio_manager.radio_operation("radio_stats_sample", blocking=False) as mc:
core_event = await mc.commands.get_stats_core()
radio_event = await mc.commands.get_stats_radio()
packet_event = await mc.commands.get_stats_packets()
except (RadioDisconnectedError, RadioOperationBusyError):
return {}
except Exception as exc:
logger.debug("Radio stats sampling failed: %s", exc)
return {}
now = int(time.time())
snapshot: dict[str, Any] = {"timestamp": now}
if getattr(core_event, "type", None) == EventType.STATS_CORE:
snapshot.update(core_event.payload)
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
snapshot.update(radio_event.payload)
noise_floor = radio_event.payload.get("noise_floor")
if isinstance(noise_floor, int):
_noise_floor_samples.append((now, noise_floor))
if getattr(packet_event, "type", None) == EventType.STATS_PACKETS:
snapshot["packets"] = packet_event.payload
has_any_data = len(snapshot) > 1
return snapshot if has_any_data else {}
def _build_fanout_payload(stats: dict[str, Any]) -> dict:
"""Build the health fanout payload from a stats snapshot + radio identity.
Includes radio identity (public_key, name), connection state, and the
full stats snapshot so fanout modules can publish rich sensor data
without a second radio poll.
"""
mc = radio_manager.meshcore
self_info = mc.self_info if mc else None
payload: dict = {
"connected": radio_manager.is_connected,
"connection_info": radio_manager.connection_info,
"public_key": (self_info.get("public_key") or None) if self_info else None,
"name": (self_info.get("name") or None) if self_info else None,
}
if stats:
payload["noise_floor_dbm"] = stats.get("noise_floor")
payload["battery_mv"] = stats.get("battery_mv")
payload["uptime_secs"] = stats.get("uptime_secs")
payload["last_rssi"] = stats.get("last_rssi")
payload["last_snr"] = stats.get("last_snr")
payload["tx_air_secs"] = stats.get("tx_air_secs")
payload["rx_air_secs"] = stats.get("rx_air_secs")
packets = stats.get("packets") or {}
payload["packets_recv"] = packets.get("recv")
payload["packets_sent"] = packets.get("sent")
payload["flood_tx"] = packets.get("flood_tx")
payload["direct_tx"] = packets.get("direct_tx")
payload["flood_rx"] = packets.get("flood_rx")
payload["direct_rx"] = packets.get("direct_rx")
return payload
async def _stats_sampling_loop() -> None:
global _latest_stats
while True:
try:
snapshot = await _sample_all_stats()
if snapshot:
_latest_stats = snapshot
elif not radio_manager.is_connected:
_latest_stats = {}
from app.websocket import broadcast_health
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
# Dispatch enriched health snapshot to fanout modules
from app.fanout.manager import fanout_manager
await fanout_manager.broadcast_health_fanout(_build_fanout_payload(snapshot))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Radio stats sampling loop error")
try:
await asyncio.sleep(STATS_SAMPLE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
# ── Public API ────────────────────────────────────────────────────────────
async def start_radio_stats_sampling() -> None:
"""Start the periodic radio stats background task."""
global _stats_task
if _stats_task is not None and not _stats_task.done():
return
_stats_task = asyncio.create_task(_stats_sampling_loop())
async def stop_radio_stats_sampling() -> None:
"""Stop the periodic radio stats background task."""
global _stats_task
if _stats_task is None:
return
if not _stats_task.done():
_stats_task.cancel()
try:
await _stats_task
except asyncio.CancelledError:
pass
_stats_task = None
def get_noise_floor_history() -> dict:
"""Return the current 24-hour in-memory noise floor history snapshot."""
now = int(time.time())
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
samples = [
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
for timestamp, noise_floor_dbm in _noise_floor_samples
if timestamp >= cutoff
]
latest = samples[-1] if samples else None
oldest_timestamp = samples[0]["timestamp"] if samples else None
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
return {
"sample_interval_seconds": STATS_SAMPLE_INTERVAL_SECONDS,
"coverage_seconds": coverage_seconds,
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
"latest_timestamp": latest["timestamp"] if latest else None,
"samples": samples,
}
def get_latest_radio_stats() -> dict[str, Any]:
"""Return the most recent radio stats snapshot (for health endpoint)."""
return dict(_latest_stats)
+1 -2
View File
@@ -13,13 +13,12 @@ import importlib.metadata
import json
import os
import subprocess
import tomllib
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
import tomllib
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
PROJECT_NAME = "remoteterm-meshcore"
+3 -1
View File
@@ -59,7 +59,7 @@ class WebSocketManager:
try:
# Timeout prevents blocking on slow/unresponsive clients
await asyncio.wait_for(connection.send_text(message), timeout=SEND_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
except TimeoutError:
logger.debug("Timeout sending to WebSocket client, marking disconnected")
disconnected.append(connection)
except Exception as e:
@@ -110,6 +110,8 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
asyncio.create_task(fanout_manager.broadcast_message(data))
elif event_type == "raw_packet":
asyncio.create_task(fanout_manager.broadcast_raw(data))
elif event_type == "contact":
asyncio.create_task(fanout_manager.broadcast_contact(data))
def broadcast_error(message: str, details: str | None = None) -> None:
+1 -1
View File
@@ -31,7 +31,7 @@ services:
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000
# MESHCORE_TCP_PORT: 5000
# BLE
# BLE in Docker usually needs additional manual compose changes such as
+2 -2
View File
@@ -348,14 +348,14 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
`AppSettings` currently includes:
- `max_radio_contacts`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `last_message_times`
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `auto_resend_channel`
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
+12 -12
View File
@@ -9,11 +9,11 @@
<meta name="theme-color" content="#111419" />
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
<title>RemoteTerm for MeshCore</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="./favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
<link rel="manifest" href="./site.webmanifest" />
<script>
// Start critical data fetches before React/Vite JS loads.
// Must be in <head> BEFORE the module script so the browser queues these
@@ -42,17 +42,17 @@
});
};
window.__prefetch = {
config: fetchJsonOrThrow('/api/radio/config'),
settings: fetchJsonOrThrow('/api/settings'),
channels: fetchJsonOrThrow('/api/channels'),
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
config: fetchJsonOrThrow('./api/radio/config'),
settings: fetchJsonOrThrow('./api/settings'),
channels: fetchJsonOrThrow('./api/channels'),
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'),
unreads: fetchJsonOrThrow('./api/read-state/unreads'),
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'),
};
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+19 -2
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.3",
"version": "3.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.3",
"version": "3.8.0",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -20,6 +20,7 @@
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4",
@@ -3687,6 +3688,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.8.0",
"version": "3.11.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -28,6 +28,7 @@
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4",
+68 -9
View File
@@ -18,13 +18,15 @@ import {
useUnreadTitle,
useRawPacketStatsSession,
} from './hooks';
import { toast } from './components/ui/sonner';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
import { shouldAutoFocusInput } from './utils/autoFocusInput';
interface ChannelUnreadMarker {
channelId: string;
@@ -87,6 +89,7 @@ export function App() {
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -150,10 +153,8 @@ export function App() {
const {
appSettings,
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
@@ -204,6 +205,38 @@ export function App() {
removeConversationMessagesRef.current(conversationId),
});
const handleToggleFavorite = useCallback(
async (type: 'channel' | 'contact', id: string) => {
// Optimistically toggle the favorite flag
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
try {
await api.toggleFavorite(type, id);
} catch {
// Revert on failure
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
toast.error('Failed to update favorite');
}
},
[setContacts, setChannels]
);
// useConversationRouter is called second — it receives channels/contacts as inputs
const {
activeConversation,
@@ -264,6 +297,21 @@ export function App() {
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
// Auto-focus the message input on conversation change (desktop only by default)
useEffect(() => {
if (!activeConversation) return;
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
// Repeaters show a login form, not a message input
if (activeConversation.type === 'contact') {
const contact = contacts.find((c) => c.public_key === activeConversation.id);
if (contact?.type === CONTACT_TYPE_REPEATER) return;
}
if (!shouldAutoFocusInput()) return;
// Defer to let the input mount/render first
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
return () => cancelAnimationFrame(raf);
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
// so the display reflects the original send order rather than our radio's receipt order.
@@ -290,8 +338,8 @@ export function App() {
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useFaviconBadge(unreadCounts, mentions, favorites);
useUnreadTitle(unreadCounts, favorites);
useFaviconBadge(unreadCounts, mentions, channels);
useUnreadTitle(unreadCounts, contacts, channels);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
@@ -426,6 +474,18 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleRepeaterAutoLogin = useCallback(
(publicKey: string, displayName: string) => {
handleSelectConversationWithTargetReset({
type: 'contact',
id: publicKey,
name: displayName,
});
setRepeaterAutoLoginKey(publicKey);
},
[handleSelectConversationWithTargetReset]
);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
@@ -491,7 +551,6 @@ export function App() {
onMarkAllRead: () => {
void markAllRead();
},
favorites,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
@@ -507,7 +566,6 @@ export function App() {
rawPacketStatsSession,
config,
health,
favorites,
messages: sortedMessages,
preSorted: activeContactIsRoom,
messagesLoading,
@@ -558,6 +616,8 @@ export function App() {
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
};
const searchProps = {
contacts,
@@ -614,7 +674,6 @@ export function App() {
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
onSearchMessagesByKey: (publicKey: string) => {
@@ -632,7 +691,6 @@ export function App() {
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
};
@@ -693,6 +751,7 @@ export function App() {
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
</DistanceUnitProvider>
);
+3 -4
View File
@@ -9,7 +9,6 @@ import type {
ContactAnalytics,
ContactAdvertPathSummary,
FanoutConfig,
Favorite,
HealthStatus,
MaintenanceResult,
Message,
@@ -40,7 +39,7 @@ import type {
UnreadCounts,
} from './types';
const API_BASE = '/api';
const API_BASE = './api';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const hasBody = options?.body !== undefined;
@@ -334,8 +333,8 @@ export const api = {
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
method: 'POST',
body: JSON.stringify({ type, id }),
}),
+20 -2
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
import { useSwipeable } from 'react-swipeable';
import { StatusBar } from './StatusBar';
@@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { CommandPalette } from './CommandPalette';
import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -71,6 +72,7 @@ interface AppShellProps {
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
export function AppShell({
@@ -100,6 +102,7 @@ export function AppShell({
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
onRepeaterAutoLogin,
}: AppShellProps) {
const swipeHandlers = useSwipeable({
onSwipedRight: ({ initial }) => {
@@ -119,6 +122,14 @@ export function AppShell({
preventScrollOnSwipe: false,
});
const handleOpenSettings = useCallback(
(section: SettingsSection) => {
onSettingsSectionChange(section);
if (!showSettings) onToggleSettingsView();
},
[onSettingsSectionChange, onToggleSettingsView, showSettings]
);
const searchMounted = useRef(false);
if (conversationPaneProps.activeConversation?.type === 'search') {
searchMounted.current = true;
@@ -299,7 +310,7 @@ export function AppShell({
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading cracker...
Loading channel finder...
</div>
}
>
@@ -323,6 +334,13 @@ export function AppShell({
onClose={onCloseBulkAddResults}
/>
<CommandPalette
contacts={sidebarProps.contacts}
channels={sidebarProps.channels}
onSelectConversation={sidebarProps.onSelectConversation}
onOpenSettings={handleOpenSettings}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} />
+2 -5
View File
@@ -3,17 +3,15 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip }
import { Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
import type { Channel, ChannelDetail, PathHashWidthStats } from '../types';
interface ChannelInfoPaneProps {
channelKey: string | null;
onClose: () => void;
channels: Channel[];
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
}
@@ -21,7 +19,6 @@ export function ChannelInfoPane({
channelKey,
onClose,
channels,
favorites,
onToggleFavorite,
}: ChannelInfoPaneProps) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
@@ -125,7 +122,7 @@ export function ChannelInfoPane({
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('channel', channel.key)}
>
{isFavorite(favorites, 'channel', channel.key) ? (
{channel.favorite ? (
<>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
@@ -53,17 +53,17 @@ export function ChannelPathHashModeOverrideModal({
{
value: 0,
label: '1-byte hop identifiers',
description: 'Shortest paths, least repeater disambiguation',
description: 'Least repeater disambiguation, up to 63 hops',
},
{
value: 1,
label: '2-byte hop identifiers',
description: 'Better repeater disambiguation',
description: 'Better repeater disambiguation, up to 32 hops',
},
{
value: 2,
label: '3-byte hop identifiers',
description: 'Best repeater disambiguation, longest paths',
description: 'Best repeater disambiguation, up to 21 hops',
},
];
+11 -19
View File
@@ -5,7 +5,6 @@ import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
@@ -13,14 +12,7 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Channel,
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps {
@@ -28,7 +20,6 @@ interface ChatHeaderProps {
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
@@ -49,7 +40,6 @@ export function ChatHeader({
contacts,
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
@@ -105,12 +95,18 @@ export function ChatHeader({
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const isFav =
conversation.type === 'contact'
? (activeContact?.favorite ?? false)
: conversation.type === 'channel'
? (activeChannel?.favorite ?? false)
: false;
const favoriteTitle =
conversation.type === 'contact'
? isFavorite(favorites, 'contact', conversation.id)
? isFav
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
: isFav
? 'Remove from favorites'
: 'Add to favorites';
@@ -359,13 +355,9 @@ export function ChatHeader({
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
title={favoriteTitle}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
{isFav ? (
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+436
View File
@@ -0,0 +1,436 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Hash,
Map,
MessageSquare,
Network,
Radio,
Route,
Search,
Star,
User,
Waypoints,
} from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { getContactDisplayName } from '../utils/pubkey';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_ICONS,
type SettingsSection,
} from './settings/settingsConstants';
import type { Channel, Contact, Conversation } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
const MAX_PER_GROUP = 8;
interface CommandPaletteProps {
contacts: Contact[];
channels: Channel[];
onSelectConversation: (conv: Conversation) => void;
onOpenSettings: (section: SettingsSection) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
interface Searchable {
searchText: string;
}
interface SearchableContact extends Searchable {
contact: Contact;
displayName: string;
}
interface SearchableChannel extends Searchable {
channel: Channel;
}
interface ToolItem extends Searchable {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
}
interface SettingItem extends Searchable {
section: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const TOOL_ITEMS: ToolItem[] = [
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
{
id: 'visualizer',
name: 'Network Visualizer',
icon: Network,
type: 'visualizer',
searchText: 'network visualizer',
},
{
id: 'search',
name: 'Message Search',
icon: Search,
type: 'search',
searchText: 'message search',
},
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
];
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
section,
label: SETTINGS_SECTION_LABELS[section],
icon: SETTINGS_SECTION_ICONS[section],
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
}));
function fuzzyMatch(text: string, query: string): boolean {
let qi = 0;
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) qi++;
}
return qi === query.length;
}
function filterList<T extends Searchable>(items: T[], query: string): T[] {
if (!query) return items.slice(0, MAX_PER_GROUP);
const results: T[] = [];
for (const item of items) {
if (fuzzyMatch(item.searchText, query)) {
results.push(item);
if (results.length >= MAX_PER_GROUP) break;
}
}
return results;
}
export function CommandPalette({
contacts,
channels,
onSelectConversation,
onOpenSettings,
onRepeaterAutoLogin,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
const select = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
const {
favContacts,
favRepeaters,
regularContacts,
repeaters,
rooms,
favChannels,
regularChannels,
} = useMemo(() => {
const fc: SearchableContact[] = [];
const fr: SearchableContact[] = [];
const rc: SearchableContact[] = [];
const rp: SearchableContact[] = [];
const rm: SearchableContact[] = [];
for (const c of contacts) {
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
const entry: SearchableContact = {
contact: c,
displayName,
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
};
if (c.type === CONTACT_TYPE_REPEATER) {
(c.favorite ? fr : rp).push(entry);
} else if (c.type === CONTACT_TYPE_ROOM) {
rm.push(entry);
} else {
(c.favorite ? fc : rc).push(entry);
}
}
const fch: SearchableChannel[] = [];
const rch: SearchableChannel[] = [];
for (const ch of channels) {
const entry: SearchableChannel = {
channel: ch,
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
};
(ch.favorite ? fch : rch).push(entry);
}
return {
favContacts: fc,
favRepeaters: fr,
regularContacts: rc,
repeaters: rp,
rooms: rm,
favChannels: fch,
regularChannels: rch,
};
}, [contacts, channels]);
const lq = query.toLowerCase();
const fTools = filterList(TOOL_ITEMS, lq);
const fSettings = filterList(SETTING_ITEMS, lq);
const fFavContacts = filterList(favContacts, lq);
const fFavRepeaters = filterList(favRepeaters, lq);
const fFavChannels = filterList(favChannels, lq);
const fContacts = filterList(regularContacts, lq);
const fRepeaters = filterList(repeaters, lq);
const fRooms = filterList(rooms, lq);
const fChannels = filterList(regularChannels, lq);
const totalResults =
fTools.length +
fSettings.length +
fFavContacts.length +
fFavRepeaters.length +
fFavChannels.length +
fContacts.length +
fRepeaters.length +
fRooms.length +
fChannels.length;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery('');
}}
>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command shouldFilter={false}>
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
<CommandList>
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
{fTools.length > 0 && (
<CommandGroup heading="Tools">
{fTools.map((tool) => (
<CommandItem
key={tool.id}
onSelect={() =>
select(() =>
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
)
}
>
<tool.icon className="text-muted-foreground" />
<span>{tool.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fSettings.length > 0 && (
<CommandGroup heading="Settings">
{fSettings.map((item) => (
<CommandItem
key={item.section}
onSelect={() => select(() => onOpenSettings(item.section))}
>
<item.icon className="text-muted-foreground" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fFavContacts.length > 0 && (
<ContactGroup
heading="Favorite Contacts"
items={fFavContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
showStar
/>
)}
{fFavRepeaters.length > 0 && (
<RepeaterGroup
heading="Favorite Repeaters"
items={fFavRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
showStar
/>
)}
{fFavChannels.length > 0 && (
<CommandGroup heading="Favorite Channels">
{fFavChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
<Star className="ml-auto h-3 w-3 text-favorite" />
</CommandItem>
))}
</CommandGroup>
)}
{fContacts.length > 0 && (
<ContactGroup
heading="Contacts"
items={fContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fRepeaters.length > 0 && (
<RepeaterGroup
heading="Repeaters"
items={fRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
)}
{fRooms.length > 0 && (
<ContactGroup
heading="Rooms"
items={fRooms}
icon={MessageSquare}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fChannels.length > 0 && (
<CommandGroup heading="Channels">
{fChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function ContactGroup({
heading,
items,
icon: Icon,
showStar,
onSelect,
onSelectConversation,
}: {
heading: string;
items: SearchableContact[];
icon: React.ComponentType<{ className?: string }>;
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.map(({ contact: c, displayName }) => (
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Icon className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
</CommandItem>
))}
</CommandGroup>
);
}
function RepeaterGroup({
heading,
items,
showStar,
onSelect,
onSelectConversation,
onRepeaterAutoLogin,
}: {
heading: string;
items: SearchableContact[];
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.flatMap(({ contact: c, displayName }) => [
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Waypoints className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
</CommandItem>,
<CommandItem
key={`${c.public_key}-acl`}
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
>
<Waypoints className="text-muted-foreground" />
<span>
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
</span>
</CommandItem>,
])}
</CommandGroup>
);
}
+1 -5
View File
@@ -29,7 +29,6 @@ import {
} from '../utils/pathUtils';
import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -42,7 +41,6 @@ import type {
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Favorite,
RadioConfig,
} from '../types';
@@ -67,7 +65,6 @@ interface ContactInfoPaneProps {
onClose: () => void;
contacts: Contact[];
config: RadioConfig | null;
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void;
onSearchMessagesByKey?: (publicKey: string) => void;
@@ -84,7 +81,6 @@ export function ContactInfoPane({
onClose,
contacts,
config,
favorites,
onToggleFavorite,
onNavigateToChannel,
onSearchMessagesByKey,
@@ -384,7 +380,7 @@ export function ContactInfoPane({
onClick={() => onToggleFavorite('contact', contact.public_key)}
title="Favorite contacts stay loaded on the radio for ACK support"
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
{contact.favorite ? (
<>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
+6 -5
View File
@@ -10,7 +10,6 @@ import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
PathDiscoveryResponse,
@@ -42,7 +41,6 @@ interface ConversationPaneProps {
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
preSorted?: boolean;
messagesLoading: boolean;
@@ -81,6 +79,8 @@ interface ConversationPaneProps {
onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
repeaterAutoLoginKey: string | null;
onClearRepeaterAutoLogin: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -119,7 +119,6 @@ export function ConversationPane({
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
preSorted,
messagesLoading,
@@ -152,6 +151,8 @@ export function ConversationPane({
onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
@@ -237,7 +238,6 @@ export function ConversationPane({
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
@@ -252,6 +252,8 @@ export function ConversationPane({
onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
onAutoLoginConsumed={onClearRepeaterAutoLogin}
/>
</Suspense>
);
@@ -266,7 +268,6 @@ export function ConversationPane({
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
+33 -12
View File
@@ -98,7 +98,7 @@ export function CrackerPanel({
.catch((err) => {
console.error('Failed to load wordlist:', err);
toast.error('Failed to load wordlist', {
description: 'Cracking will not be available',
description: 'Channel finder will not be available',
});
});
}, [visible, wordlistLoaded]);
@@ -356,7 +356,7 @@ export function CrackerPanel({
}
} catch (err) {
console.error('Failed to create channel or decrypt historical:', err);
toast.error('Failed to save cracked channel', {
toast.error('Failed to save found channel', {
description:
err instanceof Error ? err.message : 'Channel discovered but could not be saved',
});
@@ -409,7 +409,10 @@ export function CrackerPanel({
const handleStart = () => {
if (!gpuAvailable) {
toast.error('WebGPU not available', {
description: 'Cracking requires Chrome 113+ or Edge 113+ with WebGPU support.',
description:
typeof window !== 'undefined' && !window.isSecureContext
? 'WebGPU requires HTTPS when not on localhost. Set up a certificate or configure your browser to treat this origin as secure.'
: 'Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.',
});
return;
}
@@ -537,7 +540,7 @@ export function CrackerPanel({
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-success font-medium">{crackedCount}</span>
Found: <span className="text-success font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
@@ -581,7 +584,7 @@ export function CrackerPanel({
aria-valuenow={Math.round(progress.percent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Cracking progress"
aria-label="Channel finder progress"
>
<div
className="h-full bg-primary transition-all duration-200"
@@ -593,8 +596,26 @@ export function CrackerPanel({
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive" role="alert">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
<div className="text-sm text-destructive space-y-1.5" role="alert">
<p>WebGPU not available.</p>
{typeof window !== 'undefined' && !window.isSecureContext ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive/90">
<p className="font-medium mb-1">WebGPU requires HTTPS when not on localhost.</p>
<p>To enable it:</p>
<ul className="list-disc ml-4 mt-1 space-y-0.5">
<li>
Set up a TLS certificate (see the HTTPS section of README_ADVANCED.md, or re-run
the Docker setup script which can generate one automatically)
</li>
<li>
Or configure your browser to treat this origin as secure (sometimes called
&ldquo;insecure origins treated as secure&rdquo; in browser flags)
</li>
</ul>
</div>
) : (
<p>Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.</p>
)}
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
@@ -603,10 +624,10 @@ export function CrackerPanel({
</div>
)}
{/* Cracked channels list */}
{/* Found channels list */}
{crackedChannels.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
<div className="text-xs text-muted-foreground mb-1">Found Channels:</div>
<div className="space-y-1">
{crackedChannels.map((channel, i) => (
<div
@@ -630,8 +651,8 @@ export function CrackerPanel({
force payloads as they arrive, testing channel names up to the specified length to discover
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
way of knowing but try as if they are).
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
pick up messages it couldn't crack, attempting them at one longer length.
<strong> Retry failed at n+1</strong> will return to the failed queue and pick up messages
it couldn't find a key for, attempting them at one longer length.
<strong> Try word pairs</strong> will also try every combination of two dictionary words
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time and also result in
@@ -639,7 +660,7 @@ export function CrackerPanel({
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
see if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
may allow accelerated cracking and/or system instability.
may allow accelerated searching and/or system instability.
</p>
</div>
);
+4
View File
@@ -44,6 +44,7 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
export interface MessageInputHandle {
appendText: (text: string) => void;
focus: () => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
@@ -60,6 +61,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
// Focus the input after appending
inputRef.current?.focus();
},
focus: () => {
inputRef.current?.focus();
},
}));
// Calculate character limits based on conversation type
+2 -2
View File
@@ -991,7 +991,7 @@ export function MessageList({
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
{formatTime(msg.sender_timestamp || msg.received_at)}
{formatTime(msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
@@ -1019,7 +1019,7 @@ export function MessageList({
{!showAvatar && (
<>
<span className="text-[0.625rem] text-muted-foreground ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
{formatTime(msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
+179 -26
View File
@@ -11,11 +11,14 @@ import {
Cell,
} from 'recharts';
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button';
import type { Channel, Contact, RawPacket } from '../types';
import {
KNOWN_PAYLOAD_TYPES,
RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot,
type NeighborStat,
@@ -24,9 +27,26 @@ import {
type RawPacketStatsSessionState,
type RawPacketStatsWindow,
} from '../utils/rawPacketStats';
import { createDecoderOptions } from '../utils/rawPacketInspector';
import { getContactDisplayName } from '../utils/pubkey';
import { cn } from '@/lib/utils';
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
function getPacketTypeName(
packet: RawPacket,
decoderOptions?: ReturnType<typeof createDecoderOptions>
): string {
try {
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
if (!decoded.isValid) return 'Unknown';
const name = Utils.getPayloadTypeName(decoded.payloadType);
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
} catch {
return 'Unknown';
}
}
interface RawPacketFeedViewProps {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
@@ -428,6 +448,48 @@ export function RawPacketFeedView({
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const packetsWithTypes = useMemo(
() =>
packets.map((packet) => ({
packet,
payloadType: getPacketTypeName(packet, decoderOptions),
})),
[packets, decoderOptions]
);
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
const filteredPackets = useMemo(() => {
if (allTypesEnabled) return packets;
return packetsWithTypes
.filter(({ payloadType }) => enabledTypes.has(payloadType))
.map(({ packet }) => packet);
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
const handleToggleAll = () => {
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
};
const handleToggleType = (type: string) => {
setEnabledTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const handleOnly = (type: string) => {
setEnabledTypes(new Set([type]));
};
useEffect(() => {
const interval = window.setInterval(() => {
@@ -468,38 +530,129 @@ export function RawPacketFeedView({
);
return (
<>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
<div className="border-b border-border px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="hidden md:block text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
<p className="md:hidden text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
{!mobileFiltersOpen && (
<>
{' · '}
<button
type="button"
className="text-primary hover:text-primary/80 transition-colors"
onClick={() => setMobileFiltersOpen(true)}
>
Show Filters
</button>
</>
)}
</p>
{mobileFiltersOpen && (
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={allTypesEnabled}
onChange={handleToggleAll}
className="rounded"
/>
All
</label>
{KNOWN_PAYLOAD_TYPES.map((type) => (
<span key={type} className="inline-flex items-center gap-1 text-xs">
<label className="flex items-center gap-1 text-foreground cursor-pointer">
<input
type="checkbox"
checked={enabledTypes.has(type)}
onChange={() => handleToggleType(type)}
className="rounded"
/>
{type}
</label>
<button
type="button"
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
onClick={() => handleOnly(type)}
>
(only)
</button>
</span>
))}
</div>
)}
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={allTypesEnabled}
onChange={handleToggleAll}
className="rounded"
/>
All
</label>
{KNOWN_PAYLOAD_TYPES.map((type) => (
<span key={type} className="inline-flex items-center gap-1 text-xs">
<label className="flex items-center gap-1 text-foreground cursor-pointer">
<input
type="checkbox"
checked={enabledTypes.has(type)}
onChange={() => handleToggleType(type)}
className="rounded"
/>
{type}
</label>
<button
type="button"
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
onClick={() => handleOnly(type)}
>
(only)
</button>
</span>
))}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
<RawPacketList
packets={filteredPackets}
channels={channels}
onPacketClick={setSelectedPacket}
/>
</div>
<aside
+15 -11
View File
@@ -9,17 +9,10 @@ import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -41,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
@@ -56,12 +48,13 @@ interface RepeaterDashboardProps {
onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
autoLoginAndLoadAll?: boolean;
onAutoLoginConsumed?: () => void;
}
export function RepeaterDashboard({
conversation,
contacts,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
@@ -76,6 +69,8 @@ export function RepeaterDashboard({
onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
autoLoginAndLoadAll,
onAutoLoginConsumed,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -134,7 +129,16 @@ export function RepeaterDashboard({
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
// Command palette "ACL login + load all" auto-action
const autoLoginConsumedRef = useRef(false);
useEffect(() => {
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
autoLoginConsumedRef.current = true;
onAutoLoginConsumed?.();
void loginAsGuest().then(() => loadAll());
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
const isFav = contact?.favorite ?? false;
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
+2 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
interface RepeaterLoginProps {
repeaterName: string;
@@ -64,7 +65,7 @@ export function RepeaterLogin({
placeholder={passwordPlaceholder}
aria-label="Repeater password"
disabled={loading}
autoFocus
autoFocus={shouldAutoFocusInput()}
/>
<label
+6 -17
View File
@@ -19,7 +19,6 @@ import {
type Contact,
type Channel,
type Conversation,
type Favorite,
} from '../types';
import {
buildSidebarSectionSortOrders,
@@ -36,7 +35,6 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
@@ -106,7 +104,6 @@ interface SidebarProps {
crackerRunning: boolean;
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
@@ -135,7 +132,6 @@ export function Sidebar({
crackerRunning,
onToggleCracker,
onMarkAllRead,
favorites,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
@@ -488,22 +484,16 @@ export function Sidebar({
nonFavoriteRooms,
nonFavoriteRepeaters,
} = useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favChannels = filteredChannels.filter((c) => c.favorite);
const favContacts = [
...filteredNonRepeaterContacts,
...filteredRooms,
...filteredRepeaters,
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
const nonFavContacts = filteredNonRepeaterContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRooms = filteredRooms.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
].filter((c) => c.favorite);
const nonFavChannels = filteredChannels.filter((c) => !c.favorite);
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite);
const nonFavRooms = filteredRooms.filter((c) => !c.favorite);
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite);
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
@@ -522,7 +512,6 @@ export function Sidebar({
filteredNonRepeaterContacts,
filteredRooms,
filteredRepeaters,
favorites,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
+57 -2
View File
@@ -1,10 +1,24 @@
import { useEffect, useState } from 'react';
import { Menu, Moon, Sun } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
BatteryFull,
BatteryLow,
BatteryMedium,
BatteryWarning,
Menu,
Moon,
Sun,
} from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
import {
BATTERY_DISPLAY_CHANGE_EVENT,
getShowBatteryPercent,
getShowBatteryVoltage,
mvToPercent,
} from '../utils/batteryDisplay';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -22,6 +36,35 @@ export function StatusBar({
onSettingsClick,
onMenuClick,
}: StatusBarProps) {
const [showBatteryPercent, setShowBatteryPercent] = useState(getShowBatteryPercent);
const [showBatteryVoltage, setShowBatteryVoltage] = useState(getShowBatteryVoltage);
useEffect(() => {
const handler = () => {
setShowBatteryPercent(getShowBatteryPercent());
setShowBatteryVoltage(getShowBatteryVoltage());
};
window.addEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
return () => window.removeEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
}, []);
const batteryMv = health?.radio_stats?.battery_mv;
const batteryInfo = useMemo(() => {
if ((!showBatteryPercent && !showBatteryVoltage) || !batteryMv || batteryMv <= 0) return null;
const pct = mvToPercent(batteryMv);
const Icon =
pct >= 80 ? BatteryFull : pct >= 40 ? BatteryMedium : pct >= 15 ? BatteryLow : BatteryWarning;
const color =
pct >= 40 ? 'text-status-connected' : pct >= 15 ? 'text-warning' : 'text-destructive';
const label =
showBatteryPercent && showBatteryVoltage
? `${pct}% (${batteryMv}mV)`
: showBatteryPercent
? `${pct}%`
: `${batteryMv}mV`;
return { pct, Icon, color, label, mv: batteryMv };
}, [batteryMv, showBatteryPercent, showBatteryVoltage]);
const radioState =
health?.radio_state ??
(health?.radio_initializing
@@ -119,6 +162,18 @@ export function StatusBar({
<span className="hidden lg:inline text-muted-foreground">{statusLabel}</span>
</div>
{connected && batteryInfo && (
<div
className={cn('flex items-center gap-1', batteryInfo.color)}
title={`Battery: ${batteryInfo.pct}% (${(batteryInfo.mv / 1000).toFixed(2)}V)`}
role="status"
aria-label={`Battery ${batteryInfo.pct} percent`}
>
<batteryInfo.Icon className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline text-[0.6875rem]">{batteryInfo.label}</span>
</div>
)}
{config && (
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
+151 -2
View File
@@ -9,7 +9,8 @@ import type {
RadioTraceResponse,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
import { calculateDistance, formatDistance, isValidLocation } from '../utils/pathUtils';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
@@ -28,6 +29,48 @@ import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type CustomHopBytes = 1 | 2 | 4;
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
const MAX_RECENT_TRACES = 5;
interface SavedTraceHop {
kind: 'repeater' | 'custom';
publicKey?: string;
hopHex?: string;
hopBytes?: CustomHopBytes;
displayName: string;
}
interface SavedTrace {
hops: SavedTraceHop[];
ranAt: number;
}
function loadRecentTraces(): SavedTrace[] {
try {
const raw = localStorage.getItem(RECENT_TRACES_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_TRACES) : [];
} catch {
return [];
}
}
function saveRecentTrace(trace: SavedTrace): void {
try {
const existing = loadRecentTraces();
// Dedupe by hop signature
const sig = trace.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',');
const deduped = existing.filter(
(t) => t.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',') !== sig
);
const updated = [trace, ...deduped].slice(0, MAX_RECENT_TRACES);
localStorage.setItem(RECENT_TRACES_KEY, JSON.stringify(updated));
} catch {
// localStorage may be disabled
}
}
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
@@ -144,6 +187,7 @@ function TraceNodeRow({
}
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
const { distanceUnit } = useDistanceUnit();
const [searchQuery, setSearchQuery] = useState('');
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
@@ -154,6 +198,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
const [customHopError, setCustomHopError] = useState<string | null>(null);
const [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
@@ -272,6 +317,56 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
clearPendingResult();
};
const handleLoadRecentTrace = async (trace: SavedTrace) => {
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
if (h.kind === 'repeater' && h.publicKey) {
return {
id: nextDraftHopId('repeater', i),
kind: 'repeater' as const,
publicKey: h.publicKey,
};
}
return {
id: nextDraftHopId('custom', i),
kind: 'custom' as const,
hopHex: h.hopHex ?? '',
hopBytes: h.hopBytes ?? (1 as CustomHopBytes),
};
});
setDraftHops(hops);
// Determine hop hash bytes from the loaded hops
const customHop = hops.find((h) => h.kind === 'custom');
const hopHashBytes: CustomHopBytes = customHop?.hopBytes ?? 4;
// Run the trace immediately
const runToken = activeRunTokenRef.current + 1;
activeRunTokenRef.current = runToken;
setLoading(true);
setError(null);
setResult(null);
try {
const traceResult = await onRunTracePath(
hopHashBytes,
hops.map((hop) =>
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
)
);
if (activeRunTokenRef.current !== runToken) return;
setResult(traceResult);
// Re-save to bump this trace to the top of recents
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
saveRecentTrace(savedTrace);
setRecentTraces(loadRecentTraces());
} catch (err) {
if (activeRunTokenRef.current !== runToken) return;
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (activeRunTokenRef.current === runToken) setLoading(false);
}
};
const handleRunTrace = async () => {
if (draftHops.length === 0) {
return;
@@ -292,6 +387,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
return;
}
setResult(traceResult);
// Persist to recent traces
const savedHops: SavedTraceHop[] = draftHops.map((hop) => {
if (hop.kind === 'repeater') {
const c = repeatersByKey.get(hop.publicKey);
return {
kind: 'repeater',
publicKey: hop.publicKey,
displayName: getContactDisplayName(c?.name, hop.publicKey, c?.last_advert ?? null),
};
}
return {
kind: 'custom',
hopHex: hop.hopHex,
hopBytes: hop.hopBytes,
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
};
});
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
saveRecentTrace(trace);
setRecentTraces(loadRecentTraces());
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
@@ -422,7 +538,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
</div>
{sortMode === 'distance' && distanceKm !== null ? (
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
{distanceKm.toFixed(1)} km away
{formatDistance(distanceKm, distanceUnit)} away
</div>
) : null}
{selectedCount > 0 ? (
@@ -453,6 +569,39 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
{recentTraces.length > 0 && (
<div className="mt-2">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1">
Rerun a recent trace:
</div>
<div className="flex flex-wrap gap-1.5">
{recentTraces.map((trace, i) => {
const label = trace.hops
.map((h) => {
if (h.kind === 'repeater' && h.publicKey) {
const shortKey = h.publicKey.slice(0, 12);
return h.displayName !== shortKey
? `${h.displayName} (${shortKey})`
: shortKey;
}
return h.displayName;
})
.join(' → ');
return (
<button
key={i}
type="button"
className="rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent transition-colors truncate max-w-full disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading}
onClick={() => handleLoadRecentTrace(trace)}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div>
{draftHops.length > 0 ? (
<Button
@@ -13,6 +13,8 @@ export function ConsolePane({
}) {
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(loading);
// Auto-scroll to bottom on new entries
useEffect(() => {
@@ -21,6 +23,14 @@ export function ConsolePane({
}
}, [history]);
// Refocus input after command completes
useEffect(() => {
if (prevLoadingRef.current && !loading) {
inputRef.current?.focus();
}
prevLoadingRef.current = loading;
}, [loading]);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
@@ -59,6 +69,7 @@ export function ConsolePane({
</div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input
ref={inputRef}
type="text"
autoComplete="off"
name="console-input"
@@ -90,6 +90,15 @@ export function TelemetryHistoryPane({
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
const yDomain = useMemo<[number, number] | undefined>(() => {
if (metric !== 'battery_volts' || chartData.length === 0) return undefined;
const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[];
if (values.length === 0) return [3, 5];
const lo = Math.min(...values);
const hi = Math.max(...values);
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
}, [metric, chartData]);
const handleToggle = async () => {
setToggling(true);
try {
@@ -208,6 +217,7 @@ export function TelemetryHistoryPane({
tickFormatter={formatTime}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
@@ -131,7 +131,7 @@ export function SettingsAboutSection({
<div className="text-center">
<a
href="/api/debug"
href="./api/debug"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary hover:underline"
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -7,7 +7,13 @@ import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
TelemetryHistoryEntry,
} from '../../types';
export function SettingsDatabaseSection({
appSettings,
@@ -48,11 +54,35 @@ export function SettingsDatabaseSection({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]);
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 days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
@@ -242,28 +272,49 @@ export function SettingsDatabaseSection({
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-1">
<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="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 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>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
{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>
<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>
);
})}
@@ -1666,7 +1666,8 @@ function AppriseConfigEditor({
rows={4}
/>
<p className="text-xs text-muted-foreground">
One URL per line. All URLs receive every matched notification.
One URL per line. All URLs receive every matched notification. For Matrix room version 12
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
</p>
</div>
@@ -27,6 +27,14 @@ import {
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
import {
BATTERY_DISPLAY_CHANGE_EVENT,
getShowBatteryPercent,
setShowBatteryPercent as saveBatteryPercent,
getShowBatteryVoltage,
setShowBatteryVoltage as saveBatteryVoltage,
} from '../../utils/batteryDisplay';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -48,6 +56,9 @@ export function SettingsLocalSection({
});
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -129,85 +140,6 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 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 disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
@@ -233,33 +165,165 @@ export function SettingsLocalSection({
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<div className="space-y-3">
<Label>UI Tweaks</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryPercent}
onChange={(e) => {
const v = e.target.checked;
setBatteryPercent(v);
saveBatteryPercent(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery percentage in status bar</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryVoltage}
onChange={(e) => {
const v = e.target.checked;
setBatteryVoltage(v);
saveBatteryVoltage(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery voltage in status bar</span>
</label>
{(batteryPercent || batteryVoltage) && (
<p className="text-xs text-muted-foreground ml-7">
Battery data updates every 60 seconds and may take up to a minute to appear after
connecting.
</p>
)}
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 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 disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
</div>
</div>
</div>
);
}
@@ -570,9 +570,9 @@ export function SettingsRadioSection({
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 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">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
<option value="0">1 byte up to 63 hops (default)</option>
<option value="1">2 bytes up to 32 hops</option>
<option value="2">3 bytes up to 21 hops</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
@@ -42,6 +42,87 @@ function formatTime(ts: number): string {
});
}
function formatDateTime(ts: number): string {
const d = new Date(ts * 1000);
return (
d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
);
}
function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) {
// Fill gaps so hours with zero packets still appear on the chart
const filled: { timestamp: number; count: number }[] = [];
if (buckets.length > 0) {
const first = buckets[0].timestamp;
const last = buckets[buckets.length - 1].timestamp;
const byTs = new Map(buckets.map((b) => [b.timestamp, b.count]));
for (let ts = first; ts <= last; ts += 3600) {
filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 });
}
}
const data = filled.map((b, i) => ({
idx: i,
label: formatDateTime(b.timestamp),
count: b.count,
}));
// Show ~6 evenly-spaced tick labels
const tickCount = Math.min(6, data.length);
const tickIndices: number[] = [];
if (data.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1)));
}
}
return (
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, data.length - 1]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => data[idx]?.label ?? ''}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => data[Number(idx)]?.label ?? ''}
formatter={(value) => [`${Number(value).toLocaleString()} packets`, 'Count']}
/>
<Area
type="monotone"
dataKey="count"
stroke="#0ea5e9"
fill="#0ea5e9"
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, fill: '#0ea5e9', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
</AreaChart>
</ResponsiveContainer>
);
}
function NoiseFloorChart({
samples,
}: {
@@ -241,6 +322,17 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</div>
</div>
{/* Packets per Hour (72h) */}
{stats.packets_per_hour_72h?.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
</div>
</>
)}
<Separator />
{/* Path Hash Width */}
@@ -355,7 +447,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
)}
{/* Noise Floor */}
{stats.noise_floor_24h.supported !== false && (
{stats.noise_floor_24h && (
<>
<Separator />
<div>
@@ -376,14 +468,14 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
) : stats.noise_floor_24h.samples.length === 0 ? (
<p className="text-sm text-muted-foreground">
No noise floor samples collected yet. Samples are collected every five minutes,
and retained until server restart.
No noise floor samples collected yet. Samples are collected every minute and
retained until server restart.
</p>
) : (
<p className="text-sm text-muted-foreground">
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
More data needed for a chart. Samples are collected every five minutes, and
retained until server restart.
More data needed for a chart. Samples are collected every minute and retained
until server restart.
</p>
)}
</div>
+141
View File
@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
function CommandDialog({ children, ...props }: React.ComponentProps<typeof Dialog>) {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
}
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+12 -42
View File
@@ -3,16 +3,11 @@ import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { initLastMessageTimes } from '../utils/conversationState';
import { isFavorite } from '../utils/favorites';
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
import type { AppSettings, AppSettingsUpdate } from '../types';
export function useAppSettings() {
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
// Stable empty array prevents a new reference every render when there are none.
const emptyFavorites = useRef<Favorite[]>([]).current;
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
// One-time migration guard
const hasMigratedRef = useRef(false);
@@ -85,32 +80,6 @@ export function useAppSettings() {
}
}, []);
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
setAppSettings((prev) => {
if (!prev) return prev;
const currentFavorites = prev.favorites ?? [];
const wasFavorited = isFavorite(currentFavorites, type, id);
const optimisticFavorites = wasFavorited
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
: [...currentFavorites, { type, id }];
return { ...prev, favorites: optimisticFavorites };
});
try {
const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle favorite:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
toast.error('Failed to update favorite');
}
}, []);
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
@@ -151,7 +120,7 @@ export function useAppSettings() {
hasMigratedRef.current = true;
const FAVORITES_KEY = 'remoteterm-favorites';
let localFavorites: Favorite[] = [];
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
if (stored) localFavorites = JSON.parse(stored);
@@ -161,25 +130,26 @@ export function useAppSettings() {
if (localFavorites.length === 0) return;
const migrate = async () => {
try {
for (const f of localFavorites) {
let migrated = 0;
for (const f of localFavorites) {
try {
await api.toggleFavorite(f.type, f.id);
migrated++;
} catch {
// Entity may have been deleted; skip and continue
}
localStorage.removeItem(FAVORITES_KEY);
await fetchAppSettings();
} catch (err) {
console.error('Failed to migrate legacy favorites:', err);
}
localStorage.removeItem(FAVORITES_KEY);
// Reload so contacts/channels pick up the new favorite flags
if (migrated > 0) window.location.reload();
};
migrate();
}, [appSettings, fetchAppSettings]);
}, [appSettings]);
return {
appSettings,
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
@@ -4,7 +4,7 @@ import type { Message } from '../types';
import { getStateKey } from '../utils/conversationState';
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
const NOTIFICATION_ICON_PATH = './favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
@@ -372,6 +372,8 @@ export function useConversationMessages(
const olderAbortControllerRef = useRef<AbortController | null>(null);
const newerAbortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const activeConversationRef = useRef(activeConversation);
activeConversationRef.current = activeConversation;
const latestReconcileRequestIdRef = useRef(0);
const pendingReconnectReconcileRef = useRef(false);
const messagesRef = useRef<Message[]>([]);
@@ -664,9 +666,11 @@ export function useConversationMessages(
}, [activeConversation]);
const reconcileOnReconnect = useCallback(() => {
if (!isMessageConversation(activeConversation)) {
return;
}
// Read the current conversation from the ref rather than closing over
// activeConversation, so that a conversation switch during WS reconnect
// targets the right conversation instead of a stale capture.
const current = activeConversationRef.current;
if (!isMessageConversation(current)) return;
if (hasNewerMessagesRef.current) {
pendingReconnectReconcileRef.current = true;
@@ -677,8 +681,8 @@ export function useConversationMessages(
const controller = new AbortController();
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
reconcileFromBackend(activeConversation, controller.signal, requestId);
}, [activeConversation, reconcileFromBackend]);
reconcileFromBackend(current, controller.signal, requestId);
}, [reconcileFromBackend]);
useEffect(() => {
if (abortControllerRef.current) {
+37 -21
View File
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef } from 'react';
import type { Favorite } from '../types';
import type { Channel, Contact } from '../types';
import { getStateKey } from '../utils/conversationState';
const APP_TITLE = 'RemoteTerm for MeshCore';
const UNREAD_APP_TITLE = 'RemoteTerm';
const BASE_FAVICON_PATH = '/favicon.svg';
const BASE_FAVICON_PATH = './favicon.svg';
const GREEN_BADGE_FILL = '#16a34a';
const RED_BADGE_FILL = '#dc2626';
const BADGE_CENTER = 750;
@@ -25,12 +25,11 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
function getUnreadFavoriteChannelCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
channels: Channel[]
): number {
return favorites.reduce(
(sum, favorite) =>
sum +
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
return channels.reduce(
(sum, channel) =>
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0),
0
);
}
@@ -41,19 +40,29 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
export function getFavoriteUnreadCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
contacts: Contact[],
channels: Channel[]
): number {
return favorites.reduce((sum, favorite) => {
const stateKey = getStateKey(favorite.type, favorite.id);
return sum + (unreadCounts[stateKey] || 0);
}, 0);
let sum = 0;
for (const contact of contacts) {
if (contact.favorite) {
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0;
}
}
for (const channel of channels) {
if (channel.favorite) {
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
}
}
return sum;
}
export function getUnreadTitle(
unreadCounts: Record<string, number>,
favorites: Favorite[]
contacts: Contact[],
channels: Channel[]
): string {
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels);
if (unreadCount <= 0) {
return APP_TITLE;
}
@@ -65,13 +74,13 @@ export function getUnreadTitle(
export function deriveFaviconBadgeState(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
channels: Channel[]
): FaviconBadgeState {
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
return 'red';
}
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) {
return 'green';
}
@@ -128,8 +137,15 @@ function applyFaviconHref(href: string): void {
upsertFaviconLinks('shortcut icon', href);
}
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
export function useUnreadTitle(
unreadCounts: Record<string, number>,
contacts: Contact[],
channels: Channel[]
): void {
const title = useMemo(
() => getUnreadTitle(unreadCounts, contacts, channels),
[contacts, channels, unreadCounts]
);
useEffect(() => {
document.title = title;
@@ -143,12 +159,12 @@ export function useUnreadTitle(unreadCounts: Record<string, number>, favorites:
export function useFaviconBadge(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
channels: Channel[]
): void {
const objectUrlRef = useRef<string | null>(null);
const badgeState = useMemo(
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
[favorites, mentions, unreadCounts]
() => deriveFaviconBadgeState(unreadCounts, mentions, channels),
[channels, mentions, unreadCounts]
);
useEffect(() => {
+11 -11
View File
@@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts?limit=100&offset=0');
expect(url).toBe('./api/contacts?limit=100&offset=0');
});
it('builds repeater advert path endpoint query', async () => {
@@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => {
await api.getRepeaterAdvertPaths(12);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12');
});
});
@@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendDirectMessage('abc123', 'hello');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/messages/direct');
expect(url).toBe('./api/messages/direct');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({
destination: 'abc123',
@@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => {
await api.updateRadioConfig({ name: 'NewName' });
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/config');
expect(url).toBe('./api/radio/config');
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
});
@@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => {
await api.setPrivateKey('my-secret-key');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/private-key');
expect(url).toBe('./api/radio/private-key');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
});
@@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => {
await api.discoverMesh('repeaters');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/discover');
expect(url).toBe('./api/radio/discover');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
});
@@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => {
await api.deleteContact('pubkey123');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/pubkey123');
expect(url).toBe('./api/contacts/pubkey123');
expect(options.method).toBe('DELETE');
});
@@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendAdvertisement();
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(url).toBe('./api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
});
@@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendAdvertisement('zero_hop');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(url).toBe('./api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
});
@@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => {
});
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('/api/messages?');
expect(url).toContain('./api/messages?');
expect(url).toContain('limit=50');
expect(url).toContain('offset=10');
expect(url).toContain('type=PRIV');
@@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => {
await api.getMessages();
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/messages');
expect(url).toBe('./api/messages');
});
});
});
+5 -11
View File
@@ -187,7 +187,6 @@ const baseConfig = {
const baseSettings = {
max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -204,6 +203,7 @@ const publicChannel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
describe('App favorite toggle flow', () => {
@@ -216,8 +216,9 @@ describe('App favorite toggle flow', () => {
mocks.api.getChannels.mockResolvedValue([publicChannel]);
mocks.api.getContacts.mockResolvedValue([]);
mocks.api.toggleFavorite.mockResolvedValue({
...baseSettings,
favorites: [{ type: 'channel', id: publicChannel.key }],
type: 'channel',
id: publicChannel.key,
favorite: true,
});
});
@@ -239,11 +240,8 @@ describe('App favorite toggle flow', () => {
});
});
it('rolls back favorite state by refetching settings on toggle failure', async () => {
it('rolls back favorite state on toggle failure', async () => {
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
mocks.api.getSettings
.mockResolvedValueOnce({ ...baseSettings }) // initial load
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
render(<App />);
@@ -257,10 +255,6 @@ describe('App favorite toggle flow', () => {
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
});
await waitFor(() => {
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
});
+1 -1
View File
@@ -215,7 +215,6 @@ describe('App search jump target handling', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -230,6 +229,7 @@ describe('App search jump target handling', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
},
]);
mocks.api.getContacts.mockResolvedValue([]);
+5 -1
View File
@@ -145,6 +145,7 @@ const publicChannel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
describe('App startup hash resolution', () => {
@@ -166,7 +167,6 @@ describe('App startup hash resolution', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -247,6 +247,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -278,6 +279,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -308,6 +310,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -345,6 +348,7 @@ describe('App startup hash resolution', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { mvToPercent, formatBatteryLabel } from '../utils/batteryDisplay';
describe('mvToPercent', () => {
it('clamps to 100 above table ceiling', () => {
expect(mvToPercent(4500)).toBe(100);
expect(mvToPercent(4190)).toBe(100);
});
it('clamps to 0 below table floor', () => {
expect(mvToPercent(3100)).toBe(0);
expect(mvToPercent(2800)).toBe(0);
});
it('returns exact table values at boundaries', () => {
expect(mvToPercent(4050)).toBe(90);
expect(mvToPercent(3630)).toBe(40);
});
it('interpolates between table entries', () => {
// Midpoint between 3630 (40%) and 3720 (50%) = 3675 → ~45%
const mid = mvToPercent(3675);
expect(mid).toBeGreaterThan(40);
expect(mid).toBeLessThan(50);
});
});
describe('formatBatteryLabel', () => {
it('returns null when both toggles are off', () => {
expect(formatBatteryLabel(4050, false, false)).toBeNull();
});
it('returns percentage only', () => {
expect(formatBatteryLabel(4050, true, false)).toBe('90%');
});
it('returns voltage only', () => {
expect(formatBatteryLabel(4050, false, true)).toBe('4050mV');
});
it('returns combined when both enabled', () => {
expect(formatBatteryLabel(4050, true, true)).toBe('90% (4050mV)');
});
});
@@ -17,6 +17,7 @@ describe('BulkAddChannelResultModal', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
{
key: 'BB'.repeat(16),
@@ -24,6 +25,7 @@ describe('BulkAddChannelResultModal', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
existing_count: 3,
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChannelInfoPane } from '../components/ChannelInfoPane';
import type { Channel, ChannelDetail, Favorite } from '../types';
import type { Channel, ChannelDetail } from '../types';
// Mock the api module
vi.mock('../api', () => ({
@@ -15,7 +15,7 @@ import { api } from '../api';
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
}
function makeDetail(channel: Channel): ChannelDetail {
@@ -41,7 +41,6 @@ const noop = () => {};
const baseProps = {
onClose: noop,
favorites: [] as Favorite[],
onToggleFavorite: noop,
};
@@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import type { Channel, Contact, Conversation, PathDiscoveryResponse } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
}
const noop = () => {};
@@ -15,7 +15,6 @@ const noop = () => {};
const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
@@ -186,6 +185,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -237,6 +237,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -286,6 +287,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1 -1
View File
@@ -47,6 +47,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: 1699990000,
@@ -90,7 +91,6 @@ const baseProps = {
onClose: () => {},
contacts: [] as Contact[],
config: null,
favorites: [],
onToggleFavorite: () => {},
onSearchMessagesByKey: vi.fn(),
onSearchMessagesByName: vi.fn(),
+8 -10
View File
@@ -3,15 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
} from '../types';
import type { Channel, Contact, Conversation, HealthStatus, Message, RadioConfig } from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
@@ -97,6 +89,7 @@ const channel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const message: Message = {
@@ -134,7 +127,6 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
loadingOlder: false,
@@ -166,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
repeaterAutoLoginKey: null,
onClearRepeaterAutoLogin: vi.fn(),
...overrides,
};
}
@@ -205,6 +199,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -278,6 +273,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -372,6 +368,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
@@ -408,6 +405,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
+1
View File
@@ -278,6 +278,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: true,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+2
View File
@@ -40,6 +40,7 @@ describe('MapView', () => {
lon: -74,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -74,6 +75,7 @@ describe('MapView', () => {
lon: -73,
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1
View File
@@ -95,6 +95,7 @@ describe('MessageList channel sender rendering', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+8 -8
View File
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
import {
findLinkedChannelReferences,
formatTime,
isValidLinkedChannelName,
HASHTAG_CHANNEL_NAME_PATTERN,
parseSenderFromText,
} from '../utils/messageParser';
@@ -103,16 +103,16 @@ describe('formatTime', () => {
describe('linked channel references', () => {
it('accepts lowercase alphanumeric names with single dashes', () => {
expect(isValidLinkedChannelName('ops')).toBe(true);
expect(isValidLinkedChannelName('ops-1')).toBe(true);
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops')).toBe(true);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-1')).toBe(true);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('1-2-3')).toBe(true);
});
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
expect(isValidLinkedChannelName('Ops')).toBe(false);
expect(isValidLinkedChannelName('-ops')).toBe(false);
expect(isValidLinkedChannelName('ops-')).toBe(false);
expect(isValidLinkedChannelName('ops--room')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('Ops')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('-ops')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-')).toBe(false);
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops--room')).toBe(false);
});
it('finds standalone linked channel references in message text', () => {
@@ -62,6 +62,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1
View File
@@ -30,6 +30,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -23,6 +23,7 @@ const BOT_CHANNEL: Channel = {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
};
const BOT_PACKET: RawPacket = {
@@ -14,6 +14,7 @@ const TEST_CHANNEL: Channel = {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
};
const COLLIDING_TEST_CHANNEL: Channel = {
@@ -87,6 +88,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+4 -4
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RepeaterDashboard } from '../components/RepeaterDashboard';
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
import type { Contact, Conversation, Favorite } from '../types';
import type { Contact, Conversation } from '../types';
// Mock the hook — typed as mutable version of the return type
const mockHook: {
@@ -99,18 +99,16 @@ const contacts: Contact[] = [
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
const favorites: Favorite[] = [];
const defaultProps = {
conversation,
contacts,
favorites,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
@@ -337,6 +335,7 @@ describe('RepeaterDashboard', () => {
lon: 115.87,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -397,6 +396,7 @@ describe('RepeaterDashboard', () => {
lon: 115.87,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -40,6 +40,7 @@ const roomContact: Contact = {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+9 -1
View File
@@ -35,7 +35,14 @@ function createSearchResult(overrides: Partial<Message> = {}): Message {
const defaultProps = {
contacts: [],
channels: [
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
{
key: 'ABC123',
name: 'Public',
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
onNavigateToMessage: vi.fn(),
};
@@ -239,6 +246,7 @@ describe('SearchView', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
@@ -25,7 +25,7 @@ describe('SettingsAboutSection', () => {
);
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
expect(link).toHaveAttribute('href', '/api/debug');
expect(link).toHaveAttribute('href', './api/debug');
expect(link).toHaveAttribute('target', '_blank');
});
});

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