58 Commits
3.4.0 ... 3.5.0

Author SHA1 Message Date
Jack Kingsman
5213c8c84c Updating changelog + build for 3.5.0 2026-03-19 21:53:45 -07:00
Jack Kingsman
33c2b0c948 Be better about identity resolution for stats view 2026-03-19 21:42:39 -07:00
Jack Kingsman
b021a4a8ac Fix e2e tests for contact stuff 2026-03-19 21:39:18 -07:00
Jack Kingsman
c74fdec10b Add database information to debug endpoint 2026-03-19 21:17:23 -07:00
Jack Kingsman
cf314e02ff Be cleaner about message cache dedupe after trimming inactive convos 2026-03-19 21:03:20 -07:00
Jack Kingsman
8ae600d010 Docs updates 2026-03-19 20:58:49 -07:00
Jack Kingsman
fdd82e1f77 Clean up orphaned contact child rows and add foreign key enforcement 2026-03-19 20:56:36 -07:00
Jack Kingsman
9d129260fd Fix up the header collapse to be less terrible 2026-03-19 20:53:24 -07:00
Jack Kingsman
2b80760696 Add DB entry for outgoing inside the radio lock (didn't we just do the opposite?) 2026-03-19 20:43:35 -07:00
Jack Kingsman
c2655c1809 Add basic room server support. Closes #78.
Allow basic room server usage
2026-03-19 20:28:44 -07:00
Jack Kingsman
cee7103ec6 Don't log on missed login ack and don't make standalone contacts for repeater users 2026-03-19 20:26:10 -07:00
Jack Kingsman
d05312c157 Add password-remember + warning on save 2026-03-19 20:10:59 -07:00
Jack Kingsman
5b166c4b66 Add room server 2026-03-19 19:22:40 -07:00
Jack Kingsman
dbe2915635 Use metric by default 2026-03-19 17:56:04 -07:00
Jack Kingsman
2337d7b592 Remove Apprise duplicate names. Closes #88. 2026-03-19 17:44:51 -07:00
Jack Kingsman
62080424bb Multi-ack. Closes #81. 2026-03-19 17:30:34 -07:00
Jack Kingsman
1ae76848fe Improve test coverage 2026-03-19 17:19:35 -07:00
Jack Kingsman
45ed430580 Allow favorites to be sorted. Closes #91. 2026-03-19 17:05:34 -07:00
Jack Kingsman
5f8ce16855 Fix spacing around byte display on packet detail. Closes #93. 2026-03-19 16:58:27 -07:00
Jack Kingsman
b79249c4a0 Add more realistic hop stats display 2026-03-19 16:49:06 -07:00
Jack Kingsman
85d1a940dc Update meshcore for three byte path failures 2026-03-19 09:57:06 -07:00
Jack Kingsman
b85d451e26 Add packet feed clickable packet inspection. Closes #75 again. 2026-03-19 09:49:14 -07:00
Jack Kingsman
41a297c944 GIVE ME SMOOTS. Closes #87. 2026-03-18 22:43:34 -07:00
Jack Kingsman
41d64d86d4 Expand docker version testing coverage. Closes #84. 2026-03-18 22:09:44 -07:00
Jack Kingsman
bd336e3ee2 Add fancy metrics view for packet feed. Closes #75. 2026-03-18 22:01:10 -07:00
Jack Kingsman
cf585cdf87 Unread DMs are always red. Closes #86. 2026-03-18 21:05:40 -07:00
Jack Kingsman
417a583696 Use proper version formatting. Closes #70. 2026-03-18 20:50:56 -07:00
Jack Kingsman
541dba6a75 Fix migration to not import historical advert path 2026-03-19 03:45:51 +00:00
Jack Kingsman
720b8be64f Add e2e test 2026-03-19 03:45:51 +00:00
Jack Kingsman
2b5083e889 Doc updates 2026-03-19 03:45:51 +00:00
Jack Kingsman
5975006cf7 Dupe code cleanup 2026-03-19 03:45:51 +00:00
Jack Kingsman
69e09378f5 Pass 1 on PATH integration 2026-03-19 03:45:51 +00:00
Jack Kingsman
b832239e22 Add zero-hop impulse advert. Closes #83. 2026-03-18 19:59:08 -07:00
Jack Kingsman
d8e22ef4af Stop autofocus stealing of the cracker panel. Closes #80. 2026-03-18 17:53:42 -07:00
Jack Kingsman
ffc5d75a58 Use the actual remoteterm version for the client_version tag so letsmesh rollups work better 2026-03-18 17:46:10 -07:00
Jack Kingsman
350c85ca6d Behave better around DM dedupe/storage. Closes #77. 2026-03-18 17:40:11 -07:00
Jack Kingsman
4d5f0087cc Fix sidebar ordering for contacts by advert. Closes #69. 2026-03-17 21:04:57 -07:00
Jack Kingsman
e33bc553f5 improve MQTT error bubble up and massage communitymqtt + debug etc. for version management. Closes #70 2026-03-17 20:33:09 -07:00
Jack Kingsman
020acbda02 Do better DM retry to align with stndard firmware retry (but so that we can track the acks). Closes #73. 2026-03-17 18:12:07 -07:00
Jack Kingsman
d5b8f7d462 Prevent contact status bar jitter. Closes #68. 2026-03-17 16:28:05 -07:00
Jack Kingsman
bc16d804e9 Log the node time on startup 2026-03-16 22:44:53 -07:00
Jack Kingsman
a0459edf62 Add clowntown clock rollover trick 2026-03-16 22:37:13 -07:00
Jack Kingsman
86170766eb Better no-connection errors 2026-03-16 22:17:54 -07:00
Jack Kingsman
33e1b527bd Don't force save-prompt on unedited integrations 2026-03-16 21:47:04 -07:00
Jack Kingsman
23f9bd216c Add pre-filled letsmesh/meshrank MQTT 2026-03-16 21:43:45 -07:00
Jack Kingsman
35b592d2a7 Auto-reset node if clock is too far ahead to change 2026-03-16 21:24:07 -07:00
Jack Kingsman
c215aedc0d Let the radio settings pane open but not be used, rather than disabled 2026-03-16 20:30:34 -07:00
Jack Kingsman
9cd567895b Update the docs 2026-03-16 19:29:09 -07:00
Jack Kingsman
c469633a30 Serialize radio disconnect in a lock 2026-03-16 19:25:00 -07:00
Jack Kingsman
f8f0b3a8cf Updating changelog + build for 3.4.1 2026-03-16 18:43:34 -07:00
Jack Kingsman
47276dcb6c Improve handling of version info in pre-built bundles 2026-03-16 18:41:19 -07:00
Jack Kingsman
9c06ed62a4 Fix reopen-last behavior with new settings hash 2026-03-16 18:17:21 -07:00
Jack Kingsman
e19a8d3395 Update our docs and README.md. Closes #65. 2026-03-16 18:14:31 -07:00
Jack Kingsman
b68bfc41d6 Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66. 2026-03-16 17:46:12 -07:00
Jack Kingsman
ffb5fa51c1 Finish frontend phase 3 2026-03-16 17:32:27 -07:00
Jack Kingsman
0e4828bf72 Frontend optimization part 2 2026-03-16 17:32:27 -07:00
Jack Kingsman
a5d9632a67 Phase 1 of frontend fixup 2026-03-16 17:32:27 -07:00
Jack Kingsman
24747ecd17 Unify our DM ingest 2026-03-16 17:32:27 -07:00
170 changed files with 13012 additions and 2926 deletions

View File

@@ -10,7 +10,7 @@ If instructed to "run all tests" or "get ready for a commit" or other summative,
./scripts/all_quality.sh
```
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend sequentially. All checks must pass green.
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
## Overview
@@ -138,8 +138,12 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
## Data Flow
@@ -159,10 +163,22 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
- If the initial `MSG_SENT` result includes an expected ACK code, background retries are armed.
- Non-final retry attempts use the effective route (`override > direct > flood`).
- Retry timing follows the radio's `suggested_timeout`.
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
### ACK and Repeat Detection
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
This message-layer echo/path handling is independent of raw-packet storage deduplication.
@@ -193,12 +209,13 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── scripts/
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, and the standard frontend build
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── all_quality.sh # Repo-standard autofix + validate gate
│ ├── collect_licenses.sh # Gather third-party license attributions
│ ├── e2e.sh # End-to-end test runner
│ └── publish.sh # Version bump, changelog, docker build & push
├── remoteterm.service # Systemd unit file for production deployment
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
├── CONTRIBUTING.md # Contributor workflow and testing guidance
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
└── pyproject.toml # Python dependencies
@@ -281,7 +298,7 @@ npm run test:run
### Before Completing Changes
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** This runs all linting, formatting, type checking, tests, and the standard frontend build sequentially, catching type mismatches, breaking changes, and compilation errors. This is not necessary for docs-only changes.
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
## API Summary
@@ -291,10 +308,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|--------|----------|-------------|
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
@@ -308,6 +325,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
@@ -332,7 +350,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
@@ -382,7 +400,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
- Bulk update via `POST /api/read-state/mark-all-read`
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
- Channels: `channel-{channel_key}`

View File

@@ -1,3 +1,35 @@
## [3.5.0] - 2026-03-19
Feature: Add room server alpha support
Feature: Add option to force-reset node clock when it's too far ahead
Feature: DMs auto-retry before resorting to flood
Feature: Add impulse zero-hop advert
Feature: Utilize PATH packets to correctly source a contact's route
Feature: Metrics view on raw packet pane
Feature: Metric, Imperial, and Smoots are now selectable for distance display
Feature: Allow favorites to be sorted
Feature: Add multi-ack support
Feature: Password-remember checkbox on repeaters + room servers
Bugfix: Serialize radio disconnect in a lock
Bugfix: Fix contact bar layout issues
Bugfix: Fix sidebar ordering for contacts by advert recency
Bugfix: Fix version reporting in community MQTT
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: Log node time on startup
Misc: Improve community MQTT error bubble-up
Misc: Unread DMs always have a red unread counter
Misc: Improve information in the debug view to show DB status
## [3.4.1] - 2026-03-16
Bugfix: Improve handling of version information on prebuilt bundles
Bugfix: Improve frontend usability on disconnected radio
Misc: Docs and readme updates
Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16
Feature: Add radio model and stats display

108
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,108 @@
# Contributing
## Guiding Principles
- In all your interactions with developers, maintainers, and users, be kind.
- Prefer small, comprehensible changes over large sweeping ones. Individual commits should be meaningful atomic chunks of work. Pull requests with many, many commits instead of a phased approach may be declined.
- Pull requests must be fully understood and explicitly endorsed by a human before merge. AI assistance is great, and this repo is optimized for it, but we keep quality by keeping our agents on track to write clear code, useful (not useless) tests, good architecture, and big-picture thinking.
- No pull request should introduce new failing lint, typecheck, test, or build results.
- Every pull request should have an associated issue or discussion thread; a brand new feature appearing first in a PR is an antipattern.
- No truly automated radio traffic. Bot replies are already the practical edge of what this project wants to automate; any kind of traffic that would be intervalized or automated is not what this project is about.
- No ingestion from the internet onto the mesh. This project is a radio client, not a bridge for outside traffic to enter the network. The mesh is strong because it is a radio mesh, not the internet with some weird wireless links.
## Local Development
### Backend
```bash
uv sync
uv run uvicorn app.main:app --reload
```
With an explicit serial port:
```bash
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
```
On Windows (PowerShell):
```powershell
uv sync
$env:MESHCORE_SERIAL_PORT="COM8"
uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
## Quality Checks
Run the full quality suite before proposing or handing off code changes:
```bash
./scripts/all_quality.sh
```
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
If you need targeted commands while iterating:
```bash
# backend
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
uv run pyright app/
PYTHONPATH=. uv run pytest tests/ -v
# frontend
cd frontend
npm run lint:fix
npm run format
npm run test:run
npm run build
```
## E2E Testing
E2E coverage exists, but it is intentionally not part of the normal development path.
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.
```bash
cd tests/e2e
npx playwright test # headless
npx playwright test --headed # you can probably guess
```
## Pull Request Expectations
- Keep scope tight.
- Explain why the change is needed.
- Link the issue or discussion where the behavior was agreed on.
- Call out any follow-up work left intentionally undone.
- Do not treat code review as the place where the app's direction is first introduced or debated
## Notes For Agent-Assisted Work
Before making non-trivial changes, read:
- `./AGENTS.md`
- `./app/AGENTS.md`
- `./frontend/AGENTS.md`
Read these only when working in those areas:
- `./app/fanout/AGENTS_fanout.md`
- `./frontend/src/components/visualizer/AGENTS_packet_visualizer.md`
- Agent output is welcome, but human review is mandatory.
- Agents should start with the AGENTS files above before making architectural changes.
- If a change touches advanced areas like fanout or the visualizer, read the area-specific AGENTS file before editing.

View File

@@ -15,8 +15,12 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
ARG COMMIT_HASH=unknown
WORKDIR /app
ENV COMMIT_HASH=${COMMIT_HASH}
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

View File

@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
</details>
### meshcore (2.2.29) — MIT
### meshcore (2.3.1) — MIT
<details>
<summary>Full license text</summary>

274
README.md
View File

@@ -22,10 +22,22 @@ This is developed with very heavy agentic assistance -- there is no warranty of
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
## 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).
## Requirements
- Python 3.10+
- Node.js LTS or current (20, 22, 24, 25)
- 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
@@ -67,7 +79,7 @@ usbipd attach --wsl --busid 3-8
```
</details>
## Quick Start
## Path 1: Clone And Build
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
@@ -75,43 +87,33 @@ usbipd attach --wsl --busid 3-8
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
cd Remote-Terminal-for-MeshCore
# Install backend dependencies
uv sync
# Build frontend
cd frontend && npm install && npm run build && cd ..
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The server auto-detects the serial port. To specify a transport manually:
Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
## Path 1.5: Use The Prebuilt Release Zip
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
If you downloaded the release zip instead of cloning the repo, unpack it and run:
```bash
# Serial (explicit port)
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP (e.g. via wifi-enabled firmware)
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE (address and PIN both required)
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
On Windows (PowerShell), set environment variables as a separate statement:
```powershell
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
cd Remote-Terminal-for-MeshCore
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at http://localhost:8000.
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
>
> Source checkouts expect a normal frontend build in `frontend/dist`. The backend also supports `frontend/prebuilt` when you are running from the release zip artifact.
## Path 2: Docker
## Docker Compose
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
@@ -144,7 +146,7 @@ docker compose pull
docker compose up -d
```
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
To stop:
@@ -152,68 +154,9 @@ To stop:
docker compose down
```
## Development
## Standard Environment Variables
### Backend
```bash
uv sync
uv run uvicorn app.main:app --reload # autodetects serial port
# Or with explicit serial port
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
```
On Windows (PowerShell):
```powershell
uv sync
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --reload
```
> **Windows note:** I've seen an intermittent startup issue like `"Received empty packet: index out of range"` with failed contact sync. I can't figure out why this happens. The issue typically resolves on restart. If you can figure out why this happens, I will buy you a virtual or iRL six pack if you're in the PNW. As a former always-windows-girlie before embracing WSL2, I despise second-classing M$FT users, but I'm just stuck with this one.
### Frontend
```bash
cd frontend
npm install
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
npm run build # Production build to dist/
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
### Code Quality & Tests
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
Run everything at once:
```bash
./scripts/all_quality.sh
```
<details>
<summary>Or run individual checks</summary>
```bash
# python
uv run ruff check app/ tests/ --fix # lint + auto-fix
uv run ruff format app/ tests/ # format (always writes)
uv run pyright app/ # type checking
PYTHONPATH=. uv run pytest tests/ -v # backend tests
# frontend
cd frontend
npm run lint:fix # esLint + auto-fix
npm run test:run # run tests
npm run format # prettier (always writes)
npm run build # build frontend/dist
```
</details>
## Configuration
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
| Variable | Default | Description |
|----------|---------|-------------|
@@ -223,137 +166,36 @@ npm run build # build frontend/dist
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config; an intermediate security precaution, but not as good as basic auth) |
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
Common launch patterns:
```bash
# Serial (explicit port)
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
On Windows (PowerShell), set environment variables as a separate statement:
```powershell
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
### Remediation Environment Variables
## Where To Go Next
These are intended for diagnosing or working around radios that behave oddly.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly sanity check |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
- whether messages were left on the radio without reaching the app through event subscription
- whether the app's channel-slot expectations still match the radio's actual channel listing
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
## Additional Setup
<details>
<summary>HTTPS (Required for WebGPU room-finding outside localhost)</summary>
WebGPU requires a secure context. When not on `localhost`, serve over HTTPS:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
```
For Docker Compose, generate the cert and add the volume mounts and command override to `docker-compose.yaml`:
```bash
# generate snakeoil TLS cert
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
```
Then add the key and cert to the `remoteterm` service in `docker-compose.yaml`, and add an explicit launch command that uses them:
```yaml
volumes:
- ./data:/app/data
- ./cert.pem:/app/cert.pem:ro
- ./key.pem:/app/key.pem:ro
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
```
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
</details>
<details>
<summary>Systemd Service (Linux)</summary>
Assumes you're running from `/opt/remoteterm`; update commands and `remoteterm.service` if you're running elsewhere.
```bash
# Create service user
sudo useradd -r -m -s /bin/false remoteterm
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# Install and start service
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
# Check status
sudo systemctl status remoteterm
sudo journalctl -u remoteterm -f
```
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
If you are deploying from a source checkout, install Node.js and run `cd /opt/remoteterm/frontend && sudo -u remoteterm npm install && sudo -u remoteterm npm run build` before starting the service. If you are deploying from the release zip artifact, the bundled `frontend/prebuilt` is already present.
</details>
<details>
<summary>Testing</summary>
**Backend:**
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
**Frontend:**
```bash
cd frontend
npm run test:run
```
**E2E:**
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly and an available autodetect-able radio, as well as 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.
```bash
cd tests/e2e
npx playwright test # headless
npx playwright test --headed # show the browser window
```
</details>
## API Documentation
With the backend running: http://localhost:8000/docs
## Debugging & Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
To start the server with debug logging:
```bash
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Please include the relevant debug log output when filing an issue on GitHub.
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
- Live API docs after the backend is running: http://localhost:8000/docs

118
README_ADVANCED.md Normal file
View File

@@ -0,0 +1,118 @@
# Advanced Setup And Troubleshooting
## Remediation Environment Variables
These are intended for diagnosing or working around radios that behave oddly.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
- whether messages were left on the radio without reaching the app through event subscription
- whether the app's channel-slot expectations still match the radio's actual channel listing
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
`__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.
## HTTPS
WebGPU room-finding requires a secure context when you are not on `localhost`.
Generate a local cert and start the backend with TLS:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
```
For Docker Compose, generate the cert, mount it into the container, and override the launch command:
```yaml
services:
remoteterm:
volumes:
- ./data:/app/data
- ./cert.pem:/app/cert.pem:ro
- ./key.pem:/app/key.pem:ro
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
```
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
## Systemd Service
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
```bash
# Create service user
sudo useradd -r -m -s /bin/false remoteterm
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# If deploying from a source checkout, build the frontend first
sudo -u remoteterm bash -lc 'cd /opt/remoteterm/frontend && npm install && npm run build'
# If deploying from the release zip artifact, frontend/prebuilt is already present
```
Create `/etc/systemd/system/remoteterm.service` with:
```ini
[Unit]
Description=RemoteTerm for MeshCore
After=network.target
[Service]
Type=simple
User=remoteterm
Group=remoteterm
WorkingDirectory=/opt/remoteterm
ExecStart=/opt/remoteterm/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5
Environment=MESHCORE_DATABASE_PATH=/opt/remoteterm/data/meshcore.db
# Uncomment and set if auto-detection doesn't work:
# Environment=MESHCORE_SERIAL_PORT=/dev/ttyUSB0
SupplementaryGroups=dialout
[Install]
WantedBy=multi-user.target
```
Then install and start it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
sudo systemctl status remoteterm
sudo journalctl -u remoteterm -f
```
## Debug Logging And Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
```bash
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
You can also navigate to `/api/debug` (or go to Settings -> About -> "Open debug support snapshot" at the bottom). This debug block contains information about the operating environment, expectations around keys and channels, and radio status. It also includes the most recent logs. **Non-log information reveals no keys, channel names, or other privilege information beyond the names of your bots. The logs, however, may contain channel names or keys (but never your private key).** If you do not wish to include this information, copy up to the `STOP COPYING HERE` marker in the debug body.
## Development Notes
For day-to-day development, see [CONTRIBUTING.md](CONTRIBUTING.md).
Windows note: I've seen an intermittent startup issue like `"Received empty packet: index out of range"` with failed contact sync. I can't figure out why this happens. The issue typically resolves on restart. If you can figure out why this happens, I will buy you a virtual or iRL six pack if you're in the PNW. As a former always-windows-girlie before embracing WSL2, I despise second-classing M$FT users, but I'm just stuck with this one.

View File

@@ -32,6 +32,7 @@ app/
├── services/ # Shared orchestration/domain services
│ ├── messages.py # Shared message creation, dedup, ACK application
│ ├── message_send.py # Direct send, channel send, resend workflows
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
│ ├── dm_ack_tracker.py # Pending DM ACK state
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
@@ -74,7 +75,7 @@ app/
1. Radio emits events.
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
3. Shared message-domain services create/update `messages` and shape WS payloads.
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
4. Direct-message storage is centralized in `services/dm_ingest.py`; packet-processor DMs and `CONTACT_MSG_RECV` fallback events both route through that seam.
### Outgoing messages
@@ -103,19 +104,33 @@ app/
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
### Read/unread state
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
### DM ingest + ACKs
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked.
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)` when the radio provides an expected ACK code: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
### Echo/repeat dedup
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming repeats and direct-message duplicates may still add path data, but DM delivery state advances only from real ACK events.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same conversation/text/sender timestamp also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
### Raw packet dedup policy
@@ -154,10 +169,10 @@ app/
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
### Radio
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
- `PUT /radio/private-key`
- `POST /radio/advertise`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/disconnect`
- `POST /radio/reboot`
@@ -173,6 +188,7 @@ app/
- `POST /contacts/{public_key}/command`
- `POST /contacts/{public_key}/routing-override`
- `POST /contacts/{public_key}/trace`
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
- `POST /contacts/{public_key}/repeater/login`
- `POST /contacts/{public_key}/repeater/status`
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
@@ -204,7 +220,7 @@ app/
- `POST /packets/maintenance`
### Read state
- `GET /read-state/unreads`
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
- `POST /read-state/mark-all-read`
### Settings
@@ -247,7 +263,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
## Data Model Notes
Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels`
Includes optional `flood_scope_override` for channel-specific regional sends.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
@@ -256,6 +272,13 @@ Main tables:
- `contact_name_history` (tracks name changes over time)
- `app_settings`
Contact route state is canonicalized on the backend:
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
@@ -278,7 +301,7 @@ Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs
## Security Posture (intentional)
- No authn/authz.
- No per-user authn/authz model; optionally, operators may enable app-wide HTTP Basic auth for both HTTP and WS entrypoints.
- No CORS restriction (`*`).
- Bot code executes user-provided Python via `exec()`.
@@ -353,7 +376,7 @@ tests/
The MeshCore radio protocol encodes `sender_timestamp` as a 4-byte little-endian integer (Unix seconds). This is a firmware-level wire format — the radio, the Python library (`commands/messaging.py`), and the decoder (`decoder.py`) all read/write exactly 4 bytes. Millisecond Unix timestamps would overflow 4 bytes, so higher resolution is not possible without a firmware change.
**Consequence:** Channel-message dedup still operates at 1-second granularity because the radio protocol only provides second-resolution `sender_timestamp`. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`. Direct messages no longer share that channel dedup index; they are deduplicated by raw-packet identity instead so legitimate same-text same-second DMs can coexist.
**Consequence:** Message dedup still operates at 1-second granularity because the radio protocol only provides second-resolution `sender_timestamp`. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`. Incoming DMs now share the same second-resolution content identity tradeoff as channel echoes: same-contact same-text same-second observations collapse onto one stored row.
### Outgoing DM echoes remain undecrypted

View File

@@ -4,7 +4,7 @@ from collections import deque
from threading import Lock
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
disable_bots: bool = False
enable_message_poll_fallback: bool = False
force_channel_slot_reconfigure: bool = False
clowntown_do_clock_wraparound: bool = Field(
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
basic_auth_username: str = ""
basic_auth_password: str = ""

View File

@@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts (
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
out_path_hash_mode INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
@@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts (
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER
first_seen INTEGER,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS channels (
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels (
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT
flood_scope_override TEXT,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
@@ -50,10 +53,9 @@ CREATE TABLE IF NOT EXISTS messages (
acked INTEGER DEFAULT 0,
sender_name TEXT,
sender_key TEXT
-- Deduplication: channel echoes/repeats use a channel-only unique index on
-- identical conversation/text/timestamp. Direct messages are deduplicated
-- separately via raw-packet linkage so legitimate same-text same-second DMs
-- can coexist.
-- Deduplication: channel echoes/repeats use a content/time unique index so
-- duplicate observations reconcile onto a single stored row. Legacy
-- databases may also gain an incoming-DM content index via migration 44.
-- Enforced via idx_messages_dedup_null_safe (unique index) rather than a table constraint
-- to avoid the storage overhead of SQLite's autoindex duplicating every message text.
);
@@ -97,6 +99,7 @@ CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
ON contact_advert_paths(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key

View File

@@ -58,6 +58,28 @@ class DecryptedDirectMessage:
message: str
dest_hash: str # First byte of destination pubkey as hex
src_hash: str # First byte of sender pubkey as hex
signed_sender_prefix: str | None = None
@property
def txt_type(self) -> int:
return self.flags >> 2
@property
def attempt(self) -> int:
return self.flags & 0x03
@dataclass
class DecryptedPathPayload:
"""Result of decrypting a PATH payload."""
dest_hash: str
src_hash: str
returned_path: bytes
returned_path_len: int
returned_path_hash_mode: int
extra_type: int
extra: bytes
@dataclass
@@ -485,6 +507,13 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
# Extract message text (UTF-8, null-padded)
message_bytes = decrypted[5:]
signed_sender_prefix: str | None = None
txt_type = flags >> 2
if txt_type == 2:
if len(message_bytes) < 4:
return None
signed_sender_prefix = message_bytes[:4].hex()
message_bytes = message_bytes[4:]
try:
message_text = message_bytes.decode("utf-8")
# Truncate at first null terminator (consistent with channel message handling)
@@ -500,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
message=message_text,
dest_hash=dest_hash,
src_hash=src_hash,
signed_sender_prefix=signed_sender_prefix,
)
@@ -563,3 +593,88 @@ def try_decrypt_dm(
return None
return decrypt_direct_message(packet_info.payload, shared_secret)
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
"""Decrypt a PATH payload using the ECDH shared secret."""
if len(payload) < 4:
return None
dest_hash = format(payload[0], "02x")
src_hash = format(payload[1], "02x")
mac = payload[2:4]
ciphertext = payload[4:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
return None
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
if calculated_mac != mac:
return None
try:
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed for PATH payload: %s", e)
return None
if len(decrypted) < 2:
return None
from app.path_utils import decode_path_byte
packed_len = decrypted[0]
try:
returned_path_len, hash_size = decode_path_byte(packed_len)
except ValueError:
return None
path_byte_len = returned_path_len * hash_size
if len(decrypted) < 1 + path_byte_len + 1:
return None
offset = 1
returned_path = decrypted[offset : offset + path_byte_len]
offset += path_byte_len
extra_type = decrypted[offset] & 0x0F
offset += 1
extra = decrypted[offset:]
return DecryptedPathPayload(
dest_hash=dest_hash,
src_hash=src_hash,
returned_path=returned_path,
returned_path_len=returned_path_len,
returned_path_hash_mode=hash_size - 1,
extra_type=extra_type,
extra=extra,
)
def try_decrypt_path(
raw_packet: bytes,
our_private_key: bytes,
their_public_key: bytes,
our_public_key: bytes,
) -> DecryptedPathPayload | None:
"""Try to decrypt a raw packet as a PATH packet."""
packet_info = parse_packet(raw_packet)
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
return None
if len(packet_info.payload) < 4:
return None
dest_hash = packet_info.payload[0]
src_hash = packet_info.payload[1]
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
return None
try:
shared_secret = derive_shared_secret(our_private_key, their_public_key)
except Exception as e:
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
return None
return decrypt_path_payload(packet_info.payload, shared_secret)

View File

@@ -4,19 +4,22 @@ from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
from app.packet_processor import process_raw_packet
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
)
from app.services import dm_ack_tracker
from app.services.contact_reconciliation import (
claim_prefix_messages_for_contact,
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.dm_ingest import (
ingest_fallback_direct_message,
resolve_direct_message_sender_metadata,
resolve_fallback_direct_message_context,
)
from app.websocket import broadcast_event
if TYPE_CHECKING:
@@ -51,8 +54,8 @@ async def on_contact_message(event: "Event") -> None:
2. The packet processor couldn't match the sender to a known contact
The packet processor handles: decryption, storage, broadcast, bot trigger.
This handler only stores if the packet processor didn't already handle it
(detected via INSERT OR IGNORE returning None for duplicates).
This handler adapts CONTACT_MSG_RECV payloads into the shared DM ingest
workflow, which reconciles duplicates against the packet pipeline when possible.
"""
payload = event.payload
@@ -66,78 +69,69 @@ async def on_contact_message(event: "Event") -> None:
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
received_at = int(time.time())
# Look up contact from database - use prefix lookup only if needed
# (get_by_key_or_prefix does exact match first, then prefix fallback)
try:
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_pubkey,
context = await resolve_fallback_direct_message_context(
sender_public_key=sender_pubkey,
received_at=received_at,
broadcast_fn=broadcast_event,
contact_repository=ContactRepository,
log=logger,
)
if context.skip_storage:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
context.conversation_key[:12],
)
contact = None
if contact:
sender_pubkey = contact.public_key.lower()
return
# Promote any prefix-stored messages to this full key
await claim_prefix_messages_for_contact(public_key=sender_pubkey, log=logger)
# Skip messages from repeaters - they only send CLI responses, not chat messages.
# CLI responses are handled by the command endpoint and txt_type filter above.
if contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
sender_pubkey[:12],
)
return
elif sender_pubkey:
placeholder_upsert = ContactUpsert(
public_key=sender_pubkey.lower(),
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
out_path_hash_mode=-1,
)
await ContactRepository.upsert(placeholder_upsert)
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
if contact:
broadcast_event("contact", contact.model_dump())
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
# Try to create or reconcile the message via the shared DM ingest service.
ts = payload.get("sender_timestamp")
sender_timestamp = ts if ts is not None else received_at
sender_name = contact.name if contact else None
path = payload.get("path")
path_len = payload.get("path_len")
message = await create_fallback_direct_message(
conversation_key=sender_pubkey,
sender_name = context.sender_name
sender_key = context.sender_key
signature = payload.get("signature")
if (
context.contact is not None
and context.contact.type == CONTACT_TYPE_ROOM
and txt_type == 2
and isinstance(signature, str)
and signature
):
sender_name, sender_key = await resolve_direct_message_sender_metadata(
sender_public_key=signature,
received_at=received_at,
broadcast_fn=broadcast_event,
contact_repository=ContactRepository,
log=logger,
)
message = await ingest_fallback_direct_message(
conversation_key=context.conversation_key,
text=payload.get("text", ""),
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=payload.get("signature"),
signature=signature,
sender_name=sender_name,
sender_key=sender_pubkey,
sender_key=sender_key,
broadcast_fn=broadcast_event,
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
)
if message is None:
# Already handled by packet processor (or exact duplicate) - nothing more to do
logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12])
logger.debug(
"DM from %s already processed by packet processor", context.conversation_key[:12]
)
return
# If we get here, the packet processor didn't handle this message
# (likely because private key export is not available)
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Update contact last_contacted (contact was already fetched above)
if contact:
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
logger.debug(
"DM from %s handled by event handler (fallback path)", context.conversation_key[:12]
)
async def on_rx_log_data(event: "Event") -> None:
@@ -221,11 +215,12 @@ async def on_path_update(event: "Event") -> None:
)
normalized_path_hash_mode = None
await ContactRepository.update_path(
await ContactRepository.update_direct_path(
contact.public_key,
str(path),
normalized_path_len,
normalized_path_hash_mode,
updated_at=int(time.time()),
)
@@ -292,18 +287,10 @@ async def on_ack(event: "Event") -> None:
return
logger.debug("Received ACK with code %s", ack_code)
cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is not None:
logger.info("ACK received for message %d", message_id)
# DM ACKs don't carry path data, so paths is intentionally omitted.
# The frontend's mergePendingAck handles the missing field correctly,
# preserving any previously known paths.
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info("ACK received for code %s", ack_code)
else:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
logger.debug("ACK code %s does not match any pending messages", ack_code)

View File

@@ -65,6 +65,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- `code` — Python bot function source code
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
### webhook (webhook.py)
HTTP webhook delivery. Config blob:
@@ -78,6 +79,7 @@ Push notifications via Apprise library. Config blob:
- `urls` — newline-separated Apprise notification service URLs
- `preserve_identity` — suppress Discord webhook name/avatar override
- `include_path` — include routing path in notification body
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
### sqs (sqs.py)
Amazon SQS delivery. Config blob:

View File

@@ -6,7 +6,7 @@ import asyncio
import logging
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from app.fanout.base import FanoutModule
from app.fanout.base import FanoutModule, get_fanout_message_text
from app.path_utils import split_path_hex
logger = logging.getLogger(__name__)
@@ -39,7 +39,7 @@ def _normalize_discord_url(url: str) -> str:
def _format_body(data: dict, *, include_path: bool) -> str:
"""Build a human-readable notification body from message data."""
msg_type = data.get("type", "")
text = data.get("text", "")
text = get_fanout_message_text(data)
sender_name = data.get("sender_name") or "Unknown"
via = ""

View File

@@ -33,3 +33,30 @@ class FanoutModule:
def status(self) -> str:
"""Return 'connected', 'disconnected', or 'error'."""
raise NotImplementedError
def get_fanout_message_text(data: dict) -> str:
"""Return the best human-readable message body for fanout consumers.
Channel messages are stored with the rendered sender label embedded in the
text (for example ``"Alice: hello"``). Human-facing integrations that also
carry ``sender_name`` should strip that duplicated prefix when it matches
the payload sender exactly.
"""
text = data.get("text", "")
if not isinstance(text, str):
return ""
if data.get("type") != "CHAN":
return text
sender_name = data.get("sender_name")
if not isinstance(sender_name, str) or not sender_name:
return text
prefix, separator, remainder = text.partition(": ")
if separator and prefix == sender_name:
return remainder
return text

View File

@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio
import base64
import hashlib
import importlib.metadata
import json
import logging
import ssl
@@ -25,12 +24,13 @@ import nacl.bindings
from app.fanout.mqtt_base import BaseMqttPublisher
from app.path_utils import parse_packet_envelope, split_path_hex
from app.version_info import get_app_build_info
logger = logging.getLogger(__name__)
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
_CLIENT_ID = "RemoteTerm"
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
@@ -115,7 +115,7 @@ def _generate_jwt_token(
"exp": now + _TOKEN_LIFETIME,
"aud": audience,
"owner": pubkey_hex,
"client": _CLIENT_ID,
"client": _get_client_version(),
}
if email:
payload["email"] = email
@@ -260,12 +260,10 @@ def _build_radio_info() -> str:
def _get_client_version() -> str:
"""Return a client version string like ``'RemoteTerm 2.4.0'``."""
try:
version = importlib.metadata.version("remoteterm-meshcore")
return f"RemoteTerm {version}"
except Exception:
return "RemoteTerm unknown"
"""Return the canonical client/version identifier for community MQTT."""
build = get_app_build_info()
commit_hash = build.commit_hash or "unknown"
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
class CommunityMqttPublisher(BaseMqttPublisher):

View File

@@ -54,6 +54,17 @@ class BaseMqttPublisher(ABC):
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
self.integration_name: str = ""
def set_integration_name(self, name: str) -> None:
"""Attach the configured fanout-module name for operator-facing logs."""
self.integration_name = name.strip()
def _integration_label(self) -> str:
"""Return a concise label for logs, including the configured module name."""
if self.integration_name:
return f"{self._log_prefix} [{self.integration_name}]"
return self._log_prefix
# ── Lifecycle ──────────────────────────────────────────────────────
@@ -90,8 +101,9 @@ class BaseMqttPublisher(ABC):
await self._client.publish(topic, json.dumps(payload), retain=retain)
except Exception as e:
logger.warning(
"%s publish failed on %s: %s",
self._log_prefix,
"%s publish failed on %s. This is usually transient network noise; "
"if it self-resolves and reconnects, it is generally not a concern: %s",
self._integration_label(),
topic,
e,
exc_info=True,
@@ -225,8 +237,10 @@ class BaseMqttPublisher(ABC):
broadcast_error(title, detail)
_broadcast_health()
logger.warning(
"%s connection error: %s (reconnecting in %ds)",
self._log_prefix,
"%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s "
"(reconnecting in %ds)",
self._integration_label(),
e,
backoff,
exc_info=True,

View File

@@ -77,6 +77,7 @@ class MqttCommunityModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)

View File

@@ -32,6 +32,7 @@ class MqttPrivateModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)

View File

@@ -32,12 +32,14 @@ from app.routers import (
radio,
read_state,
repeaters,
rooms,
settings,
statistics,
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
setup_logging()
logger = logging.getLogger(__name__)
@@ -102,22 +104,10 @@ async def lifespan(app: FastAPI):
await db.disconnect()
def _get_version() -> str:
"""Read version from pyproject.toml so it stays in sync automatically."""
try:
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
for line in pyproject.read_text().splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except Exception:
pass
return "0.0.0"
app = FastAPI(
title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks",
version=_get_version(),
version=get_app_build_info().version,
lifespan=lifespan,
)
@@ -145,6 +135,7 @@ app.include_router(fanout.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(repeaters.router, prefix="/api")
app.include_router(rooms.router, prefix="/api")
app.include_router(channels.router, prefix="/api")
app.include_router(messages.router, prefix="/api")
app.include_router(packets.router, prefix="/api")

View File

@@ -8,6 +8,7 @@ This approach is safe for existing users - their databases have user_version=0,
so all migrations run in order on first startup after upgrade.
"""
import json
import logging
from hashlib import sha256
@@ -338,6 +339,27 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 43)
applied += 1
# Migration 44: Deduplicate incoming direct messages by content/timestamp
if version < 44:
logger.info("Applying migration 44: dedupe incoming direct messages")
await _migrate_044_dedupe_incoming_direct_messages(conn)
await set_version(conn, 44)
applied += 1
# Migration 45: Replace legacy contact route columns with direct-route columns
if version < 45:
logger.info("Applying migration 45: rebuild contacts direct-route columns")
await _migrate_045_rebuild_contacts_direct_route_columns(conn)
await set_version(conn, 45)
applied += 1
# Migration 46: Clean orphaned contact child rows left by old prefix promotion
if version < 46:
logger.info("Applying migration 46: clean orphaned contact child rows")
await _migrate_046_cleanup_orphaned_contact_child_rows(conn)
await set_version(conn, 46)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2476,3 +2498,373 @@ async def _migrate_043_split_message_dedup_by_type(conn: aiosqlite.Connection) -
WHERE type = 'CHAN'"""
)
await conn.commit()
def _merge_message_paths(paths_json_values: list[str | None]) -> str | None:
"""Merge multiple message path arrays into one exact-observation list."""
merged: list[dict[str, object]] = []
seen: set[tuple[object | None, object | None, object | None]] = set()
for paths_json in paths_json_values:
if not paths_json:
continue
try:
parsed = json.loads(paths_json)
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(parsed, list):
continue
for entry in parsed:
if not isinstance(entry, dict):
continue
key = (
entry.get("path"),
entry.get("received_at"),
entry.get("path_len"),
)
if key in seen:
continue
seen.add(key)
merged.append(entry)
return json.dumps(merged) if merged else None
async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connection) -> None:
"""Collapse same-contact same-text same-second incoming DMs into one row."""
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
cursor = await conn.execute("PRAGMA table_info(messages)")
columns = {row[1] for row in await cursor.fetchall()}
required_columns = {
"id",
"type",
"conversation_key",
"text",
"sender_timestamp",
"received_at",
"paths",
"txt_type",
"signature",
"outgoing",
"acked",
"sender_name",
"sender_key",
}
if not required_columns.issubset(columns):
logger.debug("messages table missing incoming-DM dedup columns, skipping migration 44")
await conn.commit()
return
raw_packets_cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='raw_packets'"
)
raw_packets_exists = await raw_packets_cursor.fetchone() is not None
duplicate_groups_cursor = await conn.execute(
"""
SELECT conversation_key, text,
COALESCE(sender_timestamp, 0) AS normalized_sender_timestamp,
COUNT(*) AS duplicate_count
FROM messages
WHERE type = 'PRIV' AND outgoing = 0
GROUP BY conversation_key, text, COALESCE(sender_timestamp, 0)
HAVING COUNT(*) > 1
"""
)
duplicate_groups = await duplicate_groups_cursor.fetchall()
for group in duplicate_groups:
normalized_sender_timestamp = group["normalized_sender_timestamp"]
rows_cursor = await conn.execute(
"""
SELECT *
FROM messages
WHERE type = 'PRIV' AND outgoing = 0
AND conversation_key = ? AND text = ?
AND COALESCE(sender_timestamp, 0) = ?
ORDER BY id ASC
""",
(
group["conversation_key"],
group["text"],
normalized_sender_timestamp,
),
)
rows = list(await rows_cursor.fetchall())
if len(rows) < 2:
continue
keeper = rows[0]
duplicate_ids = [row["id"] for row in rows[1:]]
merged_paths = _merge_message_paths([row["paths"] for row in rows])
merged_received_at = min(row["received_at"] for row in rows)
merged_txt_type = next((row["txt_type"] for row in rows if row["txt_type"] != 0), 0)
merged_signature = next((row["signature"] for row in rows if row["signature"]), None)
merged_sender_name = next((row["sender_name"] for row in rows if row["sender_name"]), None)
merged_sender_key = next((row["sender_key"] for row in rows if row["sender_key"]), None)
merged_acked = max(int(row["acked"] or 0) for row in rows)
await conn.execute(
"""
UPDATE messages
SET received_at = ?, paths = ?, txt_type = ?, signature = ?,
acked = ?, sender_name = ?, sender_key = ?
WHERE id = ?
""",
(
merged_received_at,
merged_paths,
merged_txt_type,
merged_signature,
merged_acked,
merged_sender_name,
merged_sender_key,
keeper["id"],
),
)
if raw_packets_exists:
for duplicate_id in duplicate_ids:
await conn.execute(
"UPDATE raw_packets SET message_id = ? WHERE message_id = ?",
(keeper["id"], duplicate_id),
)
placeholders = ",".join("?" for _ in duplicate_ids)
await conn.execute(
f"DELETE FROM messages WHERE id IN ({placeholders})",
duplicate_ids,
)
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))
WHERE type = 'PRIV' AND outgoing = 0"""
)
await conn.commit()
async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None:
"""Replace legacy contact route columns with canonical direct-route columns."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
cursor = await conn.execute("PRAGMA table_info(contacts)")
columns = {row[1] for row in await cursor.fetchall()}
target_columns = {
"public_key",
"name",
"type",
"flags",
"direct_path",
"direct_path_len",
"direct_path_hash_mode",
"direct_path_updated_at",
"route_override_path",
"route_override_len",
"route_override_hash_mode",
"last_advert",
"lat",
"lon",
"last_seen",
"on_radio",
"last_contacted",
"first_seen",
"last_read_at",
}
if (
target_columns.issubset(columns)
and "last_path" not in columns
and "out_path_hash_mode" not in columns
):
await conn.commit()
return
await conn.execute(
"""
CREATE TABLE contacts_new (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
)
"""
)
select_expr = {
"public_key": "public_key",
"name": "NULL",
"type": "0",
"flags": "0",
"direct_path": "NULL",
"direct_path_len": "NULL",
"direct_path_hash_mode": "NULL",
"direct_path_updated_at": "NULL",
"route_override_path": "NULL",
"route_override_len": "NULL",
"route_override_hash_mode": "NULL",
"last_advert": "NULL",
"lat": "NULL",
"lon": "NULL",
"last_seen": "NULL",
"on_radio": "0",
"last_contacted": "NULL",
"first_seen": "NULL",
"last_read_at": "NULL",
}
for name in ("name", "type", "flags"):
if name in columns:
select_expr[name] = name
if "direct_path" in columns:
select_expr["direct_path"] = "direct_path"
if "direct_path_len" in columns:
select_expr["direct_path_len"] = "direct_path_len"
if "direct_path_hash_mode" in columns:
select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
for name in (
"route_override_path",
"route_override_len",
"route_override_hash_mode",
"last_advert",
"lat",
"lon",
"last_seen",
"on_radio",
"last_contacted",
"first_seen",
"last_read_at",
):
if name in columns:
select_expr[name] = name
ordered_columns = list(select_expr.keys())
await conn.execute(
f"""
INSERT INTO contacts_new ({", ".join(ordered_columns)})
SELECT {", ".join(select_expr[name] for name in ordered_columns)}
FROM contacts
"""
)
await conn.execute("DROP TABLE contacts")
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
await conn.commit()
async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Connection) -> None:
"""Move uniquely resolvable orphan contact child rows onto full contacts, drop the rest."""
existing_tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await existing_tables_cursor.fetchall()}
if "contacts" not in existing_tables:
await conn.commit()
return
child_tables = [
table
for table in ("contact_name_history", "contact_advert_paths")
if table in existing_tables
]
if not child_tables:
await conn.commit()
return
orphan_keys: set[str] = set()
for table in child_tables:
cursor = await conn.execute(
f"""
SELECT DISTINCT child.public_key
FROM {table} child
LEFT JOIN contacts c ON c.public_key = child.public_key
WHERE c.public_key IS NULL
"""
)
orphan_keys.update(row[0] for row in await cursor.fetchall())
for orphan_key in sorted(orphan_keys, key=len, reverse=True):
match_cursor = await conn.execute(
"""
SELECT public_key
FROM contacts
WHERE length(public_key) = 64
AND public_key LIKE ? || '%'
ORDER BY public_key
""",
(orphan_key.lower(),),
)
matches = [row[0] for row in await match_cursor.fetchall()]
resolved_key = matches[0] if len(matches) == 1 else None
if resolved_key is not None:
if "contact_name_history" in child_tables:
await conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
SELECT ?, name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ON CONFLICT(public_key, name) DO UPDATE SET
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
""",
(resolved_key, orphan_key),
)
if "contact_advert_paths" in child_tables:
await conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
heard_count = contact_advert_paths.heard_count + excluded.heard_count
""",
(resolved_key, orphan_key),
)
if "contact_name_history" in child_tables:
await conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?",
(orphan_key,),
)
if "contact_advert_paths" in child_tables:
await conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?",
(orphan_key,),
)
await conn.commit()

View File

@@ -2,7 +2,17 @@ from typing import Literal
from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route
from app.path_utils import normalize_contact_route, normalize_route_override
class ContactRoute(BaseModel):
"""A normalized contact route."""
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
path_hash_mode: int = Field(
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
)
class ContactUpsert(BaseModel):
@@ -12,9 +22,10 @@ class ContactUpsert(BaseModel):
name: str | None = None
type: int = 0
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int | None = None
direct_path: str | None = None
direct_path_len: int | None = None
direct_path_hash_mode: int | None = None
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
@@ -40,7 +51,7 @@ class ContactUpsert(BaseModel):
cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert":
"""Convert radio contact data to the contact-row write shape."""
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"),
radio_data.get("out_path_len", -1),
radio_data.get(
@@ -53,9 +64,9 @@ class ContactUpsert(BaseModel):
name=radio_data.get("adv_name"),
type=radio_data.get("type", 0),
flags=radio_data.get("flags", 0),
last_path=last_path,
last_path_len=last_path_len,
out_path_hash_mode=out_path_hash_mode,
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"),
last_advert=radio_data.get("last_advert"),
@@ -68,9 +79,10 @@ class Contact(BaseModel):
name: str | None = None
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int = 0
direct_path: str | None = None
direct_path_len: int = -1
direct_path_hash_mode: int = -1
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
@@ -82,38 +94,99 @@ class Contact(BaseModel):
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
effective_route: ContactRoute | None = None
effective_route_source: Literal["override", "direct", "flood"] = "flood"
direct_route: ContactRoute | None = None
route_override: ContactRoute | None = None
def model_post_init(self, __context) -> None:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
self.direct_path = direct_path or None
self.direct_path_len = direct_path_len
self.direct_path_hash_mode = direct_path_hash_mode
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
)
self.route_override_path = route_override_path or None
self.route_override_len = route_override_len
self.route_override_hash_mode = route_override_hash_mode
if (
route_override_path is not None
and route_override_len is not None
and route_override_hash_mode is not None
):
self.route_override = ContactRoute(
path=route_override_path,
path_len=route_override_len,
path_hash_mode=route_override_hash_mode,
)
else:
self.route_override = None
if direct_path_len >= 0:
self.direct_route = ContactRoute(
path=direct_path,
path_len=direct_path_len,
path_hash_mode=direct_path_hash_mode,
)
else:
self.direct_route = None
path, path_len, path_hash_mode = self.effective_route_tuple()
if self.has_route_override():
self.effective_route_source = "override"
elif self.direct_route is not None:
self.effective_route_source = "direct"
else:
self.effective_route_source = "flood"
self.effective_route = ContactRoute(
path=path,
path_len=path_len,
path_hash_mode=path_hash_mode,
)
def has_route_override(self) -> bool:
return self.route_override_len is not None
def effective_route(self) -> tuple[str, int, int]:
def effective_route_tuple(self) -> tuple[str, int, int]:
if self.has_route_override():
return normalize_contact_route(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
return normalize_contact_route(
self.last_path,
self.last_path_len,
self.out_path_hash_mode,
)
if self.direct_path_len >= 0:
return normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
return "", -1, -1
def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
than our database schema (name, direct_path, etc.).
"""
last_path, last_path_len, out_path_hash_mode = self.effective_route()
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
return {
"public_key": self.public_key,
"adv_name": self.name or "",
"type": self.type,
"flags": self.flags,
"out_path": last_path,
"out_path_len": last_path_len,
"out_path_hash_mode": out_path_hash_mode,
"out_path": effective_path,
"out_path_len": effective_path_len,
"out_path_hash_mode": effective_path_hash_mode,
"adv_lat": self.lat if self.lat is not None else 0.0,
"adv_lon": self.lon if self.lon is not None else 0.0,
"last_advert": self.last_advert if self.last_advert is not None else 0,
@@ -149,7 +222,7 @@ class ContactRoutingOverrideRequest(BaseModel):
route: str = Field(
description=(
"Blank clears the override and resets learned routing to flood, "
"Blank clears the override, "
'"-1" forces flood, "0" forces direct, and explicit routes are '
"comma-separated 1/2/3-byte hop hex values"
)
@@ -158,6 +231,7 @@ class ContactRoutingOverrideRequest(BaseModel):
# Contact type constants
CONTACT_TYPE_REPEATER = 2
CONTACT_TYPE_ROOM = 3
class ContactAdvertPath(BaseModel):

View File

@@ -26,6 +26,7 @@ from app.decoder import (
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
try_decrypt_path,
)
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
@@ -44,6 +45,7 @@ from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
@@ -318,8 +320,7 @@ async def process_raw_packet(
elif payload_type == PayloadType.ADVERT:
# Process all advert arrivals (even payload-hash duplicates) so the
# path-freshness logic in _process_advertisement can pick the shortest
# path heard within the freshness window.
# advert-history table retains recent path observations.
await _process_advertisement(raw_bytes, ts, packet_info)
elif payload_type == PayloadType.TEXT_MESSAGE:
@@ -328,6 +329,9 @@ async def process_raw_packet(
if decrypt_result:
result.update(decrypt_result)
elif payload_type == PayloadType.PATH:
await _process_path_packet(raw_bytes, ts, packet_info)
# Always broadcast raw packet for the packet feed UI (even duplicates)
# This enables the frontend cracker to see all incoming packets in real-time
broadcast_payload = RawPacketBroadcast(
@@ -430,51 +434,20 @@ async def _process_advertisement(
logger.debug("Failed to parse advertisement payload")
return
# Extract path info from packet
new_path_len = packet_info.path_length
new_path_hex = packet_info.path.hex() if packet_info.path else ""
# Try to find existing contact
existing = await ContactRepository.get_by_key(advert.public_key.lower())
# Determine which path to use: keep shorter path if heard recently (within 60s)
# This handles advertisement echoes through different routes
PATH_FRESHNESS_SECONDS = 60
use_existing_path = False
if existing and existing.last_advert:
path_age = timestamp - existing.last_advert
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
# Keep existing path if it's fresh and shorter (or equal)
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
use_existing_path = True
logger.debug(
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
advert.public_key[:12],
existing_path_len,
new_path_len,
path_age,
)
if use_existing_path:
assert existing is not None # Guaranteed by the conditions that set use_existing_path
path_len = existing.last_path_len if existing.last_path_len is not None else -1
path_hex = existing.last_path or ""
out_path_hash_mode = existing.out_path_hash_mode
else:
path_len = new_path_len
path_hex = new_path_hex
out_path_hash_mode = packet_info.path_hash_size - 1
logger.debug(
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
advert.public_key[:12],
advert.name,
advert.device_role,
advert.lat,
advert.lon,
path_len,
new_path_len,
)
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
@@ -501,9 +474,6 @@ async def _process_advertisement(
lon=advert.lon,
last_advert=timestamp,
last_seen=timestamp,
last_path=path_hex,
last_path_len=path_len,
out_path_hash_mode=out_path_hash_mode,
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
@@ -667,3 +637,90 @@ async def _process_direct_message(
# Couldn't decrypt with any known contact
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
return None
async def _process_path_packet(
raw_bytes: bytes,
timestamp: int,
packet_info: PacketInfo | None,
) -> None:
"""Process a PATH packet and update the learned direct route."""
if not has_private_key():
return
private_key = get_private_key()
our_public_key = get_public_key()
if private_key is None or our_public_key is None:
return
if packet_info is None:
packet_info = parse_packet(raw_bytes)
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
return
dest_hash = format(packet_info.payload[0], "02x").lower()
src_hash = format(packet_info.payload[1], "02x").lower()
our_first_byte = format(our_public_key[0], "02x").lower()
if dest_hash != our_first_byte:
return
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
if not candidate_contacts:
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
return
for contact in candidate_contacts:
if len(contact.public_key) != 64:
continue
try:
contact_public_key = bytes.fromhex(contact.public_key)
except ValueError:
continue
result = try_decrypt_path(
raw_packet=raw_bytes,
our_private_key=private_key,
their_public_key=contact_public_key,
our_public_key=our_public_key,
)
if result is None:
continue
await ContactRepository.update_direct_path(
contact.public_key,
result.returned_path.hex(),
result.returned_path_len,
result.returned_path_hash_mode,
updated_at=timestamp,
)
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
ack_code = result.extra[:4].hex()
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info(
"Applied bundled PATH ACK for %s via contact %s",
ack_code,
contact.public_key[:12],
)
else:
logger.debug(
"Buffered bundled PATH ACK %s via contact %s",
ack_code,
contact.public_key[:12],
)
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
logger.debug(
"Observed bundled PATH RESPONSE from %s (%d bytes)",
contact.public_key[:12],
len(result.extra),
)
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
if refreshed_contact is not None:
broadcast_event("contact", refreshed_contact.model_dump())
return
logger.debug(
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
)

View File

@@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
def normalize_contact_route(
path_hex: str | None,
path_len: int | None,
out_path_hash_mode: int | None,
path_hash_mode: int | None,
) -> tuple[str, int, int]:
"""Normalize stored contact route fields.
Handles legacy/bad rows where the packed wire path byte was stored directly
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
Returns `(path_hex, hop_count, hash_mode)`.
"""
normalized_path = path_hex or ""
@@ -169,7 +169,7 @@ def normalize_contact_route(
normalized_len = -1
try:
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
except (TypeError, ValueError):
normalized_mode = None
@@ -207,7 +207,7 @@ def normalize_contact_route(
def normalize_route_override(
path_hex: str | None,
path_len: int | None,
out_path_hash_mode: int | None,
path_hash_mode: int | None,
) -> tuple[str | None, int | None, int | None]:
"""Normalize optional route-override fields while preserving the unset state."""
if path_len is None:
@@ -216,7 +216,7 @@ def normalize_route_override(
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
path_hex,
path_len,
out_path_hash_mode,
path_hash_mode,
)
return normalized_path, normalized_len, normalized_mode

View File

@@ -2,16 +2,20 @@ import asyncio
import glob
import logging
import platform
import re
from collections import OrderedDict
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
from meshcore import MeshCore
from serial.serialutil import SerialException
from app.config import settings
from app.keystore import clear_keys
logger = logging.getLogger(__name__)
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
_SERIAL_PORT_ERROR_RE = re.compile(r"could not open port (?P<port>.+?):")
class RadioOperationError(RuntimeError):
@@ -68,6 +72,36 @@ def detect_serial_devices() -> list[str]:
return devices
def _extract_serial_port_from_error(exc: Exception) -> str | None:
"""Best-effort extraction of a serial port path from a pyserial error."""
message = str(exc)
match = _SERIAL_PORT_ERROR_RE.search(message)
if match:
return match.group("port")
return None
def _format_reconnect_failure(exc: Exception) -> tuple[str, str, bool]:
"""Return log message, frontend detail, and whether to log a traceback."""
if settings.connection_type == "serial":
if isinstance(exc, RuntimeError) and str(exc).startswith("No MeshCore radio found"):
message = (
"Could not find a MeshCore radio on any serial port. "
"Did the radio get disconnected or change serial ports?"
)
return (message, message, False)
if isinstance(exc, SerialException):
port = settings.serial_port or _extract_serial_port_from_error(exc) or "the serial port"
message = (
f"Could not connect to serial port {port}. "
"Did the radio get disconnected or change serial ports?"
)
return (message, message, False)
return (f"Reconnection failed: {exc}", str(exc), True)
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
"""Test if a MeshCore radio responds on the given serial port."""
mc = None
@@ -131,6 +165,7 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._frontend_reconnect_error_broadcasts: int = 0
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
@@ -171,6 +206,20 @@ class RadioManager:
else:
logger.error("Attempted to release unlocked radio operation lock (%s)", name)
def _reset_connected_runtime_state(self) -> None:
"""Clear cached runtime state after a transport teardown completes."""
self._setup_complete = False
self.device_info_loaded = False
self.max_contacts = None
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
self.reset_channel_send_cache()
self.clear_pending_message_channel_slots()
@asynccontextmanager
async def radio_operation(
self,
@@ -387,6 +436,21 @@ class RadioManager:
self._last_connected = False
await self.disconnect()
def _reset_reconnect_error_broadcasts(self) -> None:
self._frontend_reconnect_error_broadcasts = 0
def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
from app.websocket import broadcast_error
self._frontend_reconnect_error_broadcasts += 1
if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
return
if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
broadcast_error("Reconnection failed", details)
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None)
@@ -485,25 +549,29 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
clear_keys()
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
self._reset_reconnect_error_broadcasts()
if self._meshcore is None:
return
await self._acquire_operation_lock("disconnect", blocking=True)
try:
mc = self._meshcore
if mc is None:
return
logger.debug("Disconnecting from radio")
await self._disable_meshcore_auto_reconnect(mc)
await mc.disconnect()
await self._disable_meshcore_auto_reconnect(mc)
self._meshcore = None
self._setup_complete = False
self.device_info_loaded = False
self.max_contacts = None
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
self.reset_channel_send_cache()
self.clear_pending_message_channel_slots()
try:
await mc.disconnect()
finally:
await self._disable_meshcore_auto_reconnect(mc)
if self._meshcore is mc:
self._meshcore = None
self._reset_connected_runtime_state()
logger.debug("Radio disconnected")
finally:
self._release_operation_lock("disconnect")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
"""Attempt to reconnect to the radio.
@@ -511,7 +579,7 @@ class RadioManager:
Returns True if reconnection was successful, False otherwise.
Uses a lock to prevent concurrent reconnection attempts.
"""
from app.websocket import broadcast_error, broadcast_health
from app.websocket import broadcast_health
# Lazily initialize lock (can't create in __init__ before event loop exists)
if self._reconnect_lock is None:
@@ -534,10 +602,9 @@ class RadioManager:
# Disconnect if we have a stale connection
if self._meshcore is not None:
try:
await self._meshcore.disconnect()
await self.disconnect()
except Exception:
pass
self._meshcore = None
# Try to connect (will auto-detect if no port specified)
await self.connect()
@@ -549,6 +616,7 @@ class RadioManager:
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
self._reset_reconnect_error_broadcasts()
if broadcast_on_success:
broadcast_health(True, self._connection_info)
return True
@@ -557,8 +625,9 @@ class RadioManager:
return False
except Exception as e:
logger.warning("Reconnection failed: %s", e, exc_info=True)
broadcast_error("Reconnection failed", str(e))
log_message, frontend_detail, include_traceback = _format_reconnect_failure(e)
logger.warning(log_message, exc_info=include_traceback)
self._broadcast_reconnect_error_if_needed(frontend_detail)
return False
async def start_connection_monitor(self) -> None:

View File

@@ -14,6 +14,7 @@ import logging
import math
import time
from contextlib import asynccontextmanager
from typing import Literal
from meshcore import EventType, MeshCore
@@ -37,15 +38,18 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_CHANNELS = 40
AdvertMode = Literal["flood", "zero_hop"]
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
"""Return key contact fields for sync failure diagnostics."""
return {
"type": contact.type,
"flags": contact.flags,
"last_path": contact.last_path,
"last_path_len": contact.last_path_len,
"out_path_hash_mode": contact.out_path_hash_mode,
"direct_path": contact.direct_path,
"direct_path_len": contact.direct_path_len,
"direct_path_hash_mode": contact.direct_path_hash_mode,
"direct_path_updated_at": contact.direct_path_updated_at,
"route_override_path": contact.route_override_path,
"route_override_len": contact.route_override_len,
"route_override_hash_mode": contact.route_override_hash_mode,
@@ -686,7 +690,12 @@ async def stop_message_polling():
logger.info("Stopped periodic message polling")
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
async def send_advertisement(
mc: MeshCore,
*,
force: bool = False,
mode: AdvertMode = "flood",
) -> bool:
"""Send an advertisement to announce presence on the mesh.
Respects the configured advert_interval - won't send if not enough time
@@ -695,11 +704,15 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
Args:
mc: The MeshCore instance to use for the advertisement.
force: If True, send immediately regardless of interval.
mode: Advertisement mode. Flood adverts use the persisted flood-advert
throttle state; zero-hop adverts currently send immediately.
Returns True if successful, False otherwise (including if throttled).
"""
# Check if enough time has elapsed (unless forced)
if not force:
use_flood = mode == "flood"
# Only flood adverts currently participate in persisted throttle state.
if use_flood and not force:
settings = await AppSettingsRepository.get()
interval = settings.advert_interval
last_time = settings.last_advert_time
@@ -726,18 +739,19 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
return False
try:
result = await mc.commands.send_advert(flood=True)
result = await mc.commands.send_advert(flood=use_flood)
if result.type == EventType.OK:
# Update last_advert_time in database
now = int(time.time())
await AppSettingsRepository.update(last_advert_time=now)
logger.info("Advertisement sent successfully")
if use_flood:
# Track flood advert timing for periodic/startup throttling.
now = int(time.time())
await AppSettingsRepository.update(last_advert_time=now)
logger.info("%s advertisement sent successfully", mode.replace("_", "-"))
return True
else:
logger.warning("Failed to send advertisement: %s", result.payload)
logger.warning("Failed to send %s advertisement: %s", mode, result.payload)
return False
except Exception as e:
logger.warning("Error sending advertisement: %s", e, exc_info=True)
logger.warning("Error sending %s advertisement: %s", mode, e, exc_info=True)
return False
@@ -796,16 +810,177 @@ async def stop_periodic_advert():
logger.info("Stopped periodic advertisement")
# Prevents reboot-loop: once we've rebooted to fix clock skew this session,
# don't do it again (the hardware RTC case can't be fixed by reboot).
_clock_reboot_attempted: bool = False
_CLOCK_WRAP_TARGET = 0xFFFFFFFF
_CLOCK_WRAP_POLL_INTERVAL = 0.2
_CLOCK_WRAP_TIMEOUT = 3.0
async def _query_radio_time(mc: MeshCore) -> int | None:
"""Return the radio's current epoch, or None if it can't be read."""
try:
result = await mc.commands.get_time()
except Exception:
return None
if result.payload is None:
return None
value = result.payload.get("time")
if isinstance(value, int):
return value
return None
async def _attempt_clock_wraparound(mc: MeshCore, *, now: int, observed_radio_time: int) -> bool:
"""Try the experimental uint32 wraparound trick, then retry normal time sync."""
logger.warning(
"Experimental __CLOWNTOWN_DO_CLOCK_WRAPAROUND enabled: attempting uint32 "
"clock wraparound before normal time sync (radio=%d, system=%d).",
observed_radio_time,
now,
)
result = await mc.commands.set_time(_CLOCK_WRAP_TARGET)
if result.type != EventType.OK:
logger.warning(
"Clock wraparound pre-step failed: set_time(%d) returned %s.",
_CLOCK_WRAP_TARGET,
result.type,
)
return False
deadline = time.monotonic() + _CLOCK_WRAP_TIMEOUT
wrapped_time: int | None = None
while time.monotonic() < deadline:
await asyncio.sleep(_CLOCK_WRAP_POLL_INTERVAL)
wrapped_time = await _query_radio_time(mc)
if wrapped_time is not None and wrapped_time < 60:
break
else:
wrapped_time = None
if wrapped_time is None:
logger.warning(
"Clock wraparound experiment did not observe a wrapped epoch within %.1f "
"seconds; falling back to normal recovery.",
_CLOCK_WRAP_TIMEOUT,
)
return False
logger.warning(
"Clock wraparound experiment observed wrapped epoch %d; retrying normal time sync.",
wrapped_time,
)
retry = await mc.commands.set_time(now)
if retry.type == EventType.OK:
logger.warning("Clock sync succeeded after experimental wraparound.")
return True
logger.warning(
"Clock sync still failed after experimental wraparound: set_time(%d) returned %s.",
now,
retry.type,
)
return False
async def sync_radio_time(mc: MeshCore) -> bool:
"""Sync the radio's clock with the system time.
Returns True if successful, False otherwise.
The firmware only accepts forward time adjustments (new >= current).
If the radio's clock is already ahead, set_time is silently rejected
with an ERROR response. We detect this by checking the response and,
on failure, querying the radio's actual time so we can log the skew.
When significant forward skew is detected for the first time in a
session, the radio is rebooted so that boards with a volatile clock
(most companion radios) reset to their default epoch and accept the
correct time on the next connection setup. The reboot is attempted
only once; if it doesn't help (hardware RTC persists the wrong time),
the skew is logged as a warning on subsequent syncs.
Returns True if the radio accepted the new time, False otherwise.
"""
global _clock_reboot_attempted # noqa: PLW0603
try:
now = int(time.time())
await mc.commands.set_time(now)
logger.debug("Synced radio time to %d", now)
return True
preflight_radio_time: int | None = None
wraparound_attempted = False
if settings.clowntown_do_clock_wraparound:
preflight_radio_time = await _query_radio_time(mc)
if preflight_radio_time is not None and preflight_radio_time > now:
wraparound_attempted = True
if await _attempt_clock_wraparound(
mc,
now=now,
observed_radio_time=preflight_radio_time,
):
return True
result = await mc.commands.set_time(now)
if result.type == EventType.OK:
logger.debug("Synced radio time to %d", now)
return True
# Firmware rejected the time (most likely radio clock is ahead).
# Query actual radio time so we can report the delta.
radio_time = await _query_radio_time(mc)
if radio_time is not None:
delta = radio_time - now
logger.warning(
"Radio rejected time sync: radio clock is %+d seconds "
"(%+.1f hours) from system time (radio=%d, system=%d).",
delta,
delta / 3600.0,
radio_time,
now,
)
else:
delta = None
logger.warning(
"Radio rejected time sync (set_time returned %s) "
"and get_time query failed; cannot determine clock skew.",
result.type,
)
if (
settings.clowntown_do_clock_wraparound
and not wraparound_attempted
and radio_time is not None
and radio_time > now
and await _attempt_clock_wraparound(
mc,
now=now,
observed_radio_time=radio_time,
)
):
return True
# If the clock is significantly ahead and we haven't already tried
# a corrective reboot this session, reboot the radio. Boards with
# a volatile RTC (most companion radios) will reset their clock on
# reboot, allowing the next post-connect sync to succeed.
if not _clock_reboot_attempted and (delta is None or delta > 30):
_clock_reboot_attempted = True
logger.warning(
"Rebooting radio to reset clock skew. Boards with a "
"volatile RTC will accept the correct time after restart."
)
try:
await mc.commands.reboot()
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."
)
return False
except Exception as e:
logger.warning("Failed to sync radio time: %s", e, exc_info=True)
return False

View File

@@ -36,11 +36,20 @@ class ContactRepository:
@staticmethod
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
contact_row = ContactRepository._coerce_contact_upsert(contact)
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
contact_row.last_path,
contact_row.last_path_len,
contact_row.out_path_hash_mode,
)
if (
contact_row.direct_path is None
and contact_row.direct_path_len is None
and contact_row.direct_path_hash_mode is None
):
direct_path = None
direct_path_len = None
direct_path_hash_mode = None
else:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
contact_row.direct_path,
contact_row.direct_path_len,
contact_row.direct_path_hash_mode,
)
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
contact_row.route_override_path,
@@ -51,20 +60,25 @@ class ContactRepository:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
out_path_hash_mode,
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
direct_path_hash_mode, direct_path_updated_at,
route_override_path, route_override_len,
route_override_hash_mode,
last_advert, lat, lon, last_seen,
on_radio, last_contacted, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
out_path_hash_mode = excluded.out_path_hash_mode,
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
direct_path_hash_mode = COALESCE(
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
),
direct_path_updated_at = COALESCE(
excluded.direct_path_updated_at, contacts.direct_path_updated_at
),
route_override_path = COALESCE(
excluded.route_override_path, contacts.route_override_path
),
@@ -87,9 +101,10 @@ class ContactRepository:
contact_row.name,
contact_row.type,
contact_row.flags,
last_path,
last_path_len,
out_path_hash_mode,
direct_path,
direct_path_len,
direct_path_hash_mode,
contact_row.direct_path_updated_at,
route_override_path,
route_override_len,
route_override_hash_mode,
@@ -107,12 +122,12 @@ class ContactRepository:
@staticmethod
def _row_to_contact(row) -> Contact:
"""Convert a database row to a Contact model."""
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
row["last_path"],
row["last_path_len"],
row["out_path_hash_mode"],
)
available_columns = set(row.keys())
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
row["direct_path"] if "direct_path" in available_columns else None,
row["direct_path_len"] if "direct_path_len" in available_columns else None,
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
)
route_override_path = (
row["route_override_path"] if "route_override_path" in available_columns else None
)
@@ -136,9 +151,14 @@ class ContactRepository:
name=row["name"],
type=row["type"],
flags=row["flags"],
last_path=last_path,
last_path_len=last_path_len,
out_path_hash_mode=out_path_hash_mode,
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
direct_path_updated_at=(
row["direct_path_updated_at"]
if "direct_path_updated_at" in available_columns
else None
),
route_override_path=route_override_path,
route_override_len=route_override_len,
route_override_hash_mode=route_override_hash_mode,
@@ -286,26 +306,30 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(
async def update_direct_path(
public_key: str,
path: str,
path_len: int,
out_path_hash_mode: int | None = None,
path_hash_mode: int | None = None,
updated_at: int | None = None,
) -> None:
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
path,
path_len,
out_path_hash_mode,
path_hash_mode,
)
ts = updated_at if updated_at is not None else int(time.time())
await db.conn.execute(
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
direct_path_updated_at = ?,
last_seen = ? WHERE public_key = ?""",
(
normalized_path,
normalized_path_len,
normalized_hash_mode,
int(time.time()),
ts,
ts,
public_key.lower(),
),
)
@@ -316,12 +340,12 @@ class ContactRepository:
public_key: str,
path: str | None,
path_len: int | None,
out_path_hash_mode: int | None = None,
path_hash_mode: int | None = None,
) -> None:
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
path,
path_len,
out_path_hash_mode,
path_hash_mode,
)
await db.conn.execute(
"""
@@ -407,6 +431,43 @@ class ContactRepository:
Returns the placeholder public keys that were merged into the full key.
"""
async def migrate_child_rows(old_key: str, new_key: str) -> None:
await db.conn.execute(
"""
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
SELECT ?, name, first_seen, last_seen
FROM contact_name_history
WHERE public_key = ?
ON CONFLICT(public_key, name) DO UPDATE SET
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
""",
(new_key, old_key),
)
await db.conn.execute(
"""
INSERT INTO contact_advert_paths
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
FROM contact_advert_paths
WHERE public_key = ?
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
heard_count = contact_advert_paths.heard_count + excluded.heard_count
""",
(new_key, old_key),
)
await db.conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?",
(old_key,),
)
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?",
(old_key,),
)
normalized_full_key = full_key.lower()
cursor = await db.conn.execute(
"""
@@ -443,6 +504,8 @@ class ContactRepository:
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
await migrate_child_rows(old_key, normalized_full_key)
if full_exists:
await db.conn.execute(
"""

View File

@@ -66,9 +66,10 @@ class MessageRepository:
) -> int | None:
"""Create a message, returning the ID or None if duplicate.
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
(type, conversation_key, text, sender_timestamp). This prevents
duplicate messages when the same message arrives via multiple RF paths.
Uses INSERT OR IGNORE to handle the message dedup indexes:
- channel messages dedupe by content/timestamp for echo reconciliation
- incoming direct messages dedupe by conversation/text/timestamp so
raw-packet and fallback observations merge onto one row
The path parameter is converted to the paths JSON array format.
"""
@@ -553,22 +554,32 @@ class MessageRepository:
return MessageRepository._row_to_message(row)
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
@staticmethod
async def get_by_content(
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int | None,
outgoing: bool | None = None,
) -> "Message | None":
"""Look up a message by its unique content fields."""
cursor = await db.conn.execute(
"""
query = """
SELECT * FROM messages
WHERE type = ? AND conversation_key = ? AND text = ?
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
""",
(msg_type, conversation_key, text, sender_timestamp, sender_timestamp),
)
"""
params: list[Any] = [msg_type, conversation_key, text, sender_timestamp, sender_timestamp]
if outgoing is not None:
query += " AND outgoing = ?"
params.append(1 if outgoing else 0)
query += " ORDER BY id ASC"
cursor = await db.conn.execute(query, params)
row = await cursor.fetchone()
if not row:
return None

View File

@@ -304,7 +304,6 @@ async def create_contact(
contact_upsert = ContactUpsert(
public_key=lower_key,
name=request.name,
out_path_hash_mode=-1,
on_radio=False,
)
await ContactRepository.upsert(contact_upsert)
@@ -474,7 +473,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
return_len = int(payload.get("in_path_len") or 0)
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
await ContactRepository.update_path(
await ContactRepository.update_direct_path(
contact.public_key,
forward_path,
forward_len,
@@ -524,9 +523,8 @@ async def set_contact_routing_override(
route_text = request.route.strip()
if route_text == "":
await ContactRepository.clear_routing_override(contact.public_key)
await ContactRepository.update_path(contact.public_key, "", -1, -1)
logger.info(
"Cleared routing override and reset learned path to flood for %s",
"Cleared routing override for %s",
contact.public_key[:12],
)
elif route_text == "-1":

View File

@@ -1,10 +1,7 @@
import hashlib
import importlib.metadata
import logging
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter
@@ -13,18 +10,35 @@ from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import MessageRepository
from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
logger = logging.getLogger(__name__)
router = APIRouter(tags=["debug"])
LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW"
LOG_COPY_BOUNDARY_LINE = "-" * 64
LOG_COPY_BOUNDARY_PREFIX = [
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_MESSAGE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
]
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
commit_hash: str | None = None
commit_source: str | None = None
git_branch: str | None = None
git_dirty: bool | None = None
python_version: str
@@ -64,6 +78,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
@@ -72,54 +87,31 @@ class DebugRadioProbe(BaseModel):
channels: DebugChannelAudit | None = None
class DebugDatabaseInfo(BaseModel):
total_dms: int
total_channel_messages: int
total_outgoing: int
class DebugSnapshotResponse(BaseModel):
captured_at: str
application: DebugApplicationInfo
health: HealthResponse
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
radio_probe: DebugRadioProbe
logs: list[str]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _get_app_version() -> str:
try:
return importlib.metadata.version("remoteterm-meshcore")
except Exception:
pyproject = _repo_root() / "pyproject.toml"
try:
for line in pyproject.read_text().splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except Exception:
pass
return "0.0.0"
def _git_output(*args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=_repo_root(),
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _build_application_info() -> DebugApplicationInfo:
dirty_output = _git_output("status", "--porcelain")
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
return DebugApplicationInfo(
version=_get_app_version(),
commit_hash=_git_output("rev-parse", "HEAD"),
git_branch=_git_output("rev-parse", "--abbrev-ref", "HEAD"),
version=build_info.version,
version_source=build_info.version_source,
commit_hash=build_info.commit_hash,
commit_source=build_info.commit_source,
git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"),
git_dirty=(dirty_output is not None and dirty_output != ""),
python_version=sys.version.split()[0],
)
@@ -250,6 +242,9 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe(
performed=True,
errors=errors,
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info,
stats_core=stats_core,
@@ -270,6 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
statistics = await StatisticsRepository.get_all()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
@@ -294,6 +290,11 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
},
),
database=DebugDatabaseInfo(
total_dms=statistics["total_dms"],
total_channel_messages=statistics["total_channel_messages"],
total_outgoing=statistics["total_outgoing"],
),
radio_probe=radio_probe,
logs=get_recent_log_lines(limit=1000),
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
)

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel
from app.config import settings
from app.repository import RawPacketRepository
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
router = APIRouter(tags=["health"])
@@ -19,12 +20,18 @@ class RadioDeviceInfoResponse(BaseModel):
max_channels: int | None = None
class AppInfoResponse(BaseModel):
version: str
commit_hash: str | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
app_info: AppInfoResponse | None = None
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
@@ -41,6 +48,7 @@ def _clean_optional_str(value: object) -> str | None:
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info()
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
@@ -102,6 +110,10 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"app_info": {
"version": app_build_info.version,
"commit_hash": app_build_info.commit_hash,
},
"radio_device_info": radio_device_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,

View File

@@ -32,6 +32,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
RadioAdvertMode = Literal["flood", "zero_hop"]
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
DISCOVERY_WINDOW_SECONDS = 8.0
_DISCOVERY_TARGET_BITS = {
@@ -80,6 +81,10 @@ class RadioConfigResponse(BaseModel):
default="current",
description="Whether adverts include the node's current location state",
)
multi_acks_enabled: bool = Field(
default=False,
description="Whether the radio sends an extra direct ACK transmission",
)
class RadioConfigUpdate(BaseModel):
@@ -98,12 +103,23 @@ class RadioConfigUpdate(BaseModel):
default=None,
description="Whether adverts include the node's current location state",
)
multi_acks_enabled: bool | None = Field(
default=None,
description="Whether the radio sends an extra direct ACK transmission",
)
class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
class RadioAdvertiseRequest(BaseModel):
mode: RadioAdvertMode = Field(
default="flood",
description="Advertisement mode: flood through repeaters or zero-hop local only",
)
def _monotonic() -> float:
return time.monotonic()
@@ -214,6 +230,7 @@ async def get_radio_config() -> RadioConfigResponse:
path_hash_mode=radio_manager.path_hash_mode,
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source,
multi_acks_enabled=bool(info.get("multi_acks", 0)),
)
@@ -266,24 +283,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
@router.post("/advertise")
async def send_advertisement() -> dict:
"""Send a flood advertisement to announce presence on the mesh.
async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict:
"""Send an advertisement to announce presence on the mesh.
Manual advertisement requests always send immediately, updating the
last_advert_time which affects when the next periodic/startup advert
can occur.
Manual advertisement requests always send immediately. Flood adverts update
the shared flood-advert timing state used by periodic/startup advertising;
zero-hop adverts currently do not.
Returns:
status: "ok" if sent successfully
"""
require_connected()
mode: RadioAdvertMode = request.mode if request is not None else "flood"
logger.info("Sending flood advertisement")
logger.info("Sending %s advertisement", mode.replace("_", "-"))
async with radio_manager.radio_operation("manual_advertisement") as mc:
success = await do_send_advertisement(mc, force=True)
success = await do_send_advertisement(mc, force=True, mode=mode)
if not success:
raise HTTPException(status_code=500, detail="Failed to send advertisement")
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
return {"status": "ok"}

View File

@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException
@@ -28,6 +27,14 @@ from app.models import (
)
from app.repository import ContactRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
_monotonic,
batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection,
require_server_capable_contact,
send_contact_cli_command,
)
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
@@ -43,39 +50,11 @@ ACL_PERMISSION_NAMES = {
3: "Admin",
}
router = APIRouter(prefix="/contacts", tags=["repeaters"])
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
REPEATER_LOGIN_REJECTED_MESSAGE = (
"The repeater replied but did not confirm this login. "
"Existing access may still allow some repeater operations, but admin actions may fail."
)
REPEATER_LOGIN_SEND_FAILED_MESSAGE = (
"The login request could not be sent to the repeater. "
"The dashboard is still available, but repeater operations may fail until a login succeeds."
)
REPEATER_LOGIN_TIMEOUT_MESSAGE = (
"No login confirmation was heard from the repeater. "
"On current repeater firmware, that can mean the password was wrong, "
"blank-password login was not allowed by the ACL, or the reply was missed in transit. "
"The dashboard is still available; try logging in again if admin actions fail."
)
def _monotonic() -> float:
"""Wrapper around time.monotonic() for testability.
Patching time.monotonic directly breaks the asyncio event loop which also
uses it. This indirection allows tests to control the clock safely.
"""
return time.monotonic()
def _extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload))
if text.startswith("> "):
text = text[2:]
return text
return extract_response_text(event)
async def _fetch_repeater_response(
@@ -83,21 +62,6 @@ async def _fetch_repeater_response(
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
target repeater arrives or the wall-clock deadline expires. Unrelated messages
are safe to skip — meshcore's event dispatcher already delivers them to the
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
Args:
mc: MeshCore instance
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
timeout: Wall-clock seconds before giving up
Returns:
The matching Event, or None if no response arrived before the deadline.
"""
deadline = _monotonic() + timeout
while _monotonic() < deadline:
@@ -105,13 +69,12 @@ async def _fetch_repeater_response(
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as e:
logger.debug("get_msg() exception: %s", e)
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
await asyncio.sleep(1.0)
continue
if result.type == EventType.NO_MORE_MSGS:
# No messages queued yet — wait and retry
await asyncio.sleep(1.0)
continue
@@ -125,8 +88,6 @@ async def _fetch_repeater_response(
txt_type = result.payload.get("txt_type", 0)
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
# Not our target — already dispatched to subscribers by meshcore,
# so just continue draining the queue.
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
msg_prefix,
@@ -136,7 +97,6 @@ async def _fetch_repeater_response(
continue
if result.type == EventType.CHANNEL_MSG_RECV:
# Already dispatched to subscribers by meshcore; skip.
logger.debug(
"Skipping channel message (channel_idx=%s) during repeater fetch",
result.payload.get("channel_idx"),
@@ -150,87 +110,13 @@ async def _fetch_repeater_response(
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
"""Prepare connection to a repeater by adding to radio and attempting login.
Args:
mc: MeshCore instance
contact: The repeater contact
password: Password for login (empty string for no password)
"""
pubkey_prefix = contact.public_key[:12].lower()
loop = asyncio.get_running_loop()
login_future = loop.create_future()
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
if login_future.done():
return
login_future.set_result(
RepeaterLoginResponse(
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
authenticated=event_type == EventType.LOGIN_SUCCESS,
message=message,
)
)
success_subscription = mc.subscribe(
EventType.LOGIN_SUCCESS,
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
attribute_filters={"pubkey_prefix": pubkey_prefix},
return await prepare_authenticated_contact_connection(
mc,
contact,
password,
label="repeater",
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
failed_subscription = mc.subscribe(
EventType.LOGIN_FAILED,
lambda _event: _resolve_login(
EventType.LOGIN_FAILED,
REPEATER_LOGIN_REJECTED_MESSAGE,
),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
try:
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
)
try:
return await asyncio.wait_for(
login_future,
timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning(
"No login response from repeater %s within %.1fs",
contact.public_key[:12],
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=REPEATER_LOGIN_TIMEOUT_MESSAGE,
)
except HTTPException as exc:
logger.warning(
"Repeater login setup failed for %s: %s",
contact.public_key[:12],
exc.detail,
)
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({exc.detail})",
)
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
def _require_repeater(contact: Contact) -> None:
@@ -403,43 +289,7 @@ async def _batch_cli_fetch(
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a repeater and collect responses.
Opens a radio operation with polling paused and auto-fetch suspended (since
we call get_msg() directly via _fetch_repeater_response), adds the contact
to the radio for routing, then sends each command sequentially with a 1-second
gap between them.
Returns a dict mapping field names to response strings (or None on timeout).
"""
results: dict[str, str | None] = {field: None for _, field in commands}
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for i, (cmd, field) in enumerate(commands):
if i > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
continue
response_event = await _fetch_repeater_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = _extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
return await batch_cli_fetch(contact, operation_name, commands)
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
@@ -524,72 +374,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
@router.post("/{public_key}/command", response_model=CommandResponse)
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
"""Send a CLI command to a repeater.
The contact must be a repeater (type=2). The user must have already logged in
via the repeater/login endpoint. This endpoint ensures the contact is on the
radio before sending commands (the repeater remembers ACL permissions after login).
Common commands:
- get name, set name <value>
- get tx, set tx <dbm>
- get radio, set radio <freq,bw,sf,cr>
- tempradio <freq,bw,sf,cr,minutes>
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
- clock, clock sync, time <epoch_seconds>
- reboot
- ver
"""
"""Send a CLI command to a repeater or room server."""
require_connected()
# Get contact from database
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
async with radio_manager.radio_operation(
"send_repeater_command",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
# Send the command
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
)
# Wait for response using validated fetch loop
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
if response_event is None:
logger.warning(
"No response from repeater %s for command: %s",
contact.public_key[:12],
request.command,
)
return CommandResponse(
command=request.command,
response="(no response - command may have been processed)",
)
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
response_text = _extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
)
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
return CommandResponse(
command=request.command,
response=response_text,
sender_timestamp=sender_timestamp,
)
require_server_capable_contact(contact)
return await send_contact_cli_command(
contact,
request.command,
operation_name="send_repeater_command",
)

145
app/routers/rooms.py Normal file
View File

@@ -0,0 +1,145 @@
from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import (
CONTACT_TYPE_ROOM,
AclEntry,
LppSensor,
RepeaterAclResponse,
RepeaterLoginRequest,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterStatusResponse,
)
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
prepare_authenticated_contact_connection,
require_server_capable_contact,
)
from app.services.radio_runtime import radio_runtime as radio_manager
router = APIRouter(prefix="/contacts", tags=["rooms"])
def _require_room(contact) -> None:
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt room-server login and report whether auth was confirmed."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_login",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
return await prepare_authenticated_contact_connection(
mc,
contact,
request.password,
label="room server",
)
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
async def room_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_status", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
if status is None:
raise HTTPException(status_code=504, detail="No status response from room server")
return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
last_rssi_dbm=status.get("last_rssi", 0),
last_snr_db=status.get("last_snr", 0.0),
packets_received=status.get("nb_recv", 0),
packets_sent=status.get("nb_sent", 0),
airtime_seconds=status.get("airtime", 0),
rx_airtime_seconds=status.get("rx_airtime", 0),
uptime_seconds=status.get("uptime", 0),
sent_flood=status.get("sent_flood", 0),
sent_direct=status.get("sent_direct", 0),
recv_flood=status.get("recv_flood", 0),
recv_direct=status.get("recv_direct", 0),
flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0),
)
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP telemetry from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
telemetry = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if telemetry is None:
raise HTTPException(status_code=504, detail="No telemetry response from room server")
sensors = [
LppSensor(
channel=entry.get("channel", 0),
type_name=str(entry.get("type", "unknown")),
value=entry.get("value", 0),
)
for entry in telemetry
]
return RepeaterLppTelemetryResponse(sensors=sensors)
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
async def room_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL entries from a room server."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_room(contact)
async with radio_manager.radio_operation(
"room_acl", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
acl_entries = []
if acl_data and isinstance(acl_data, list):
from app.repository import ContactRepository
from app.routers.repeaters import ACL_PERMISSION_NAMES
for entry in acl_data:
pubkey_prefix = entry.get("key", "")
perm = entry.get("perm", 0)
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
acl_entries.append(
AclEntry(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
permission=perm,
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
)
)
return RepeaterAclResponse(acl=acl_entries)

View File

@@ -0,0 +1,317 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from fastapi import HTTPException
from meshcore import EventType
from app.models import (
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
CommandResponse,
Contact,
RepeaterLoginResponse,
)
from app.routers.contacts import _ensure_on_radio
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
def _monotonic() -> float:
"""Wrapper around time.monotonic() for testability."""
return time.monotonic()
def get_server_contact_label(contact: Contact) -> str:
"""Return a user-facing label for server-capable contacts."""
if contact.type == CONTACT_TYPE_REPEATER:
return "repeater"
if contact.type == CONTACT_TYPE_ROOM:
return "room server"
return "server"
def require_server_capable_contact(
contact: Contact,
*,
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
) -> None:
"""Raise 400 if the contact does not support server control/login features."""
if contact.type not in allowed_types:
expected = ", ".join(str(value) for value in allowed_types)
raise HTTPException(
status_code=400,
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
)
def _login_rejected_message(label: str) -> str:
return (
f"The {label} replied but did not confirm this login. "
f"Existing access may still allow some {label} operations, but privileged actions may fail."
)
def _login_send_failed_message(label: str) -> str:
return (
f"The login request could not be sent to the {label}. "
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
)
def _login_timeout_message(label: str) -> str:
return (
f"No login confirmation was heard from the {label}. "
"That can mean the password was wrong or the reply was missed in transit. "
"The control panel is still available; try logging in again if authenticated actions fail."
)
def extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload))
if text.startswith("> "):
text = text[2:]
return text
async def fetch_contact_cli_response(
mc,
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
deadline = _monotonic() + timeout
while _monotonic() < deadline:
try:
result = await mc.commands.get_msg(timeout=2.0)
except asyncio.TimeoutError:
continue
except Exception as exc:
logger.debug("get_msg() exception: %s", exc)
await asyncio.sleep(1.0)
continue
if result.type == EventType.NO_MORE_MSGS:
await asyncio.sleep(1.0)
continue
if result.type == EventType.ERROR:
logger.debug("get_msg() error: %s", result.payload)
await asyncio.sleep(1.0)
continue
if result.type == EventType.CONTACT_MSG_RECV:
msg_prefix = result.payload.get("pubkey_prefix", "")
txt_type = result.payload.get("txt_type", 0)
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
msg_prefix,
txt_type,
target_pubkey_prefix,
)
continue
if result.type == EventType.CHANNEL_MSG_RECV:
logger.debug(
"Skipping channel message (channel_idx=%s) during CLI fetch",
result.payload.get("channel_idx"),
)
continue
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
return None
async def prepare_authenticated_contact_connection(
mc,
contact: Contact,
password: str,
*,
label: str | None = None,
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
) -> RepeaterLoginResponse:
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
pubkey_prefix = contact.public_key[:12].lower()
contact_label = label or get_server_contact_label(contact)
loop = asyncio.get_running_loop()
login_future = loop.create_future()
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
if login_future.done():
return
login_future.set_result(
RepeaterLoginResponse(
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
authenticated=event_type == EventType.LOGIN_SUCCESS,
message=message,
)
)
success_subscription = mc.subscribe(
EventType.LOGIN_SUCCESS,
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
failed_subscription = mc.subscribe(
EventType.LOGIN_FAILED,
lambda _event: _resolve_login(
EventType.LOGIN_FAILED,
_login_rejected_message(contact_label),
),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
try:
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
)
try:
return await asyncio.wait_for(
login_future,
timeout=response_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"No login response from %s %s within %.1fs",
contact_label,
contact.public_key[:12],
response_timeout,
)
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=_login_timeout_message(contact_label),
)
except HTTPException as exc:
logger.warning(
"%s login setup failed for %s: %s",
contact_label.capitalize(),
contact.public_key[:12],
exc.detail,
)
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
)
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
async def batch_cli_fetch(
contact: Contact,
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
results: dict[str, str | None] = {field: None for _, field in commands}
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for index, (cmd, field) in enumerate(commands):
if index > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
continue
response_event = await fetch_contact_cli_response(
mc, contact.public_key[:12], timeout=10.0
)
if response_event is not None:
results[field] = extract_response_text(response_event)
else:
logger.warning("No response for command '%s' (%s)", cmd, field)
return results
async def send_contact_cli_command(
contact: Contact,
command: str,
*,
operation_name: str,
) -> CommandResponse:
"""Send a CLI command to a server-capable contact and return the text response."""
label = get_server_contact_label(contact)
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
send_result = await mc.commands.send_cmd(contact.public_key, command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send command: {send_result.payload}"
)
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
if response_event is None:
logger.warning(
"No response from %s %s for command: %s",
label,
contact.public_key[:12],
command,
)
return CommandResponse(
command=command,
response="(no response - command may have been processed)",
)
response_text = extract_response_text(response_event)
sender_timestamp = response_event.payload.get(
"sender_timestamp",
response_event.payload.get("timestamp"),
)
logger.info(
"Received response from %s %s: %s",
label,
contact.public_key[:12],
response_text,
)
return CommandResponse(
command=command,
response=response_text,
sender_timestamp=sender_timestamp,
)

View File

@@ -0,0 +1,26 @@
"""Shared direct-message ACK application logic."""
from collections.abc import Callable
from typing import Any
from app.services import dm_ack_tracker
from app.services.messages import increment_ack_and_broadcast
BroadcastFn = Callable[..., Any]
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
"""Apply a DM ACK code using the shared pending/buffered state machine.
Returns True when the ACK matched a pending message, False when it was buffered.
"""
dm_ack_tracker.cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is None:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
return False
dm_ack_tracker.clear_pending_acks_for_message(message_id)
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
return True

View File

@@ -71,3 +71,15 @@ def pop_pending_ack(ack_code: str) -> int | None:
return None
message_id, _, _ = pending
return message_id
def clear_pending_acks_for_message(message_id: int) -> None:
"""Remove any still-pending ACK codes for a message once one ACK wins."""
sibling_codes = [
code
for code, (pending_message_id, _created_at, _timeout_ms) in _pending_acks.items()
if pending_message_id == message_id
]
for code in sibling_codes:
del _pending_acks[code]
logger.debug("Cleared sibling pending ACK %s for message %d", code, message_id)

371
app/services/dm_ingest.py Normal file
View File

@@ -0,0 +1,371 @@
import asyncio
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
MessageRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
from app.services.messages import (
broadcast_message,
build_message_model,
build_message_paths,
format_contact_log_target,
handle_duplicate_message,
reconcile_duplicate_message,
truncate_for_log,
)
if TYPE_CHECKING:
from app.decoder import DecryptedDirectMessage
logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
_decrypted_dm_store_lock = asyncio.Lock()
@dataclass(frozen=True)
class FallbackDirectMessageContext:
conversation_key: str
contact: Contact | None
sender_name: str | None
sender_key: str | None
skip_storage: bool = False
async def _prepare_resolved_contact(
contact: Contact,
*,
log: logging.Logger | None = None,
) -> tuple[str, bool]:
conversation_key = contact.public_key.lower()
await claim_prefix_messages_for_contact(public_key=conversation_key, log=log or logger)
if contact.type == CONTACT_TYPE_REPEATER:
return conversation_key, True
return conversation_key, False
async def resolve_fallback_direct_message_context(
*,
sender_public_key: str,
received_at: int,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> FallbackDirectMessageContext:
normalized_sender = sender_public_key.lower()
try:
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
except AmbiguousPublicKeyPrefixError:
(log or logger).warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_public_key,
)
contact = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=log)
return FallbackDirectMessageContext(
conversation_key=conversation_key,
contact=contact,
sender_name=contact.name,
sender_key=conversation_key,
skip_storage=skip_storage,
)
if normalized_sender:
placeholder_upsert = ContactUpsert(
public_key=normalized_sender,
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
)
await contact_repository.upsert(placeholder_upsert)
contact = await contact_repository.get_by_key(normalized_sender)
if contact is not None:
broadcast_fn("contact", contact.model_dump())
return FallbackDirectMessageContext(
conversation_key=normalized_sender,
contact=contact,
sender_name=contact.name if contact else None,
sender_key=normalized_sender or None,
)
async def resolve_direct_message_sender_metadata(
*,
sender_public_key: str,
received_at: int,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> tuple[str | None, str | None]:
"""Resolve sender attribution for direct-message variants such as room-server posts."""
normalized_sender = sender_public_key.lower()
try:
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
except AmbiguousPublicKeyPrefixError:
(log or logger).warning(
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
sender_public_key,
)
contact = None
if contact is not None:
await claim_prefix_messages_for_contact(
public_key=contact.public_key.lower(), log=log or logger
)
return contact.name, contact.public_key.lower()
return None, normalized_sender or None
async def _store_direct_message(
*,
packet_id: int | None,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
outgoing: bool,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
realtime: bool,
broadcast_fn: BroadcastFn,
update_last_contacted_key: str | None,
best_effort_content_dedup: bool,
linked_packet_dedup: bool,
message_repository=MessageRepository,
contact_repository=ContactRepository,
raw_packet_repository=RawPacketRepository,
) -> Message | None:
async def store() -> Message | None:
if linked_packet_dedup and packet_id is not None:
linked_message_id = await raw_packet_repository.get_linked_message_id(packet_id)
if linked_message_id is not None:
existing_msg = await message_repository.get_by_id(linked_message_id)
if existing_msg is not None:
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if best_effort_content_dedup:
existing_msg = await message_repository.get_by_content(
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
)
if existing_msg is not None:
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
msg_id = await message_repository.create(
msg_type="PRIV",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=signature,
outgoing=outgoing,
sender_key=sender_key,
sender_name=sender_name,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=packet_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if packet_id is not None:
await raw_packet_repository.mark_decrypted(packet_id, msg_id)
message = build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
outgoing=outgoing,
sender_name=sender_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
if update_last_contacted_key:
await contact_repository.update_last_contacted(update_last_contacted_key, received_at)
return message
if linked_packet_dedup:
async with _decrypted_dm_store_lock:
return await store()
return await store()
async def ingest_decrypted_direct_message(
*,
packet_id: int,
decrypted: "DecryptedDirectMessage",
their_public_key: str,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
realtime: bool = True,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
) -> Message | None:
conversation_key = their_public_key.lower()
if not outgoing and decrypted.txt_type == 1:
logger.debug(
"Skipping CLI response from %s (txt_type=1): %s",
conversation_key[:12],
(decrypted.message or "")[:50],
)
return None
contact = await contact_repository.get_by_key(conversation_key)
sender_name: str | None = None
sender_key: str | None = conversation_key if not outgoing else None
signature: str | None = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
if skip_storage:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
conversation_key[:12],
(decrypted.message or "")[:50],
)
return None
if not outgoing:
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
sender_name, sender_key = await resolve_direct_message_sender_metadata(
sender_public_key=decrypted.signed_sender_prefix,
received_at=received_at or int(time.time()),
broadcast_fn=broadcast_fn,
contact_repository=contact_repository,
log=logger,
)
signature = decrypted.signed_sender_prefix
else:
sender_name = contact.name
received = received_at or int(time.time())
message = await _store_direct_message(
packet_id=packet_id,
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
path=path,
path_len=path_len,
outgoing=outgoing,
txt_type=decrypted.txt_type,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
realtime=realtime,
broadcast_fn=broadcast_fn,
update_last_contacted_key=conversation_key,
best_effort_content_dedup=outgoing,
linked_packet_dedup=True,
)
if message is None:
return None
logger.info(
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
truncate_for_log(decrypted.message),
format_contact_log_target(contact.name if contact else None, conversation_key),
message.id,
conversation_key,
outgoing,
)
return message
async def ingest_fallback_direct_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
broadcast_fn: BroadcastFn,
update_last_contacted_key: str | None = None,
) -> Message | None:
return await _store_direct_message(
packet_id=None,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=False,
txt_type=txt_type,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
realtime=True,
broadcast_fn=broadcast_fn,
update_last_contacted_key=update_last_contacted_key,
best_effort_content_dedup=True,
linked_packet_dedup=False,
)

View File

@@ -11,8 +11,10 @@ from meshcore import EventType
from app.models import ResendChannelMessageResponse
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
from app.services import dm_ack_tracker
from app.services.messages import (
build_message_model,
broadcast_message,
build_stored_outgoing_channel_message,
create_outgoing_channel_message,
create_outgoing_direct_message,
increment_ack_and_broadcast,
@@ -29,10 +31,15 @@ BroadcastFn = Callable[..., Any]
TrackAckFn = Callable[[str, int, int], bool]
NowFn = Callable[[], float]
OutgoingReservationKey = tuple[str, str, str]
RetryTaskScheduler = Callable[[Any], Any]
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
_outgoing_timestamp_reservations_lock = asyncio.Lock()
DM_SEND_MAX_ATTEMPTS = 3
DEFAULT_DM_ACK_TIMEOUT_MS = 10000
DM_RETRY_WAIT_MARGIN = 1.2
async def allocate_outgoing_sender_timestamp(
*,
@@ -248,6 +255,183 @@ async def send_channel_message_with_effective_scope(
)
def _extract_expected_ack_code(result: Any) -> str | None:
if result is None or result.type == EventType.ERROR:
return None
payload = result.payload or {}
expected_ack = payload.get("expected_ack")
if not expected_ack:
return None
return expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
def _get_ack_tracking_timeout_ms(result: Any) -> int:
if result is None or result.type == EventType.ERROR:
return DEFAULT_DM_ACK_TIMEOUT_MS
payload = result.payload or {}
suggested_timeout = payload.get("suggested_timeout")
if suggested_timeout is None:
return DEFAULT_DM_ACK_TIMEOUT_MS
try:
return max(1, int(suggested_timeout))
except (TypeError, ValueError):
return DEFAULT_DM_ACK_TIMEOUT_MS
def _get_direct_message_retry_timeout_ms(result: Any) -> int:
"""Return the ACK window to wait before retrying a DM.
The MeshCore firmware already computes and returns `suggested_timeout` in
`PACKET_MSG_SENT`, derived from estimated packet airtime and route mode.
We use that firmware-supplied window directly so retries do not fire before
the radio's own ACK timeout expires.
Sources:
- https://github.com/meshcore-dev/MeshCore/blob/main/src/helpers/BaseChatMesh.cpp
- https://github.com/meshcore-dev/MeshCore/blob/main/examples/companion_radio/MyMesh.cpp
- https://github.com/meshcore-dev/MeshCore/blob/main/docs/companion_protocol.md
"""
return _get_ack_tracking_timeout_ms(result)
async def _apply_direct_message_ack_tracking(
*,
result: Any,
message_id: int,
track_pending_ack_fn: TrackAckFn,
broadcast_fn: BroadcastFn,
) -> int:
ack_code = _extract_expected_ack_code(result)
if not ack_code:
return 0
timeout_ms = _get_ack_tracking_timeout_ms(result)
matched_immediately = track_pending_ack_fn(ack_code, message_id, timeout_ms) is True
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
if matched_immediately:
dm_ack_tracker.clear_pending_acks_for_message(message_id)
return await increment_ack_and_broadcast(
message_id=message_id,
broadcast_fn=broadcast_fn,
)
return 0
async def _is_message_acked(*, message_id: int, message_repository) -> bool:
acked_count, _paths = await message_repository.get_ack_and_paths(message_id)
return acked_count > 0
async def _retry_direct_message_until_acked(
*,
contact,
text: str,
message_id: int,
sender_timestamp: int,
radio_manager,
track_pending_ack_fn: TrackAckFn,
broadcast_fn: BroadcastFn,
wait_timeout_ms: int,
sleep_fn,
message_repository,
) -> None:
next_wait_timeout_ms = wait_timeout_ms
for attempt in range(1, 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
try:
async with radio_manager.radio_operation("retry_direct_message") as mc:
contact_data = contact.to_radio_dict()
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning(
"Failed to reload contact %s on radio before DM retry: %s",
contact.public_key[:12],
add_result.payload,
)
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
if attempt == DM_SEND_MAX_ATTEMPTS - 1:
reset_result = await mc.commands.reset_path(contact.public_key)
if reset_result is None:
logger.warning(
"No response from radio for reset_path to %s before final DM retry",
contact.public_key[:12],
)
elif reset_result.type == EventType.ERROR:
logger.warning(
"Failed to reset path before final DM retry to %s: %s",
contact.public_key[:12],
reset_result.payload,
)
refreshed_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if refreshed_contact:
cached_contact = refreshed_contact
result = await mc.commands.send_msg(
dst=cached_contact,
msg=text,
timestamp=sender_timestamp,
attempt=attempt,
)
except Exception:
logger.exception(
"Background DM retry attempt %d/%d failed for %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
if result is None:
logger.warning(
"No response from radio after background DM retry attempt %d/%d to %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
if result.type == EventType.ERROR:
logger.warning(
"Background DM retry attempt %d/%d failed for %s: %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
result.payload,
)
continue
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
return
ack_code = _extract_expected_ack_code(result)
if not ack_code:
logger.warning(
"Background DM retry attempt %d/%d for %s returned no expected_ack; "
"stopping retries to avoid duplicate sends",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
return
next_wait_timeout_ms = _get_direct_message_retry_timeout_ms(result)
ack_count = await _apply_direct_message_ack_tracking(
result=result,
message_id=message_id,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
)
if ack_count > 0:
return
async def send_direct_message_to_contact(
*,
contact,
@@ -256,10 +440,17 @@ async def send_direct_message_to_contact(
broadcast_fn: BroadcastFn,
track_pending_ack_fn: TrackAckFn,
now_fn: NowFn,
retry_task_scheduler: RetryTaskScheduler | None = None,
retry_sleep_fn=None,
message_repository=MessageRepository,
contact_repository=ContactRepository,
) -> Any:
"""Send a direct message and persist/broadcast the outgoing row."""
if retry_task_scheduler is None:
retry_task_scheduler = asyncio.create_task
if retry_sleep_fn is None:
retry_sleep_fn = asyncio.sleep
contact_data = contact.to_radio_dict()
sent_at: int | None = None
sender_timestamp: int | None = None
@@ -328,18 +519,33 @@ async def send_direct_message_to_contact(
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
expected_ack = result.payload.get("expected_ack")
suggested_timeout: int = result.payload.get("suggested_timeout", 10000)
if expected_ack:
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
matched_immediately = track_pending_ack_fn(ack_code, message.id, suggested_timeout) is True
logger.debug("Tracking ACK %s for message %d", ack_code, message.id)
if matched_immediately:
ack_count = await increment_ack_and_broadcast(
ack_code = _extract_expected_ack_code(result)
retry_timeout_ms = _get_direct_message_retry_timeout_ms(result)
ack_count = await _apply_direct_message_ack_tracking(
result=result,
message_id=message.id,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
)
if ack_count > 0:
message.acked = ack_count
return message
if DM_SEND_MAX_ATTEMPTS > 1 and ack_code:
retry_task_scheduler(
_retry_direct_message_until_acked(
contact=contact,
text=text,
message_id=message.id,
sender_timestamp=sender_timestamp,
radio_manager=radio_manager,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
wait_timeout_ms=retry_timeout_ms,
sleep_fn=retry_sleep_fn,
message_repository=message_repository,
)
message.acked = ack_count
)
return message
@@ -381,6 +587,23 @@ async def send_channel_message_to_channel(
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
broadcast=False,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
result = await send_channel_message_with_effective_scope(
mc=mc,
@@ -406,23 +629,11 @@ async def send_channel_message_to_channel(
raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}"
)
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
except Exception:
if outgoing_message is not None:
await message_repository.delete_by_id(outgoing_message.id)
outgoing_message = None
raise
finally:
if sender_timestamp is not None:
await release_outgoing_sender_timestamp(
@@ -435,22 +646,19 @@ async def send_channel_message_to_channel(
if sent_at is None or sender_timestamp is None or outgoing_message is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
message_id = outgoing_message.id
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
outgoing_message = await build_stored_outgoing_channel_message(
message_id=outgoing_message.id,
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=sender_timestamp,
received_at=sent_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
message_repository=message_repository,
)
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
return outgoing_message
async def resend_channel_message_record(
@@ -500,6 +708,23 @@ async def resend_channel_message_record(
requested_timestamp=sent_at,
)
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
broadcast=False,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
result = await send_channel_message_with_effective_scope(
mc=mc,
@@ -524,26 +749,11 @@ async def resend_channel_message_record(
status_code=500,
detail=f"Failed to resend message: {result.payload}",
)
if new_timestamp:
if sent_at is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await create_outgoing_channel_message(
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if new_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store resent message - unexpected duplicate",
)
except Exception:
if new_message is not None:
await message_repository.delete_by_id(new_message.id)
new_message = None
raise
finally:
if new_timestamp and sent_at is not None:
await release_outgoing_sender_timestamp(
@@ -557,6 +767,19 @@ async def resend_channel_message_record(
if sent_at is None or new_message is None:
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
new_message = await build_stored_outgoing_channel_message(
message_id=new_message.id,
conversation_key=message.conversation_key,
text=message.text,
sender_timestamp=sender_timestamp,
received_at=sent_at,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=channel.name,
message_repository=message_repository,
)
broadcast_message(message=new_message, broadcast_fn=broadcast_fn)
logger.info(
"Resent channel message %d as new message %d to %s",
message.id,

View File

@@ -1,10 +1,9 @@
import asyncio
import logging
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, Message, MessagePath
from app.models import Message, MessagePath
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
if TYPE_CHECKING:
@@ -14,10 +13,9 @@ logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
LOG_MESSAGE_PREVIEW_LEN = 32
_decrypted_dm_store_lock = asyncio.Lock()
def _truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
def truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
"""Return a compact single-line message preview for log output."""
normalized = " ".join(text.split())
if len(normalized) <= max_chars:
@@ -30,7 +28,7 @@ def _format_channel_log_target(channel_name: str | None, channel_key: str) -> st
return channel_name or channel_key
def _format_contact_log_target(contact_name: str | None, public_key: str) -> str:
def format_contact_log_target(contact_name: str | None, public_key: str) -> str:
"""Return a human-friendly DM target label for logs."""
return contact_name or public_key[:12]
@@ -98,6 +96,36 @@ def broadcast_message(
broadcast_fn("message", payload, realtime=realtime)
async def build_stored_outgoing_channel_message(
*,
message_id: int,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
sender_name: str | None,
sender_key: str | None,
channel_name: str | None,
message_repository=MessageRepository,
) -> Message:
"""Build the current payload for a stored outgoing channel message."""
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=paths,
outgoing=True,
acked=acked_count,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
)
def broadcast_message_acked(
*,
message_id: int,
@@ -127,7 +155,7 @@ async def increment_ack_and_broadcast(
return ack_count
async def _reconcile_duplicate_message(
async def reconcile_duplicate_message(
*,
existing_msg: Message,
packet_id: int | None,
@@ -173,6 +201,7 @@ async def handle_duplicate_message(
conversation_key: str,
text: str,
sender_timestamp: int,
outgoing: bool | None = None,
path: str | None,
received_at: int,
path_len: int | None = None,
@@ -184,6 +213,7 @@ async def handle_duplicate_message(
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
)
if not existing_msg:
label = "message" if msg_type == "CHAN" else "DM"
@@ -194,7 +224,7 @@ async def handle_duplicate_message(
)
return
await _reconcile_duplicate_message(
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
@@ -248,6 +278,7 @@ async def create_message_from_decrypted(
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
outgoing=None,
path=path,
received_at=received,
path_len=path_len,
@@ -257,7 +288,7 @@ async def create_message_from_decrypted(
logger.info(
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
_truncate_for_log(text),
truncate_for_log(text),
_format_channel_log_target(channel_name, channel_key_normalized),
msg_id,
channel_key_normalized,
@@ -298,165 +329,20 @@ async def create_dm_message_from_decrypted(
broadcast_fn: BroadcastFn,
) -> int | None:
"""Store and broadcast a decrypted direct message."""
contact = await ContactRepository.get_by_key(their_public_key)
if contact and contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
their_public_key[:12],
(decrypted.message or "")[:50],
)
return None
from app.services.dm_ingest import ingest_decrypted_direct_message
received = received_at or int(time.time())
conversation_key = their_public_key.lower()
sender_name = contact.name if contact and not outgoing else None
async with _decrypted_dm_store_lock:
linked_message_id = await RawPacketRepository.get_linked_message_id(packet_id)
if linked_message_id is not None:
existing_msg = await MessageRepository.get_by_id(linked_message_id)
if existing_msg is not None:
await _reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if outgoing:
existing_msg = await MessageRepository.get_by_content(
msg_type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
)
if existing_msg is not None:
await _reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=decrypted.message,
conversation_key=conversation_key,
sender_timestamp=decrypted.timestamp,
received_at=received,
path=path,
path_len=path_len,
outgoing=outgoing,
sender_key=conversation_key if not outgoing else None,
sender_name=sender_name,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=packet_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
path=path,
received_at=received,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
logger.info(
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
_truncate_for_log(decrypted.message),
_format_contact_log_target(contact.name if contact else None, conversation_key),
msg_id,
conversation_key,
outgoing,
)
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
broadcast_message(
message=build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
paths=build_message_paths(path, received, path_len),
outgoing=outgoing,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
),
broadcast_fn=broadcast_fn,
realtime=realtime,
)
await ContactRepository.update_last_contacted(conversation_key, received)
return msg_id
async def create_fallback_direct_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
broadcast_fn: BroadcastFn,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast a CONTACT_MSG_RECV fallback direct message."""
existing = await message_repository.get_by_content(
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
)
if existing is not None:
return None
msg_id = await message_repository.create(
msg_type="PRIV",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
message = await ingest_decrypted_direct_message(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=their_public_key,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
sender_name=sender_name,
outgoing=outgoing,
realtime=realtime,
broadcast_fn=broadcast_fn,
)
if msg_id is None:
return None
message = build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
sender_name=sender_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message
return message.id if message is not None else None
async def create_fallback_channel_message(
@@ -502,6 +388,7 @@ async def create_fallback_channel_message(
conversation_key=conversation_key_normalized,
text=text,
sender_timestamp=sender_timestamp,
outgoing=None,
path=path,
received_at=received_at,
path_len=path_len,
@@ -571,6 +458,7 @@ async def create_outgoing_channel_message(
sender_key: str | None,
channel_name: str | None,
broadcast_fn: BroadcastFn,
broadcast: bool = True,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast an outgoing channel message."""
@@ -587,18 +475,17 @@ async def create_outgoing_channel_message(
if msg_id is None:
return None
message = build_message_model(
message = await build_stored_outgoing_channel_message(
message_id=msg_id,
msg_type="CHAN",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
outgoing=True,
acked=0,
sender_name=sender_name,
sender_key=sender_key,
channel_name=channel_name,
message_repository=message_repository,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
if broadcast:
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message

View File

@@ -44,6 +44,13 @@ async def apply_radio_config_update(
f"Failed to set advert location policy: {result.payload}"
)
if update.multi_acks_enabled is not None:
multi_acks = 1 if update.multi_acks_enabled else 0
logger.info("Setting multi ACKs to %d", multi_acks)
result = await mc.commands.set_multi_acks(multi_acks)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -180,6 +181,23 @@ async def run_post_connect_setup(radio_manager) -> None:
else "unknown",
radio_manager.max_channels,
)
try:
time_result = await mc.commands.get_time()
radio_time = (
time_result.payload.get("time")
if time_result is not None and time_result.payload
else None
)
if isinstance(radio_time, int):
logger.info(
"Radio clock at connect: epoch=%d utc=%s",
radio_time,
datetime.fromtimestamp(radio_time, timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S UTC"
),
)
except Exception as exc:
logger.debug("Failed to query radio clock after device info: %s", exc)
logger.info("Max channel slots: %d", radio_manager.max_channels)
except Exception as exc:
logger.debug("Failed to query device info capabilities: %s", exc)

149
app/version_info.py Normal file
View File

@@ -0,0 +1,149 @@
"""Unified application version/build metadata resolution.
Resolution order:
- version: installed package metadata, ``APP_VERSION`` env, ``build_info.json``, ``pyproject.toml``
- commit: local git, ``COMMIT_HASH``/``VITE_COMMIT_HASH`` env, ``build_info.json``
This keeps backend surfaces, release bundles, and Docker builds aligned.
"""
from __future__ import annotations
import importlib.metadata
import json
import os
import subprocess
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"
@dataclass(frozen=True)
class AppBuildInfo:
version: str
version_source: str
commit_hash: str | None
commit_source: str | None
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_build_info(root: Path) -> dict[str, Any] | None:
build_info_path = root / RELEASE_BUILD_INFO_FILENAME
try:
data = json.loads(build_info_path.read_text())
except Exception:
return None
return data if isinstance(data, dict) else None
def _package_metadata_version() -> str | None:
try:
return importlib.metadata.version(PROJECT_NAME)
except Exception:
return None
def _env_version() -> str | None:
value = os.getenv("APP_VERSION")
return value.strip() if value and value.strip() else None
def _build_info_version(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("version")
return value.strip() if isinstance(value, str) and value.strip() else None
def _pyproject_version(root: Path) -> str | None:
try:
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
project = pyproject.get("project")
if isinstance(project, dict):
version = project.get("version")
if isinstance(version, str) and version.strip():
return version.strip()
except Exception:
return None
return None
def _git_output(root: Path, *args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=root,
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _env_commit_hash() -> str | None:
for name in ("COMMIT_HASH", "VITE_COMMIT_HASH"):
value = os.getenv(name)
if value and value.strip():
return value.strip()[:8]
return None
def _build_info_commit_hash(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("commit_hash")
return value.strip()[:8] if isinstance(value, str) and value.strip() else None
@lru_cache(maxsize=1)
def get_app_build_info() -> AppBuildInfo:
root = repo_root()
build_info = _read_build_info(root)
version = _package_metadata_version()
version_source = "package_metadata"
if version is None:
version = _env_version()
version_source = "env"
if version is None:
version = _build_info_version(build_info)
version_source = "build_info"
if version is None:
version = _pyproject_version(root)
version_source = "pyproject"
if version is None:
version = "0.0.0"
version_source = "fallback"
commit_hash = _git_output(root, "rev-parse", "--short", "HEAD")
commit_source: str | None = "git" if commit_hash else None
if commit_hash is None:
commit_hash = _env_commit_hash()
commit_source = "env" if commit_hash else None
if commit_hash is None:
commit_hash = _build_info_commit_hash(build_info)
commit_source = "build_info" if commit_hash else None
return AppBuildInfo(
version=version,
version_source=version_source,
commit_hash=commit_hash,
commit_source=commit_source,
)
def git_output(*args: str) -> str | None:
"""Shared git helper for debug surfaces that still need live repo state."""
return _git_output(repo_root(), *args)

View File

@@ -35,7 +35,6 @@ frontend/src/
├── types.ts # Shared TS contracts
├── useWebSocket.ts # WS lifecycle + event dispatch
├── wsEvents.ts # Typed WS event parsing / discriminated union
├── messageCache.ts # Conversation-scoped cache
├── prefetch.ts # Consumes prefetched API promises started in index.html
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
@@ -130,8 +129,7 @@ frontend/src/
│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
├── types/
── d3-force-3d.d.ts # Type declarations for d3-force-3d
│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__)
── d3-force-3d.d.ts # Type declarations for d3-force-3d
└── test/
├── setup.ts
├── fixtures/websocket_events.json
@@ -200,9 +198,9 @@ High-level state is delegated to hooks:
- `useContactsAndChannels`: contact/channel lists, creation, deletion
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, cache restore, jump-target loading, pagination, dedup/update helpers, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
@@ -233,6 +231,8 @@ High-level state is delegated to hooks:
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
- ACK/repeat updates arrive as `message_acked` events.
- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
- Conversation-scoped message caching now lives inside `useConversationMessages.ts` rather than a standalone `messageCache.ts` module. If you touch message timeline restore/dedup/reconnect behavior, start there.
- `contact_resolved` is a real-time identity migration event, not just a contact-list update. Changes in that area need to consider active conversation state, cached messages, unread state keys, and reconnect reconciliation together.
### Visualizer behavior
@@ -245,11 +245,14 @@ High-level state is delegated to hooks:
- `id`: backend storage row identity (payload-level dedup)
- `observation_id`: realtime per-arrival identity (session fidelity)
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
@@ -328,6 +331,8 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
## Contact Info Pane
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
@@ -339,9 +344,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
- Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths
- Recent advert paths (informational only; not part of DM route selection)
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
@@ -421,9 +427,9 @@ PYTHONPATH=. uv run pytest tests/ -v
## Errata & Known Non-Issues
### Contacts rollup uses mention styling for unread DMs
### Contacts use mention styling for unread DMs
This is intentional. In the sidebar section headers, unread direct messages are treated as mention-equivalent, so the Contacts rollup uses the highlighted mention-style badge for any unread DM. Row-level mention detection remains separate; this note is only about the section summary styling.
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
### RawPacketList always scrolls to bottom

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.4.0",
"version": "3.5.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,6 +1,5 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { api } from './api';
import * as messageCache from './messageCache';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
@@ -15,9 +14,11 @@ import {
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
useRawPacketStatsSession,
} from './hooks';
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 { Conversation, Message, RawPacket } from './types';
@@ -82,6 +83,7 @@ export function App() {
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
const {
showNewMessage,
showSettings,
@@ -90,10 +92,12 @@ export function App() {
showCracker,
crackerRunning,
localLabel,
distanceUnit,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
@@ -109,6 +113,7 @@ export function App() {
// useConversationRouter, but useConversationRouter needs channels/contacts from
// useContactsAndChannels. We break the cycle with a ref-based indirection.
const setActiveConversationRef = useRef<(conv: Conversation | null) => void>(() => {});
const removeConversationMessagesRef = useRef<(conversationId: string) => void>(() => {});
// --- Extracted hooks ---
@@ -180,6 +185,8 @@ export function App() {
setActiveConversation: (conv) => setActiveConversationRef.current(conv),
pendingDeleteFallbackRef,
hasSetDefaultConversation,
removeConversationMessages: (conversationId) =>
removeConversationMessagesRef.current(conversationId),
});
// useConversationRouter is called second — it receives channels/contacts as inputs
@@ -192,6 +199,7 @@ export function App() {
channels,
contacts,
contactsLoaded,
suspendHashSync: showSettings,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -228,25 +236,27 @@ export function App() {
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
reloadCurrentConversation,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
observeMessage,
receiveMessageAck,
reconcileOnReconnect,
renameConversationMessages,
removeConversationMessages,
clearConversationMessages,
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
const {
unreadCounts,
mentions,
lastMessageTimes,
unreadLastReadAts,
incrementUnread,
recordMessageEvent,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
@@ -308,7 +318,7 @@ export function App() {
setHealth,
fetchConfig,
setRawPackets,
triggerReconcile,
reconcileOnReconnect,
refreshUnreads,
setChannels,
fetchAllContacts,
@@ -316,23 +326,24 @@ export function App() {
blockedKeysRef,
blockedNamesRef,
activeConversationRef,
hasNewerMessagesRef,
addMessageIfNew,
trackNewMessage,
incrementUnread,
observeMessage,
recordMessageEvent,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
renameConversationMessages,
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
recordRawPacketObservation,
});
const handleVisibilityPolicyChanged = useCallback(() => {
messageCache.clear();
clearConversationMessages();
reloadCurrentConversation();
void refreshUnreads();
setVisibilityVersion((current) => current + 1);
}, [refreshUnreads, reloadCurrentConversation]);
}, [clearConversationMessages, refreshUnreads, reloadCurrentConversation]);
const handleBlockKey = useCallback(
async (key: string) => {
@@ -361,7 +372,7 @@ export function App() {
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
observeMessage,
messageInputRef,
});
const handleCreateCrackedChannel = useCallback(
@@ -408,6 +419,7 @@ export function App() {
contacts,
channels,
rawPackets,
rawPacketStatsSession,
config,
health,
favorites,
@@ -555,29 +567,31 @@ export function App() {
setContactsLoaded,
]);
return (
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
</DistanceUnitProvider>
);
}

View File

@@ -15,6 +15,7 @@ import type {
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -95,9 +96,10 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: () =>
sendAdvertisement: (mode: RadioAdvertMode = 'flood') =>
fetchJson<{ status: string }>('/radio/advertise', {
method: 'POST',
body: JSON.stringify({ mode }),
}),
discoverMesh: (target: RadioDiscoveryTarget) =>
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
@@ -381,4 +383,21 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
roomStatus: (publicKey: string) =>
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
method: 'POST',
}),
roomAcl: (publicKey: string) =>
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
method: 'POST',
}),
roomLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
method: 'POST',
}),
};

View File

@@ -41,6 +41,7 @@ interface AppShellProps {
settingsSection: SettingsSection;
sidebarOpen: boolean;
showCracker: boolean;
disabledSettingsSections?: SettingsSection[];
onSettingsSectionChange: (section: SettingsSection) => void;
onSidebarOpenChange: (open: boolean) => void;
onCrackerRunningChange: (running: boolean) => void;
@@ -69,6 +70,7 @@ export function AppShell({
settingsSection,
sidebarOpen,
showCracker,
disabledSettingsSections = [],
onSettingsSectionChange,
onSidebarOpenChange,
onCrackerRunningChange,
@@ -118,13 +120,16 @@ export function AppShell({
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
const disabled = disabledSettingsSections.includes(section);
return (
<button
key={section}
type="button"
disabled={disabled}
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
!disabled && 'hover:bg-accent',
settingsSection === section && !disabled && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
@@ -252,14 +257,6 @@ export function AppShell({
</div>
<div
ref={(el) => {
if (showCracker && el) {
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
if (focusable) {
setTimeout(() => focusable.focus(), 210);
}
}
}}
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
@@ -9,6 +9,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
@@ -19,6 +20,7 @@ import type {
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps {
conversation: Conversation;
@@ -60,10 +62,8 @@ export function ChatHeader({
onOpenChannelInfo,
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
const [contactStatusInline, setContactStatusInline] = useState(true);
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
const keyTextRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
@@ -86,6 +86,7 @@ export function ChatHeader({
conversation.type === 'contact'
? contacts.find((contact) => contact.public_key === conversation.id)
: null;
const activeContactIsRoomServer = activeContact?.type === CONTACT_TYPE_ROOM;
const activeContactIsPrefixOnly = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
@@ -117,46 +118,16 @@ export function ChatHeader({
}
};
useEffect(() => {
if (conversation.type !== 'contact') {
setContactStatusInline(true);
return;
}
const measure = () => {
const keyElement = keyTextRef.current;
if (!keyElement) return;
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
setContactStatusInline(!isTruncated);
};
measure();
const onResize = () => {
window.requestAnimationFrame(measure);
};
window.addEventListener('resize', onResize);
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
window.requestAnimationFrame(measure);
});
if (keyTextRef.current?.parentElement) {
observer.observe(keyTextRef.current.parentElement);
}
}
return () => {
window.removeEventListener('resize', onResize);
observer?.disconnect();
};
}, [conversation.id, conversation.type, showKey]);
return (
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
<header
className={cn(
'conversation-header grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
conversation.type === 'contact' && activeContact
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]'
)}
>
<span className="flex min-w-0 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<button
type="button"
@@ -177,11 +148,11 @@ export function ChatHeader({
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
@@ -222,7 +193,6 @@ export function ChatHeader({
</button>
) : (
<span
ref={keyTextRef}
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
@@ -245,45 +215,36 @@ export function ChatHeader({
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && contactStatusInline && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex basis-full items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
</span>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
{conversation.type === 'contact' && activeContact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</div>
)}
<div className="flex items-center justify-end gap-0.5">
{conversation.type === 'contact' && !activeContactIsRoomServer && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
@@ -298,7 +259,7 @@ export function ChatHeader({
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
{conversation.type === 'contact' && (
{conversation.type === 'contact' && !activeContactIsRoomServer && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
@@ -313,7 +274,7 @@ export function ChatHeader({
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && (
{notificationsSupported && !activeContactIsRoomServer && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}

View File

@@ -12,6 +12,7 @@ import {
calculateDistance,
formatDistance,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
@@ -23,6 +24,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import type {
Contact,
ContactActiveRoom,
@@ -81,6 +83,7 @@ export function ContactInfoPane({
onToggleBlockedKey,
onToggleBlockedName,
}: ContactInfoPaneProps) {
const { distanceUnit } = useDistanceUnit();
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
@@ -134,11 +137,12 @@ export function ContactInfoPane({
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
const directRoute = contact ? getDirectContactRoute(contact) : null;
const pathHashModeLabel =
effectiveRoute && effectiveRoute.pathLen >= 0
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null;
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
const isUnknownFullKeyResolvedContact =
contact !== null &&
@@ -313,7 +317,7 @@ export function ContactInfoPane({
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
)}
{distFromUs !== null && (
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
)}
{effectiveRoute && (
<InfoItem
@@ -330,7 +334,7 @@ export function ContactInfoPane({
}
/>
)}
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
{hasRoutingOverride(contact) && learnedRouteLabel && (
<InfoItem label="Learned Route" value={learnedRouteLabel} />
)}
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}

View File

@@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type
import {
findContactsByPrefix,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
@@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]);
const hasForcedRoute = hasRoutingOverride(contact);
const learnedRouteSummary = useMemo(() => {
if (contact.last_path_len === -1) {
if (!directRoute) {
return 'Flood';
}
const hops = parsePathHops(contact.last_path, contact.last_path_len);
const hops = parsePathHops(directRoute.path, directRoute.path_len);
return hops.length > 0
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(contact.last_path_len, true);
}, [contact.last_path, contact.last_path_len]);
? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(directRoute.path_len, true);
}, [directRoute]);
const forcedRouteSummary = useMemo(() => {
if (!hasForcedRoute) {
return null;

View File

@@ -5,6 +5,7 @@ import type { Contact } from '../types';
import {
formatRouteLabel,
formatRoutingOverrideInput,
getDirectContactRoute,
hasRoutingOverride,
} from '../utils/pathUtils';
import { Button } from './ui/button';
@@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps {
}
function summarizeLearnedRoute(contact: Contact): string {
return formatRouteLabel(contact.last_path_len, true);
return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true);
}
function summarizeForcedRoute(contact: Contact): string | null {
@@ -118,6 +119,10 @@ export function ContactRoutingOverrideModal({
/>
<div className="space-y-1 text-xs text-muted-foreground">
<p>Use comma-separated 1, 2, or 3 byte hop IDs for an explicit path.</p>
<p>
Note: direct messages that do not see an ACK retry up to 3 times. The final retry is
sent as flood, even when forced routing is configured.
</p>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import {
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import type { Contact } from '../types';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
interface ContactStatusInfoProps {
@@ -24,6 +25,7 @@ interface ContactStatusInfoProps {
* shared between ChatHeader and RepeaterDashboard.
*/
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
const { distanceUnit } = useDistanceUnit();
const [routingModalOpen, setRoutingModalOpen] = useState(false);
const parts: ReactNode[] = [];
const effectiveRoute = getEffectiveContactRoute(contact);
@@ -74,7 +76,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
</span>
);
}

View File

@@ -1,9 +1,10 @@
import { lazy, Suspense, useMemo, type Ref } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
import { ChatHeader } from './ChatHeader';
import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketList } from './RawPacketList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import type {
Channel,
Contact,
@@ -15,7 +16,8 @@ import type {
RawPacket,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
const RepeaterDashboard = lazy(() =>
@@ -31,6 +33,7 @@ interface ConversationPaneProps {
contacts: Contact[];
channels: Channel[];
rawPackets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
@@ -95,6 +98,7 @@ export function ConversationPane({
contacts,
channels,
rawPackets,
rawPacketStatsSession,
config,
health,
notificationsSupported,
@@ -128,6 +132,7 @@ export function ConversationPane({
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
@@ -137,6 +142,10 @@ export function ConversationPane({
if (!activeConversation || activeConversation.type !== 'contact') return null;
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
}, [activeConversation, contacts]);
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
useEffect(() => {
setRoomAuthenticated(false);
}, [activeConversation?.id]);
const isPrefixOnlyActiveContact = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
@@ -178,14 +187,12 @@ export function ConversationPane({
if (activeConversation.type === 'raw') {
return (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
</>
<RawPacketFeedView
packets={rawPackets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
}
@@ -217,6 +224,8 @@ export function ConversationPane({
);
}
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
return (
<>
<ChatHeader
@@ -244,35 +253,40 @@ export function ConversationPane({
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
<ContactResolutionBanner variant="unknown-full-key" />
)}
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
onOpenContactInfo={onOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={onTargetReached}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
{activeContactIsRoom && activeContact && (
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
)}
{showRoomChat && (
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
onOpenContactInfo={onOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={onTargetReached}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
)}
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
@@ -285,7 +299,7 @@ export function ConversationPane({
: `Message ${activeConversation.name}...`
}
/>
)}
) : null}
</>
);
}

View File

@@ -9,9 +9,10 @@ import {
type ReactNode,
} from 'react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -329,7 +330,7 @@ export function MessageList({
}, [messages, onResendChannelMessage]);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// Note: Deduplication is handled by useConversationMessages.observeMessage()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
@@ -499,13 +500,41 @@ export function MessageList({
contact: Contact | null,
parsedSender: string | null
): SenderInfo => {
if (
msg.type === 'PRIV' &&
contact?.type === CONTACT_TYPE_ROOM &&
(msg.sender_key || msg.sender_name)
) {
const authorContact =
(msg.sender_key
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
: null) || (msg.sender_name ? getContactByName(msg.sender_name) : null);
if (authorContact) {
const directRoute = getDirectContactRoute(authorContact);
return {
name: authorContact.name || msg.sender_name || authorContact.public_key.slice(0, 12),
publicKeyOrPrefix: authorContact.public_key,
lat: authorContact.lat,
lon: authorContact.lon,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
return {
name: msg.sender_name || msg.sender_key || 'Unknown',
publicKeyOrPrefix: msg.sender_key || '',
lat: null,
lon: null,
pathHashMode: null,
};
}
if (msg.type === 'PRIV' && contact) {
const directRoute = getDirectContactRoute(contact);
return {
name: contact.name || contact.public_key.slice(0, 12),
publicKeyOrPrefix: contact.public_key,
lat: contact.lat,
lon: contact.lon,
pathHashMode: contact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
if (msg.type === 'CHAN') {
@@ -515,12 +544,13 @@ export function MessageList({
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
: null) || (senderName ? getContactByName(senderName) : null);
if (senderContact) {
const directRoute = getDirectContactRoute(senderContact);
return {
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
pathHashMode: senderContact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
if (senderName || msg.sender_key) {
@@ -538,12 +568,13 @@ export function MessageList({
if (parsedSender) {
const senderContact = getContactByName(parsedSender);
if (senderContact) {
const directRoute = getDirectContactRoute(senderContact);
return {
name: parsedSender,
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
pathHashMode: senderContact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
}
@@ -580,6 +611,8 @@ export function MessageList({
isCorruptChannelMessage: boolean
): string => {
if (msg.outgoing) return '__outgoing__';
if (msg.type === 'PRIV' && msg.sender_key) return `key:${msg.sender_key}`;
if (msg.type === 'PRIV' && senderName) return `name:${senderName}`;
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
if (msg.sender_key) return `key:${msg.sender_key}`;
if (senderName) return `name:${senderName}`;
@@ -608,18 +641,24 @@ export function MessageList({
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
// Skip sender parsing for repeater messages (CLI responses often have colons)
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const { sender, content } =
isRepeater || (isRoomServer && msg.type === 'PRIV')
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const directSenderName =
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
const channelSenderContact =
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
const displaySender = msg.outgoing
? 'You'
: contact?.name ||
: directSenderName ||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
contact?.name ||
channelSenderName ||
(isCorruptChannelMessage
? CORRUPT_SENDER_LABEL
@@ -632,15 +671,22 @@ export function MessageList({
displaySender !== CORRUPT_SENDER_LABEL;
// Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
const currentSenderKey = getSenderKey(
msg,
directSenderName || channelSenderName,
isCorruptChannelMessage
);
const prevMsg = sortedMessages[index - 1];
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
const prevSenderKey = prevMsg
? getSenderKey(
prevMsg,
prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender
: prevParsedSender,
prevMsg.type === 'PRIV' &&
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
? prevMsg.sender_name
: prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender
: prevParsedSender,
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
)
: null;
@@ -654,9 +700,14 @@ export function MessageList({
let avatarVariant: 'default' | 'corrupt' = 'default';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) {
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
if (isRoomServer) {
avatarName = directSenderName;
avatarKey =
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
} else {
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
}
} else if (isCorruptChannelMessage) {
avatarName = CORRUPT_SENDER_LABEL;
avatarKey = `corrupt:${msg.id}`;
@@ -721,7 +772,12 @@ export function MessageList({
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
onClick={() =>
onOpenContactInfo(
avatarKey,
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
)
}
>
<ContactAvatar
name={avatarName}
@@ -776,7 +832,7 @@ export function MessageList({
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
})
}
/>
@@ -802,7 +858,7 @@ export function MessageList({
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
})
}
/>

View File

@@ -14,6 +14,8 @@ import {
} from '../utils/pathUtils';
import { formatTime } from '../utils/messageParser';
import { getMapFocusHash } from '../utils/urlHash';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import type { DistanceUnit } from '../utils/distanceUnits';
const PathRouteMap = lazy(() =>
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
@@ -44,6 +46,7 @@ export function PathModal({
isResendable,
onResend,
}: PathModalProps) {
const { distanceUnit } = useDistanceUnit();
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0;
@@ -120,7 +123,8 @@ export function PathModal({
resolvedPaths[0].resolved.sender.lon,
resolvedPaths[0].resolved.receiver.lat,
resolvedPaths[0].resolved.receiver.lon
)!
)!,
distanceUnit
)}
</span>
</div>
@@ -171,7 +175,11 @@ export function PathModal({
</Suspense>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
<PathVisualization
resolved={pathData.resolved}
senderInfo={senderInfo}
distanceUnit={distanceUnit}
/>
</div>
);
})}
@@ -227,9 +235,10 @@ export function PathModal({
interface PathVisualizationProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
distanceUnit: DistanceUnit;
}
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
// Track previous location for each hop to calculate distances
// Returns null if previous hop was ambiguous or has invalid location
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
@@ -264,6 +273,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
name={resolved.sender.name}
prefix={resolved.sender.prefix}
distance={null}
distanceUnit={distanceUnit}
isFirst
lat={resolved.sender.lat}
lon={resolved.sender.lon}
@@ -277,6 +287,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
hop={hop}
hopNumber={index + 1}
prevLocation={getPrevLocation(index)}
distanceUnit={distanceUnit}
/>
))}
@@ -286,6 +297,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
name={resolved.receiver.name}
prefix={resolved.receiver.prefix}
distance={calculateReceiverDistance(resolved)}
distanceUnit={distanceUnit}
isLast
lat={resolved.receiver.lat}
lon={resolved.receiver.lon}
@@ -300,7 +312,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
</span>
<span className="text-sm font-medium">
{resolved.hasGaps ? '>' : ''}
{formatDistance(resolved.totalDistances[0])}
{formatDistance(resolved.totalDistances[0], distanceUnit)}
</span>
</div>
)}
@@ -313,6 +325,7 @@ interface PathNodeProps {
name: string;
prefix: string;
distance: number | null;
distanceUnit: DistanceUnit;
isFirst?: boolean;
isLast?: boolean;
/** Optional coordinates for map link */
@@ -327,6 +340,7 @@ function PathNode({
name,
prefix,
distance,
distanceUnit,
isFirst,
isLast,
lat,
@@ -353,7 +367,9 @@ function PathNode({
<div className="font-medium truncate">
{name}
{distance !== null && (
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(distance, distanceUnit)}
</span>
)}
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
</div>
@@ -366,9 +382,10 @@ interface HopNodeProps {
hop: PathHop;
hopNumber: number;
prevLocation: { lat: number | null; lon: number | null } | null;
distanceUnit: DistanceUnit;
}
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
@@ -417,7 +434,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist)}
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
@@ -436,7 +453,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
{hop.distanceFromPrev !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(hop.distanceFromPrev)}
- {formatDistance(hop.distanceFromPrev, distanceUnit)}
</span>
)}
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (

View File

@@ -0,0 +1,644 @@
import { useMemo, useState } from 'react';
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
import type { Channel, RawPacket } from '../types';
import { cn } from '@/lib/utils';
import {
createDecoderOptions,
inspectRawPacketWithOptions,
type PacketByteField,
} from '../utils/rawPacketInspector';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
interface RawPacketDetailModalProps {
packet: RawPacket | null;
channels: Channel[];
onClose: () => void;
}
interface FieldPaletteEntry {
box: string;
boxActive: string;
hex: string;
hexActive: string;
}
interface GroupTextResolutionCandidate {
key: string;
name: string;
hash: string;
}
const FIELD_PALETTE: FieldPaletteEntry[] = [
{
box: 'border-sky-500/30 bg-sky-500/10',
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
hex: 'bg-sky-500/20 ring-1 ring-inset ring-sky-500/35',
hexActive: 'bg-sky-500/40 ring-1 ring-inset ring-sky-600/70',
},
{
box: 'border-emerald-500/30 bg-emerald-500/10',
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
hex: 'bg-emerald-500/20 ring-1 ring-inset ring-emerald-500/35',
hexActive: 'bg-emerald-500/40 ring-1 ring-inset ring-emerald-600/70',
},
{
box: 'border-amber-500/30 bg-amber-500/10',
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
hex: 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/35',
hexActive: 'bg-amber-500/40 ring-1 ring-inset ring-amber-600/70',
},
{
box: 'border-rose-500/30 bg-rose-500/10',
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
hex: 'bg-rose-500/20 ring-1 ring-inset ring-rose-500/35',
hexActive: 'bg-rose-500/40 ring-1 ring-inset ring-rose-600/70',
},
{
box: 'border-violet-500/30 bg-violet-500/10',
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
hex: 'bg-violet-500/20 ring-1 ring-inset ring-violet-500/35',
hexActive: 'bg-violet-500/40 ring-1 ring-inset ring-violet-600/70',
},
{
box: 'border-cyan-500/30 bg-cyan-500/10',
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
hex: 'bg-cyan-500/20 ring-1 ring-inset ring-cyan-500/35',
hexActive: 'bg-cyan-500/40 ring-1 ring-inset ring-cyan-600/70',
},
{
box: 'border-lime-500/30 bg-lime-500/10',
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
hex: 'bg-lime-500/20 ring-1 ring-inset ring-lime-500/35',
hexActive: 'bg-lime-500/40 ring-1 ring-inset ring-lime-600/70',
},
{
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
hex: 'bg-fuchsia-500/20 ring-1 ring-inset ring-fuchsia-500/35',
hexActive: 'bg-fuchsia-500/40 ring-1 ring-inset ring-fuchsia-600/70',
},
];
function formatTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatSignal(packet: RawPacket): string {
const parts: string[] = [];
if (packet.rssi !== null) {
parts.push(`${packet.rssi} dBm RSSI`);
}
if (packet.snr !== null) {
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
}
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
}
function formatByteRange(field: PacketByteField): string {
if (field.absoluteStartByte === field.absoluteEndByte) {
return `Byte ${field.absoluteStartByte}`;
}
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
}
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
if (hopCount === 0) {
return 'No path hops';
}
if (!hashSize) {
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
}
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
}
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
return channels.map((channel) => ({
key: channel.key,
name: channel.name,
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
}));
}
function resolveGroupTextRoomName(
payload: {
channelHash?: string;
cipherMac?: string;
ciphertext?: string;
decrypted?: { message?: string };
},
candidates: GroupTextResolutionCandidate[]
): string | null {
if (!payload.channelHash) {
return null;
}
const hashMatches = candidates.filter(
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
);
if (hashMatches.length === 1) {
return hashMatches[0].name;
}
if (
hashMatches.length <= 1 ||
!payload.cipherMac ||
!payload.ciphertext ||
!payload.decrypted?.message
) {
return null;
}
const decryptMatches = hashMatches.filter(
(candidate) =>
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
.success
);
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
}
function packetShowsDecryptedState(
packet: RawPacket,
inspection: ReturnType<typeof inspectRawPacketWithOptions>
): boolean {
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
}
function getPacketContext(
packet: RawPacket,
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
groupTextCandidates: GroupTextResolutionCandidate[]
) {
const fallbackSender = packet.decrypted_info?.sender ?? null;
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
if (!inspection.decoded?.payload.decoded) {
if (!fallbackSender && !fallbackRoom) {
return null;
}
return {
title: fallbackRoom ? 'Room' : 'Context',
primary: fallbackRoom ?? 'Sender metadata available',
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
};
}
if (inspection.decoded.payloadType === PayloadType.GroupText) {
const payload = inspection.decoded.payload.decoded as {
channelHash?: string;
cipherMac?: string;
ciphertext?: string;
decrypted?: { sender?: string; message?: string };
};
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
return {
title: roomName ? 'Room' : 'Channel',
primary:
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
secondary: payload.decrypted?.sender
? `Sender: ${payload.decrypted.sender}`
: fallbackSender
? `Sender: ${fallbackSender}`
: null,
};
}
if (fallbackSender) {
return {
title: 'Context',
primary: fallbackSender,
secondary: null,
};
}
return null;
}
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
return [
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
...inspection.payloadFields,
];
}
function buildFieldColorMap(fields: PacketByteField[]) {
return new Map(
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
);
}
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
const owners = new Array<string | null>(totalBytes).fill(null);
for (const field of fields) {
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
if (index >= 0 && index < owners.length) {
owners[index] = field.id;
}
}
}
return owners;
}
function buildByteRuns(bytes: string[], owners: Array<string | null>) {
const runs: Array<{ fieldId: string | null; text: string }> = [];
for (let index = 0; index < bytes.length; index += 1) {
const fieldId = owners[index];
const lastRun = runs[runs.length - 1];
if (lastRun && lastRun.fieldId === fieldId) {
lastRun.text += ` ${bytes[index]}`;
continue;
}
runs.push({
fieldId,
text: bytes[index],
});
}
return runs;
}
function CompactMetaCard({
label,
primary,
secondary,
}: {
label: string;
primary: string;
secondary?: string | null;
}) {
return (
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
{secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
) : null}
</div>
);
}
function FullPacketHex({
packetHex,
fields,
colorMap,
hoveredFieldId,
onHoverField,
}: {
packetHex: string;
fields: PacketByteField[];
colorMap: Map<string, FieldPaletteEntry>;
hoveredFieldId: string | null;
onHoverField: (fieldId: string | null) => void;
}) {
const normalized = packetHex.toUpperCase();
const bytes = useMemo(() => normalized.match(/.{1,2}/g) ?? [], [normalized]);
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
return (
<div className="font-mono text-[15px] leading-7 text-foreground">
{byteRuns.map((run, index) => {
const fieldId = run.fieldId;
const palette = fieldId ? colorMap.get(fieldId) : null;
const active = fieldId !== null && hoveredFieldId === fieldId;
return (
<span key={`${fieldId ?? 'plain'}-${index}`}>
<span
onMouseEnter={() => onHoverField(fieldId)}
onMouseLeave={() => onHoverField(null)}
className={cn(
'inline rounded-sm px-0.5 py-0.5 transition-colors',
palette ? (active ? palette.hexActive : palette.hex) : ''
)}
>
{run.text}
</span>
{index < byteRuns.length - 1 ? ' ' : ''}
</span>
);
})}
</div>
);
}
function renderFieldValue(field: PacketByteField) {
if (field.name !== 'Path Data') {
return field.value.toUpperCase();
}
const parts = field.value
.toUpperCase()
.split(' → ')
.filter((part) => part.length > 0);
if (parts.length <= 1) {
return field.value.toUpperCase();
}
return (
<span className="inline-flex flex-wrap justify-start gap-x-1 sm:justify-end">
{parts.map((part, index) => {
const isLast = index === parts.length - 1;
return (
<span key={`${field.id}-${part}-${index}`} className="whitespace-nowrap">
{isLast ? part : `${part}`}
</span>
);
})}
</span>
);
}
function FieldBox({
field,
palette,
active,
onHoverField,
}: {
field: PacketByteField;
palette: FieldPaletteEntry;
active: boolean;
onHoverField: (fieldId: string | null) => void;
}) {
return (
<div
onMouseEnter={() => onHoverField(field.id)}
onMouseLeave={() => onHoverField(null)}
className={cn(
'rounded-lg border p-2.5 transition-colors',
active ? palette.boxActive : palette.box
)}
>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
<div className="min-w-0">
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
</div>
<div
className={cn(
'w-full font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right',
field.name === 'Path Data' ? 'break-normal' : 'break-all'
)}
>
{renderFieldValue(field)}
</div>
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
{field.description}
</div>
{field.decryptedMessage ? (
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
</div>
<PlaintextContent text={field.decryptedMessage} />
</div>
) : null}
{field.headerBreakdown ? (
<div className="mt-2 space-y-1.5">
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
{field.headerBreakdown.fullBinary}
</div>
{field.headerBreakdown.fields.map((part) => (
<div
key={`${field.id}-${part.bits}-${part.field}`}
className="rounded border border-border/50 bg-background/40 p-2"
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-medium leading-tight text-foreground">
{part.field}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
</div>
<div className="text-right">
<div className="font-mono text-sm text-foreground">{part.binary}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
</div>
</div>
</div>
))}
</div>
) : null}
</div>
);
}
function PlaintextContent({ text }: { text: string }) {
const lines = text.split('\n');
return (
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
{lines.map((line, index) => {
const separatorIndex = line.indexOf(': ');
if (separatorIndex === -1) {
return (
<div key={`${line}-${index}`} className="font-mono">
{line}
</div>
);
}
const label = line.slice(0, separatorIndex + 1);
const value = line.slice(separatorIndex + 2);
return (
<div key={`${line}-${index}`}>
<span>{label} </span>
<span className="font-mono">{value}</span>
</div>
);
})}
</div>
);
}
function FieldSection({
title,
fields,
colorMap,
hoveredFieldId,
onHoverField,
}: {
title: string;
fields: PacketByteField[];
colorMap: Map<string, FieldPaletteEntry>;
hoveredFieldId: string | null;
onHoverField: (fieldId: string | null) => void;
}) {
return (
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
{fields.length === 0 ? (
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
) : (
<div className="grid gap-2">
{fields.map((field) => (
<FieldBox
key={field.id}
field={field}
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
active={hoveredFieldId === field.id}
onHoverField={onHoverField}
/>
))}
</div>
)}
</section>
);
}
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels),
[channels]
);
const inspection = useMemo(
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
[decoderOptions, packet]
);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const packetDisplayFields = useMemo(
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
[inspection]
);
const fullPacketFields = useMemo(
() => (inspection ? buildDisplayFields(inspection) : []),
[inspection]
);
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
const packetContext = useMemo(
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
[groupTextCandidates, inspection, packet]
);
const packetIsDecrypted = useMemo(
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
[inspection, packet]
);
if (!packet || !inspection) {
return null;
}
return (
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle>Packet Details</DialogTitle>
<DialogDescription className="sr-only">
Detailed byte and field breakdown for the selected raw packet.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary
</div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
{inspection.summary.summary}
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{formatTimestamp(packet.timestamp)}
</div>
</div>
{packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title}
</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
{packetContext.primary}
</div>
{packetContext.secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">
{packetContext.secondary}
</div>
) : null}
</div>
) : null}
</section>
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
<CompactMetaCard
label="Packet"
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
/>
<CompactMetaCard
label="Transport"
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/>
<CompactMetaCard
label="Signal"
primary={formatSignal(packet)}
secondary={packetContext ? null : undefined}
/>
</section>
</div>
{inspection.validationErrors.length > 0 ? (
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
<div className="text-sm font-semibold text-foreground">Validation notes</div>
<div className="mt-1.5 space-y-1 text-sm text-foreground">
{inspection.validationErrors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
</div>
) : null}
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
<div className="mt-2.5">
<FullPacketHex
packetHex={packet.data}
fields={fullPacketFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<FieldSection
title="Packet fields"
fields={packetDisplayFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
<FieldSection
title="Payload fields"
fields={inspection.payloadFields}
colorMap={colorMap}
hoveredFieldId={hoveredFieldId}
onHoverField={setHoveredFieldId}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,609 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { RawPacketList } from './RawPacketList';
import { RawPacketDetailModal } from './RawPacketDetailModal';
import type { Channel, Contact, RawPacket } from '../types';
import {
RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot,
type NeighborStat,
type PacketTimelineBin,
type RankedPacketStat,
type RawPacketStatsSessionState,
type RawPacketStatsWindow,
} from '../utils/rawPacketStats';
import { getContactDisplayName } from '../utils/pubkey';
import { cn } from '@/lib/utils';
interface RawPacketFeedViewProps {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
contacts: Contact[];
channels: Channel[];
}
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
'1m': '1 min',
'5m': '5 min',
'10m': '10 min',
'30m': '30 min',
session: 'Session',
};
const TIMELINE_COLORS = [
'bg-sky-500/80',
'bg-emerald-500/80',
'bg-amber-500/80',
'bg-rose-500/80',
'bg-violet-500/80',
];
function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatDuration(seconds: number): string {
if (seconds < 60) {
return `${Math.max(1, Math.round(seconds))} sec`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainder = Math.round(seconds % 60);
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
function formatRate(value: number): string {
if (value >= 100) return value.toFixed(0);
if (value >= 10) return value.toFixed(1);
return value.toFixed(2);
}
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
function formatRssi(value: number | null): string {
return value === null ? '-' : `${Math.round(value)} dBm`;
}
function normalizeResolvableSourceKey(sourceKey: string): string {
return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey;
}
function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null {
if (!sourceKey || sourceKey.startsWith('name:')) {
return null;
}
const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase();
const matches = contacts.filter((contact) =>
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
);
if (matches.length !== 1) {
return null;
}
return matches[0];
}
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
const contact = resolveContact(sourceKey, contacts);
if (!contact) {
return null;
}
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
}
function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat {
return {
...item,
label: resolveContactLabel(item.key, contacts) ?? item.label,
};
}
function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] {
const merged = new Map<string, NeighborStat>();
for (const item of items) {
const contact = resolveContact(item.key, contacts);
const canonicalKey = contact?.public_key ?? item.key;
const resolvedLabel =
contact != null
? getContactDisplayName(contact.name, contact.public_key, contact.last_advert)
: item.label;
const existing = merged.get(canonicalKey);
if (!existing) {
merged.set(canonicalKey, {
...item,
key: canonicalKey,
label: resolvedLabel,
});
continue;
}
existing.count += item.count;
existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen);
existing.bestRssi =
existing.bestRssi === null
? item.bestRssi
: item.bestRssi === null
? existing.bestRssi
: Math.max(existing.bestRssi, item.bestRssi);
existing.label = resolvedLabel;
}
return Array.from(merged.values());
}
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
if (item.key.startsWith('name:')) {
return true;
}
return resolveContact(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
contacts: Contact[]
): string | undefined {
if (!stats.strongestPacketPayloadType) {
return undefined;
}
const resolvedLabel =
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
stats.strongestPacketSourceLabel;
if (resolvedLabel) {
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
}
if (stats.strongestPacketPayloadType === 'GroupText') {
return '<unknown sender> · GroupText';
}
return stats.strongestPacketPayloadType;
}
function getCoverageMessage(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
session: RawPacketStatsSessionState
): { tone: 'default' | 'warning'; message: string } {
if (session.trimmedObservationCount > 0 && stats.window === 'session') {
return {
tone: 'warning',
message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`,
};
}
if (!stats.windowFullyCovered) {
return {
tone: 'warning',
message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`,
};
}
return {
tone: 'default',
message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`,
};
}
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
</div>
);
}
function RankedBars({
title,
items,
emptyLabel,
formatter,
}: {
title: string;
items: RankedPacketStat[];
emptyLabel: string;
formatter?: (item: RankedPacketStat) => string;
}) {
const maxCount = Math.max(...items.map((item) => item.count), 1);
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="truncate text-foreground">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</section>
);
}
function NeighborList({
title,
items,
emptyLabel,
mode,
contacts,
}: {
title: string;
items: NeighborStat[];
emptyLabel: string;
mode: 'heard' | 'signal' | 'recent';
contacts: Contact[];
}) {
const mergedItems = mergeResolvedNeighbors(items, contacts);
const sortedItems = [...mergedItems].sort((a, b) => {
if (mode === 'heard') {
return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label);
}
if (mode === 'signal') {
return (
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
b.count - a.count ||
a.label.localeCompare(b.label)
);
}
return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label);
});
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{sortedItems.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{sortedItems.map((item) => (
<div
key={item.key}
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
>
<div className="min-w-0">
<div className="truncate text-sm text-foreground">{item.label}</div>
<div className="text-xs text-muted-foreground">
{mode === 'heard'
? `${item.count.toLocaleString()} packets`
: mode === 'signal'
? `${formatRssi(item.bestRssi)} best`
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div>
{!isNeighborIdentityResolvable(item, contacts) ? (
<div className="text-[11px] text-warning">Identity not resolvable</div>
) : null}
</div>
{mode !== 'signal' ? (
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
</div>
) : null}
</div>
))}
</div>
)}
</section>
);
}
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
0,
TIMELINE_COLORS.length
);
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
{typeOrder.map((type, index) => (
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
<span>{type}</span>
</span>
))}
</div>
</div>
<div className="mt-3 flex items-start gap-1">
{bins.map((bin, index) => (
<div
key={`${bin.label}-${index}`}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
<div className="flex h-full w-full flex-col justify-end">
{typeOrder.map((type, index) => {
const count = bin.countsByType[type] ?? 0;
if (count === 0) return null;
return (
<div
key={type}
className={cn('w-full', TIMELINE_COLORS[index])}
style={{
height: `${(count / maxTotal) * 100}%`,
}}
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
/>
);
})}
</div>
</div>
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
</div>
))}
</div>
</section>
);
}
export function RawPacketFeedView({
packets,
rawPacketStatsSession,
contacts,
channels,
}: RawPacketFeedViewProps) {
const [statsOpen, setStatsOpen] = useState(() =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(min-width: 768px)').matches
: false
);
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
useEffect(() => {
const interval = window.setInterval(() => {
setNowSec(Math.floor(Date.now() / 1000));
}, 30000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
setNowSec(Math.floor(Date.now() / 1000));
}, [packets, rawPacketStatsSession]);
const stats = useMemo(
() => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec),
[nowSec, rawPacketStatsSession, selectedWindow]
);
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
const strongestPacketDetail = useMemo(
() => formatStrongestPacketDetail(stats, contacts),
[contacts, stats]
);
const strongestNeighbors = useMemo(
() => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.strongestNeighbors]
);
const mostActiveNeighbors = useMemo(
() => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.mostActiveNeighbors]
);
const newestNeighbors = useMemo(
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
[contacts, stats.newestNeighbors]
);
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>
<button
type="button"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</button>
</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} />
</div>
<aside
className={cn(
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
statsOpen
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
)}
>
{statsOpen ? (
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
Coverage
</div>
<div
className={cn(
'mt-1 text-sm',
coverageMessage.tone === 'warning'
? 'text-warning'
: 'text-muted-foreground'
)}
>
{coverageMessage.message}
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<span className="text-muted-foreground">Window</span>
<select
value={selectedWindow}
onChange={(event) =>
setSelectedWindow(event.target.value as RawPacketStatsWindow)
}
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
aria-label="Stats window"
>
{RAW_PACKET_STATS_WINDOWS.map((option) => (
<option key={option} value={option}>
{WINDOW_LABELS[option]}
</option>
))}
</select>
</label>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{stats.packetCount.toLocaleString()} packets in{' '}
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
{' · '}
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
session
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
<StatTile
label="Packets / min"
value={formatRate(stats.packetsPerMinute)}
detail={`${stats.packetCount.toLocaleString()} total in window`}
/>
<StatTile
label="Unique Sources"
value={stats.uniqueSources.toLocaleString()}
detail="Distinct identified senders"
/>
<StatTile
label="Decrypt Rate"
value={formatPercent(stats.decryptRate)}
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
/>
<StatTile
label="Path Diversity"
value={stats.distinctPaths.toLocaleString()}
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
/>
<StatTile
label="Best RSSI"
value={formatRssi(stats.bestRssi)}
detail={strongestPacketDetail ?? 'No signal sample in window'}
/>
<StatTile
label="Median RSSI"
value={formatRssi(stats.medianRssi)}
detail={
stats.averageRssi === null
? 'No signal sample in window'
: `Average ${formatRssi(stats.averageRssi)}`
}
/>
</div>
<div className="mt-4">
<TimelineChart bins={stats.timeline} />
</div>
<div className="md:columns-2 md:gap-4">
<RankedBars
title="Packet Types"
items={stats.payloadBreakdown}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Route Mix"
items={stats.routeBreakdown}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Hop Profile"
items={stats.hopProfile}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Hop Byte Width"
items={stats.hopByteWidthProfile}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Signal Distribution"
items={stats.rssiBuckets}
emptyLabel="No RSSI samples in this window yet."
/>
<NeighborList
title="Most-Heard Neighbors"
items={mostActiveNeighbors}
emptyLabel="No sender identities resolved in this window yet."
mode="heard"
contacts={contacts}
/>
<NeighborList
title="Strongest Recent Neighbors"
items={strongestNeighbors}
emptyLabel="No RSSI-tagged neighbors in this window yet."
mode="signal"
contacts={contacts}
/>
<NeighborList
title="Newest Heard Neighbors"
items={newestNeighbors}
emptyLabel="No newly identified neighbors in this window yet."
mode="recent"
contacts={contacts}
/>
</div>
</div>
) : null}
</aside>
</div>
<RawPacketDetailModal
packet={selectedPacket}
channels={channels}
onClose={() => setSelectedPacket(null)}
/>
</>
);
}

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
import type { Channel, RawPacket } from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector';
import { cn } from '@/lib/utils';
interface RawPacketListProps {
packets: RawPacket[];
channels?: Channel[];
onPacketClick?: (packet: RawPacket) => void;
}
function formatTime(timestamp: number): string {
@@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string {
return parts.join(' | ');
}
// Decrypted info from the packet (validated by backend)
interface DecryptedInfo {
channel_name: string | null;
sender: string | null;
}
// Decode a packet and generate a human-readable summary
// Uses backend's decrypted_info when available (validated), falls back to decoder
function decodePacketSummary(
hexData: string,
decryptedInfo: DecryptedInfo | null
): {
summary: string;
routeType: string;
details?: string;
} {
try {
const decoded = MeshCoreDecoder.decode(hexData);
if (!decoded.isValid) {
return { summary: 'Invalid packet', routeType: 'Unknown' };
}
const routeType = Utils.getRouteTypeName(decoded.routeType);
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
const tracePayload =
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
? (decoded.payload.decoded as { pathHashes?: string[] })
: null;
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
// Build path string if available
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
// Generate summary based on payload type
let summary = payloadTypeName;
let details: string | undefined;
switch (decoded.payloadType) {
case PayloadType.TextMessage: {
const payload = decoded.payload.decoded as {
destinationHash?: string;
sourceHash?: string;
} | null;
if (payload?.sourceHash && payload?.destinationHash) {
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
} else {
summary = `DM${pathStr}`;
}
break;
}
case PayloadType.GroupText: {
const payload = decoded.payload.decoded as {
channelHash?: string;
} | null;
// Use backend's validated decrypted_info when available
if (decryptedInfo?.channel_name) {
if (decryptedInfo.sender) {
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
} else {
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
}
} else if (payload?.channelHash) {
// Fallback to showing channel hash when not decrypted
summary = `GT ch:${payload.channelHash}${pathStr}`;
} else {
summary = `GroupText${pathStr}`;
}
break;
}
case PayloadType.Advert: {
const payload = decoded.payload.decoded as {
publicKey?: string;
appData?: { name?: string; deviceRole?: number };
} | null;
if (payload?.appData?.name) {
const role =
payload.appData.deviceRole !== undefined
? Utils.getDeviceRoleName(payload.appData.deviceRole)
: '';
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
} else if (payload?.publicKey) {
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
} else {
summary = `Advert${pathStr}`;
}
break;
}
case PayloadType.Ack: {
summary = `ACK${pathStr}`;
break;
}
case PayloadType.Request: {
summary = `Request${pathStr}`;
break;
}
case PayloadType.Response: {
summary = `Response${pathStr}`;
break;
}
case PayloadType.Trace: {
summary = `Trace${pathStr}`;
break;
}
case PayloadType.Path: {
summary = `Path${pathStr}`;
break;
}
default:
summary = `${payloadTypeName}${pathStr}`;
}
return { summary, routeType, details };
} catch {
return { summary: 'Decode error', routeType: 'Unknown' };
}
}
// Get route type badge color
function getRouteTypeColor(routeType: string): string {
switch (routeType) {
@@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string {
}
}
export function RawPacketList({ packets }: RawPacketListProps) {
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
// Decode all packets (memoized to avoid re-decoding on every render)
const decodedPackets = useMemo(() => {
return packets.map((packet) => ({
packet,
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
decoded: decodePacketSummary(packet, decoderOptions),
}));
}, [packets]);
}, [decoderOptions, packets]);
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = useMemo(
@@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) {
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
ref={listRef}
>
{sortedPackets.map(({ packet, decoded }) => (
<div
key={getRawPacketObservationKey(packet)}
className="py-2 px-3 bg-card rounded-md border border-border/50"
>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{sortedPackets.map(({ packet, decoded }) => {
const cardContent = (
<>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType}
>
{getRouteTypeLabel(decoded.routeType)}
</span>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
{/* Encryption status */}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
)}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)}
</div>
)}
{/* Summary */}
<span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
>
{decoded.summary}
</span>
{/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)}
</span>
</div>
{/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)}
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()}
</div>
)}
</>
);
{/* Raw hex data (always visible) */}
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()}
const className = cn(
'rounded-md border border-border/50 bg-card px-3 py-2 text-left',
onPacketClick &&
'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
);
if (onPacketClick) {
return (
<button
key={getRawPacketObservationKey(packet)}
type="button"
onClick={() => onPacketClick(packet)}
className={className}
>
{cardContent}
</button>
);
}
return (
<div key={getRawPacketObservationKey(packet)} className={className}>
{cardContent}
</div>
</div>
))}
);
})}
</div>
);
}

View File

@@ -5,12 +5,14 @@ import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
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 } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
@@ -81,8 +83,18 @@ export function RepeaterDashboard({
rebootRepeater,
syncClock,
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
};
const handleRepeaterGuestLogin = async () => {
await loginAsGuest();
persistAfterLogin('');
};
// Loading all panes indicator
const anyLoading = Object.values(paneStates).some((s) => s.loading);
@@ -90,8 +102,15 @@ export function RepeaterDashboard({
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 flex-col">
<header
className={cn(
'grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
contact
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]'
)}
>
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
@@ -111,14 +130,14 @@ export function RepeaterDashboard({
{conversation.id}
</span>
</span>
{contact && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</span>
)}
</span>
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{contact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</div>
)}
<div className="flex items-center gap-0.5">
{loggedIn && (
<Button
variant="outline"
@@ -221,8 +240,12 @@ export function RepeaterDashboard({
repeaterName={conversation.name}
loading={loginLoading}
error={loginError}
onLogin={login}
onLoginAsGuest={loginAsGuest}
password={password}
onPasswordChange={setPassword}
rememberPassword={rememberPassword}
onRememberPasswordChange={setRememberPassword}
onLogin={handleRepeaterLogin}
onLoginAsGuest={handleRepeaterGuestLogin}
/>
) : (
<div className="space-y-4">

View File

@@ -1,24 +1,39 @@
import { useState, useCallback, type FormEvent } from 'react';
import { useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
interface RepeaterLoginProps {
repeaterName: string;
loading: boolean;
error: string | null;
password: string;
onPasswordChange: (password: string) => void;
rememberPassword: boolean;
onRememberPasswordChange: (checked: boolean) => void;
onLogin: (password: string) => Promise<void>;
onLoginAsGuest: () => Promise<void>;
description?: string;
passwordPlaceholder?: string;
loginLabel?: string;
guestLabel?: string;
}
export function RepeaterLogin({
repeaterName,
loading,
error,
password,
onPasswordChange,
rememberPassword,
onRememberPasswordChange,
onLogin,
onLoginAsGuest,
description = 'Log in to access repeater dashboard',
passwordPlaceholder = 'Repeater password...',
loginLabel = 'Login with Password',
guestLabel = 'Login as Guest / ACLs',
}: RepeaterLoginProps) {
const [password, setPassword] = useState('');
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
@@ -33,7 +48,7 @@ export function RepeaterLogin({
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<h2 className="text-lg font-semibold">{repeaterName}</h2>
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
@@ -45,13 +60,34 @@ export function RepeaterLogin({
data-1p-ignore="true"
data-bwignore="true"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
onChange={(e) => onPasswordChange(e.target.value)}
placeholder={passwordPlaceholder}
aria-label="Repeater password"
disabled={loading}
autoFocus
/>
<label
htmlFor="remember-server-password"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Checkbox
id="remember-server-password"
checked={rememberPassword}
disabled={loading}
onCheckedChange={(checked) => onRememberPasswordChange(checked === true)}
/>
<span>Remember password</span>
</label>
{rememberPassword && (
<p className="text-xs text-muted-foreground">
Passwords are stored unencrypted in local browser storage for this domain. It is
highly recommended to login via ACLs after your first successful login; saving the
password is not recommended.
</p>
)}
{error && (
<p className="text-sm text-destructive text-center" role="alert">
{error}
@@ -60,7 +96,7 @@ export function RepeaterLogin({
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Logging in...' : 'Login with Password'}
{loading ? 'Logging in...' : loginLabel}
</Button>
<Button
type="button"
@@ -69,7 +105,7 @@ export function RepeaterLogin({
className="w-full"
onClick={onLoginAsGuest}
>
Login as Guest / ACLs
{guestLabel}
</Button>
</div>
</form>

View File

@@ -0,0 +1,312 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import type {
Contact,
PaneState,
RepeaterAclResponse,
RepeaterLppTelemetryResponse,
RepeaterStatusResponse,
} from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
interface RoomServerPanelProps {
contact: Contact;
onAuthenticatedChange?: (authenticated: boolean) => void;
}
type RoomPaneKey = 'status' | 'acl' | 'lppTelemetry';
type RoomPaneData = {
status: RepeaterStatusResponse | null;
acl: RepeaterAclResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
};
type RoomPaneStates = Record<RoomPaneKey, PaneState>;
type ConsoleEntry = {
command: string;
response: string;
timestamp: number;
outgoing: boolean;
};
const INITIAL_PANE_STATE: PaneState = {
loading: false,
attempt: 0,
error: null,
fetched_at: null,
};
function createInitialPaneStates(): RoomPaneStates {
return {
status: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
};
}
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('room', contact.public_key);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginMessage, setLoginMessage] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
status: null,
acl: null,
lppTelemetry: null,
});
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleLoading, setConsoleLoading] = useState(false);
useEffect(() => {
setLoginLoading(false);
setLoginError(null);
setLoginMessage(null);
setAuthenticated(false);
setAdvancedOpen(false);
setPaneData({
status: null,
acl: null,
lppTelemetry: null,
});
setPaneStates(createInitialPaneStates());
setConsoleHistory([]);
setConsoleLoading(false);
}, [contact.public_key]);
useEffect(() => {
onAuthenticatedChange?.(authenticated);
}, [authenticated, onAuthenticatedChange]);
const refreshPane = useCallback(
async <K extends RoomPaneKey>(pane: K, loader: () => Promise<RoomPaneData[K]>) => {
setPaneStates((prev) => ({
...prev,
[pane]: {
...prev[pane],
loading: true,
attempt: prev[pane].attempt + 1,
error: null,
},
}));
try {
const data = await loader();
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: {
loading: false,
attempt: prev[pane].attempt,
error: null,
fetched_at: Date.now(),
},
}));
} catch (err) {
setPaneStates((prev) => ({
...prev,
[pane]: {
...prev[pane],
loading: false,
error: err instanceof Error ? err.message : 'Unknown error',
},
}));
}
},
[]
);
const performLogin = useCallback(
async (password: string) => {
if (loginLoading) return;
setLoginLoading(true);
setLoginError(null);
setLoginMessage(null);
try {
const result = await api.roomLogin(contact.public_key, password);
setAuthenticated(true);
setLoginMessage(
result.message ??
(result.authenticated
? 'Login confirmed. You can now send room messages and open admin tools.'
: 'Login request sent, but authentication was not confirmed.')
);
if (result.authenticated) {
toast.success('Room login confirmed');
} else {
toast(result.message ?? 'Room login was not confirmed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setAuthenticated(true);
setLoginError(message);
toast.error('Room login failed', { description: message });
} finally {
setLoginLoading(false);
}
},
[contact.public_key, loginLoading]
);
const handleLogin = useCallback(
async (password: string) => {
await performLogin(password);
persistAfterLogin(password);
},
[performLogin, persistAfterLogin]
);
const handleLoginAsGuest = useCallback(async () => {
await performLogin('');
persistAfterLogin('');
}, [performLogin, persistAfterLogin]);
const handleConsoleCommand = useCallback(
async (command: string) => {
setConsoleLoading(true);
const timestamp = Date.now();
setConsoleHistory((prev) => [
...prev,
{ command, response: command, timestamp, outgoing: true },
]);
try {
const response = await api.sendRepeaterCommand(contact.public_key, command);
setConsoleHistory((prev) => [
...prev,
{
command,
response: response.response,
timestamp: Date.now(),
outgoing: false,
},
]);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setConsoleHistory((prev) => [
...prev,
{
command,
response: `(error) ${message}`,
timestamp: Date.now(),
outgoing: false,
},
]);
} finally {
setConsoleLoading(false);
}
},
[contact.public_key]
);
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
if (!authenticated) {
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning">
Room server access is experimental and in public alpha. Please report any issues on{' '}
<a
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-2 hover:text-warning/80"
>
GitHub
</a>
.
</div>
<RepeaterLogin
repeaterName={panelTitle}
loading={loginLoading}
error={loginError}
password={password}
onPasswordChange={setPassword}
rememberPassword={rememberPassword}
onRememberPasswordChange={setRememberPassword}
onLogin={handleLogin}
onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest"
/>
</div>
</div>
);
}
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Room Server Controls</div>
<p className="text-xs text-muted-foreground">
Room access is active. Use the chat history and message box below to participate, and
open admin tools when needed.
</p>
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Refresh ACL Login
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div>
{advancedOpen && (
<div className="grid gap-3 xl:grid-cols-2">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
/>
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() =>
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
}
/>
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={handleConsoleCommand}
/>
</div>
)}
</div>
</section>
);
}

View File

@@ -3,6 +3,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -35,7 +36,7 @@ interface SettingsModalBaseProps {
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -177,33 +178,38 @@ export function SettingsModal(props: SettingsModalProps) {
return null;
}
return !config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
return (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && appSettings && (
<SettingsRadioSection
config={config}
health={health}
appSettings={appSettings}
pageMode={pageMode}
onSave={onSave}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={onSetPrivateKey}
onReboot={onReboot}
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onAdvertise={onAdvertise}
meshDiscovery={meshDiscovery}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onDiscoverMesh={onDiscoverMesh}
onClose={onClose}
className={sectionContentClass}
/>
)}
{isSectionVisible('radio') &&
(config && appSettings ? (
<SettingsRadioSection
config={config}
health={health}
appSettings={appSettings}
pageMode={pageMode}
onSave={onSave}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={onSetPrivateKey}
onReboot={onReboot}
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onAdvertise={onAdvertise}
meshDiscovery={meshDiscovery}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onDiscoverMesh={onDiscoverMesh}
onClose={onClose}
className={sectionContentClass}
/>
) : (
<div className={sectionContentClass}>
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Radio is not available.
</div>
</div>
))}
</section>
)}
@@ -222,19 +228,26 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('database') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && appSettings && (
<SettingsDatabaseSection
appSettings={appSettings}
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
className={sectionContentClass}
/>
)}
{isSectionVisible('database') &&
(appSettings ? (
<SettingsDatabaseSection
appSettings={appSettings}
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
className={sectionContentClass}
/>
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Loading app settings...
</div>
</div>
))}
</section>
)}
@@ -263,7 +276,9 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('about') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('about')}
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
{isSectionVisible('about') && (
<SettingsAboutSection health={health} className={sectionContentClass} />
)}
</section>
)}
</div>

View File

@@ -13,6 +13,7 @@ import {
X,
} from 'lucide-react';
import {
CONTACT_TYPE_ROOM,
CONTACT_TYPE_REPEATER,
type Contact,
type Channel,
@@ -57,6 +58,7 @@ type CollapseState = {
favorites: boolean;
channels: boolean;
contacts: boolean;
rooms: boolean;
repeaters: boolean;
};
@@ -67,6 +69,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
favorites: false,
channels: false,
contacts: false,
rooms: false,
repeaters: false,
};
@@ -80,6 +83,7 @@ function loadCollapsedState(): CollapseState {
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms,
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
};
} catch {
@@ -157,6 +161,7 @@ export function Sidebar({
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
const collapseSnapshotRef = useRef<CollapseState | null>(null);
const sectionSortSourceRef = useRef(initialSectionSortState.source);
@@ -216,6 +221,20 @@ export function Sidebar({
[lastMessageTimes]
);
const getContactHeardTime = useCallback((contact: Contact): number => {
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
}, []);
const getContactRecentTime = useCallback(
(contact: Contact): number => {
if (contact.type === CONTACT_TYPE_REPEATER) {
return getContactHeardTime(contact);
}
return getLastMessageTime('contact', contact.public_key) || getContactHeardTime(contact);
},
[getContactHeardTime, getLastMessageTime]
);
// Deduplicate channels by key only.
// Channel names are not unique; distinct keys must remain visible.
const uniqueChannels = useMemo(
@@ -274,34 +293,94 @@ export function Sidebar({
(items: Contact[], order: SortOrder) =>
[...items].sort((a, b) => {
if (order === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
const timeA = getContactRecentTime(a);
const timeB = getContactRecentTime(b);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}),
[getLastMessageTime]
[getContactRecentTime]
);
const sortRepeatersByOrder = useCallback(
(items: Contact[], order: SortOrder) =>
[...items].sort((a, b) => {
if (order === 'recent') {
const timeA = getContactHeardTime(a);
const timeB = getContactHeardTime(b);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}),
[getContactHeardTime]
);
const getFavoriteItemName = useCallback(
(item: FavoriteItem) =>
item.type === 'channel'
? item.channel.name
: getContactDisplayName(
item.contact.name,
item.contact.public_key,
item.contact.last_advert
),
[]
);
const sortFavoriteItemsByOrder = useCallback(
(items: FavoriteItem[], order: SortOrder) =>
[...items].sort((a, b) => {
if (order === 'recent') {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getContactRecentTime(a.contact);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getContactRecentTime(b.contact);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b));
}),
[getContactRecentTime, getFavoriteItemName, getLastMessageTime]
);
// Split non-repeater contacts and repeater contacts into separate sorted lists
const sortedNonRepeaterContacts = useMemo(
() =>
sortContactsByOrder(
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
uniqueContacts.filter(
(c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM
),
sectionSortOrders.contacts
),
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
);
const sortedRepeaters = useMemo(
const sortedRooms = useMemo(
() =>
sortContactsByOrder(
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM),
sectionSortOrders.rooms
),
[uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder]
);
const sortedRepeaters = useMemo(
() =>
sortRepeatersByOrder(
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER),
sectionSortOrders.repeaters
),
[uniqueContacts, sectionSortOrders.repeaters, sortContactsByOrder]
[uniqueContacts, sectionSortOrders.repeaters, sortRepeatersByOrder]
);
// Filter by search query
@@ -329,6 +408,17 @@ export function Sidebar({
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(
() =>
query
? sortedRooms.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(
() =>
query
@@ -349,6 +439,7 @@ export function Sidebar({
favorites: favoritesCollapsed,
channels: channelsCollapsed,
contacts: contactsCollapsed,
rooms: roomsCollapsed,
repeaters: repeatersCollapsed,
};
}
@@ -358,12 +449,14 @@ export function Sidebar({
favoritesCollapsed ||
channelsCollapsed ||
contactsCollapsed ||
roomsCollapsed ||
repeatersCollapsed
) {
setToolsCollapsed(false);
setFavoritesCollapsed(false);
setChannelsCollapsed(false);
setContactsCollapsed(false);
setRoomsCollapsed(false);
setRepeatersCollapsed(false);
}
return;
@@ -376,6 +469,7 @@ export function Sidebar({
setFavoritesCollapsed(prev.favorites);
setChannelsCollapsed(prev.channels);
setContactsCollapsed(prev.contacts);
setRoomsCollapsed(prev.rooms);
setRepeatersCollapsed(prev.repeaters);
}
}, [
@@ -384,6 +478,7 @@ export function Sidebar({
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
roomsCollapsed,
repeatersCollapsed,
]);
@@ -395,6 +490,7 @@ export function Sidebar({
favorites: favoritesCollapsed,
channels: channelsCollapsed,
contacts: contactsCollapsed,
rooms: roomsCollapsed,
repeaters: repeatersCollapsed,
};
@@ -409,61 +505,56 @@ export function Sidebar({
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
roomsCollapsed,
repeatersCollapsed,
]);
// Separate favorites from regular items, and build combined favorites list
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favContacts = [...filteredNonRepeaterContacts, ...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 nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const {
favoriteItems,
nonFavoriteChannels,
nonFavoriteContacts,
nonFavoriteRooms,
nonFavoriteRepeaters,
} = useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
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)
);
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
].sort((a, b) => {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getLastMessageTime('contact', a.contact.public_key);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getLastMessageTime('contact', b.contact.public_key);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
const nameA =
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
const nameB =
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
return nameA.localeCompare(nameB);
});
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
];
return {
favoriteItems: items,
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRepeaters: nonFavRepeaters,
};
}, [
filteredChannels,
filteredNonRepeaterContacts,
filteredRepeaters,
favorites,
getLastMessageTime,
]);
return {
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRooms: nonFavRooms,
nonFavoriteRepeaters: nonFavRepeaters,
};
}, [
filteredChannels,
filteredNonRepeaterContacts,
filteredRooms,
filteredRepeaters,
favorites,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
key: `${keyPrefix}-${channel.key}`,
@@ -487,57 +578,65 @@ export function Sidebar({
contact,
});
const renderConversationRow = (row: ConversationRow) => (
<div
key={row.key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive(row.type, row.id) && 'bg-accent border-l-primary',
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
)}
role="button"
tabIndex={0}
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: row.type,
id: row.id,
name: row.name,
})
}
>
{row.type === 'contact' && row.contact && (
<ContactAvatar
name={row.contact.name}
publicKey={row.contact.public_key}
size={24}
contactType={row.contact.type}
/>
)}
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
const renderConversationRow = (row: ConversationRow) => {
const highlightUnread =
row.isMention ||
(row.type === 'contact' &&
row.contact?.type !== CONTACT_TYPE_REPEATER &&
row.unreadCount > 0);
return (
<div
key={row.key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive(row.type, row.id) && 'bg-accent border-l-primary',
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
role="button"
tabIndex={0}
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: row.type,
id: row.id,
name: row.name,
})
}
>
{row.type === 'contact' && row.contact && (
<ContactAvatar
name={row.contact.name}
publicKey={row.contact.public_key}
size={24}
contactType={row.contact.type}
/>
)}
</span>
</div>
);
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
highlightUnread
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
</span>
</div>
);
};
const renderSidebarActionRow = ({
key,
@@ -583,11 +682,13 @@ export function Sidebar({
);
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room'));
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
const channelsUnreadCount = getSectionUnreadCount(channelRows);
const contactsUnreadCount = getSectionUnreadCount(contactRows);
const roomsUnreadCount = getSectionUnreadCount(roomRows);
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
const favoritesHasMention = sectionHasMention(favoriteRows);
const channelsHasMention = sectionHasMention(channelRows);
@@ -803,7 +904,7 @@ export function Sidebar({
'Favorites',
favoritesCollapsed,
() => setFavoritesCollapsed((prev) => !prev),
null,
'favorites',
favoritesUnreadCount,
favoritesHasMention
)}
@@ -844,6 +945,21 @@ export function Sidebar({
</>
)}
{/* Room Servers */}
{nonFavoriteRooms.length > 0 && (
<>
{renderSectionHeader(
'Room Servers',
roomsCollapsed,
() => setRoomsCollapsed((prev) => !prev),
'rooms',
roomsUnreadCount,
roomsUnreadCount > 0
)}
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
</>
)}
{/* Repeaters */}
{nonFavoriteRepeaters.length > 0 && (
<>
@@ -861,6 +977,7 @@ export function Sidebar({
{/* Empty state */}
{nonFavoriteContacts.length === 0 &&
nonFavoriteRooms.length === 0 &&
nonFavoriteChannels.length === 0 &&
nonFavoriteRepeaters.length === 0 &&
favoriteItems.length === 0 && (

View File

@@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react';
import { cn } from '@/lib/utils';
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import type {
Contact,
RepeaterNeighborsResponse,
@@ -35,6 +36,7 @@ export function NeighborsPane({
nodeInfoState: PaneState;
repeaterName: string | null;
}) {
const { distanceUnit } = useDistanceUnit();
const advertLat = repeaterContact?.lat ?? null;
const advertLon = repeaterContact?.lon ?? null;
@@ -93,7 +95,7 @@ export function NeighborsPane({
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
dist = formatDistance(distKm, distanceUnit);
anyDist = true;
}
}
@@ -111,7 +113,7 @@ export function NeighborsPane({
sorted: enriched,
hasDistances: anyDist,
};
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
return (
<RepeaterPane

View File

@@ -1,10 +1,17 @@
import type { HealthStatus } from '../../types';
import { Separator } from '../ui/separator';
const GITHUB_URL = 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore';
export function SettingsAboutSection({ className }: { className?: string }) {
const version = __APP_VERSION__;
const commit = __COMMIT_HASH__;
export function SettingsAboutSection({
health,
className,
}: {
health?: HealthStatus | null;
className?: string;
}) {
const version = health?.app_info?.version ?? 'unknown';
const commit = health?.app_info?.commit_hash;
return (
<div className={className}>
@@ -14,8 +21,14 @@ export function SettingsAboutSection({ className }: { className?: string }) {
<h3 className="text-lg font-semibold">RemoteTerm for MeshCore</h3>
<div className="text-sm text-muted-foreground">
v{version}
<span className="mx-1.5">·</span>
<span className="font-mono text-xs">{commit}</span>
{commit ? (
<>
<span className="mx-1.5">·</span>
<span className="font-mono text-xs" title={commit}>
{commit}
</span>
</>
) : null}
</div>
</div>

View File

@@ -173,7 +173,8 @@ export function SettingsDatabaseSection({
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality.
This will not affect any displayed messages or app functionality, nor impact your
ability to do historical decryption.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>

View File

@@ -14,16 +14,16 @@ const BotCodeEditor = lazy(() =>
const TYPE_LABELS: Record<string, string> = {
mqtt_private: 'Private MQTT',
mqtt_community: 'meshcoretomqtt/LetsMesh/MeshRank',
mqtt_community: 'Community MQTT',
bot: 'Bot',
webhook: 'Webhook',
apprise: 'Apprise',
sqs: 'Amazon SQS',
};
const TYPE_OPTIONS = [
const LIST_TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'meshcoretomqtt/LetsMesh/MeshRank' },
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
@@ -32,9 +32,313 @@ const TYPE_OPTIONS = [
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
const DEFAULT_COMMUNITY_BROKER_PORT = 443;
const DEFAULT_COMMUNITY_TRANSPORT = 'websockets';
const DEFAULT_COMMUNITY_AUTH_MODE = 'token';
const DEFAULT_MESHRANK_BROKER_HOST = 'meshrank.net';
const DEFAULT_MESHRANK_BROKER_PORT = 8883;
const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
const DEFAULT_MESHRANK_IATA = 'XYZ';
const CREATE_TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
{ value: 'sqs', label: 'Amazon SQS' },
] as const;
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
type DraftRecipe = {
savedType: string;
detailLabel: string;
defaultName: string;
defaults: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
};
};
function createCommunityConfigDefaults(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
return {
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
transport: DEFAULT_COMMUNITY_TRANSPORT,
use_tls: true,
tls_verify: true,
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
username: '',
password: '',
iata: '',
email: '',
token_audience: '',
topic_template: DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
...overrides,
};
}
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
kwargs keys currently provided:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
channel_name = kwargs.get("channel_name")
is_outgoing = kwargs.get("is_outgoing", False)
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
# Don't reply to our own outgoing messages
if is_outgoing:
return None
# Example: Only respond in #bot channel to "!pling" command
if channel_name == "#bot" and "!pling" in message_text.lower():
return "[BOT] Plong!"
return None`;
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
mqtt_private: {
savedType: 'mqtt_private',
detailLabel: 'Private MQTT',
defaultName: 'Private MQTT',
defaults: {
config: {
broker_host: '',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
},
},
mqtt_community_meshrank: {
savedType: 'mqtt_community',
detailLabel: 'MeshRank',
defaultName: 'MeshRank',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
transport: DEFAULT_MESHRANK_TRANSPORT,
auth_mode: DEFAULT_MESHRANK_AUTH_MODE,
iata: DEFAULT_MESHRANK_IATA,
email: '',
token_audience: '',
topic_template: '',
}),
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community_letsmesh_us: {
savedType: 'mqtt_community',
detailLabel: 'LetsMesh (US)',
defaultName: 'LetsMesh (US)',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
token_audience: DEFAULT_COMMUNITY_BROKER_HOST,
}),
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community_letsmesh_eu: {
savedType: 'mqtt_community',
detailLabel: 'LetsMesh (EU)',
defaultName: 'LetsMesh (EU)',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
token_audience: DEFAULT_COMMUNITY_BROKER_HOST_EU,
}),
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community: {
savedType: 'mqtt_community',
detailLabel: 'Community MQTT/meshcoretomqtt',
defaultName: 'Community MQTT',
defaults: {
config: createCommunityConfigDefaults(),
scope: { messages: 'none', raw_packets: 'all' },
},
},
bot: {
savedType: 'bot',
detailLabel: 'Bot',
defaultName: 'Bot',
defaults: {
config: {
code: DEFAULT_BOT_CODE,
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
webhook: {
savedType: 'webhook',
detailLabel: 'Webhook',
defaultName: 'Webhook',
defaults: {
config: {
url: '',
method: 'POST',
headers: {},
hmac_secret: '',
hmac_header: '',
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
apprise: {
savedType: 'apprise',
detailLabel: 'Apprise',
defaultName: 'Apprise',
defaults: {
config: {
urls: '',
preserve_identity: true,
include_path: true,
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
sqs: {
savedType: 'sqs',
detailLabel: 'Amazon SQS',
defaultName: 'Amazon SQS',
defaults: {
config: {
queue_url: '',
region_name: '',
endpoint_url: '',
access_key_id: '',
secret_access_key: '',
session_token: '',
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
};
function isDraftType(value: string): value is DraftType {
return value in DRAFT_RECIPES;
}
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
const recipe = DRAFT_RECIPES[draftType];
return name || getDefaultIntegrationName(recipe.savedType, configs);
}
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
if (draftType === 'mqtt_community_meshrank') {
const topicTemplate = String(config.topic_template || '').trim();
if (!topicTemplate) {
throw new Error('MeshRank packet topic is required');
}
return {
...config,
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
transport: DEFAULT_MESHRANK_TRANSPORT,
auth_mode: DEFAULT_MESHRANK_AUTH_MODE,
use_tls: true,
tls_verify: true,
iata: DEFAULT_MESHRANK_IATA,
email: '',
token_audience: '',
topic_template: topicTemplate,
username: '',
password: '',
};
}
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
const brokerHost =
draftType === 'mqtt_community_letsmesh_eu'
? DEFAULT_COMMUNITY_BROKER_HOST_EU
: DEFAULT_COMMUNITY_BROKER_HOST;
return {
...config,
broker_host: brokerHost,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
transport: DEFAULT_COMMUNITY_TRANSPORT,
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
use_tls: true,
tls_verify: true,
token_audience: brokerHost,
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
username: '',
password: '',
};
}
return config;
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
if (draftType.startsWith('mqtt_community_')) {
return { messages: 'none', raw_packets: 'all' };
}
return scope;
}
function cloneDraftDefaults(draftType: DraftType) {
const recipe = DRAFT_RECIPES[draftType];
return {
config: structuredClone(recipe.defaults.config),
scope: structuredClone(recipe.defaults.scope),
};
}
function getDetailTypeLabel(detailType: string) {
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
return TYPE_LABELS[detailType] || detailType;
}
function fanoutDraftHasUnsavedChanges(
original: FanoutConfig | null,
current: {
name: string;
config: Record<string, unknown>;
scope: Record<string, unknown>;
}
) {
if (!original) return false;
return (
current.name !== original.name ||
JSON.stringify(current.config) !== JSON.stringify(original.config) ||
JSON.stringify(current.scope) !== JSON.stringify(original.scope)
);
}
function formatBrokerSummary(
config: Record<string, unknown>,
@@ -74,42 +378,6 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
return `${label} #${nextIndex}`;
}
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
kwargs keys currently provided:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
channel_name = kwargs.get("channel_name")
is_outgoing = kwargs.get("is_outgoing", False)
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
# Don't reply to our own outgoing messages
if is_outgoing:
return None
# Example: Only respond in #bot channel to "!pling" command
if channel_name == "#bot" and "!pling" in message_text.lower():
return "[BOT] Plong!"
return None`;
function getStatusLabel(status: string | undefined, type?: string) {
if (status === 'connected')
return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected';
@@ -251,8 +519,9 @@ function MqttCommunityConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Share raw packet data with the MeshCore community for coverage mapping and network analysis.
Only raw RF packets are shared &mdash; never decrypted messages.
Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for
modifying a saved preset after creation. Only raw RF packets are shared &mdash; never
decrypted messages.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -424,6 +693,110 @@ function MqttCommunityConfigEditor({
);
}
function MeshRankConfigEditor({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created,
but only asks for the MeshRank packet topic you were given.
</p>
<div className="rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Broker <code>{DEFAULT_MESHRANK_BROKER_HOST}</code> on port{' '}
<code>{DEFAULT_MESHRANK_BROKER_PORT}</code> via <code>{DEFAULT_MESHRANK_TRANSPORT}</code>,
auth <code>{DEFAULT_MESHRANK_AUTH_MODE}</code>, TLS on, certificate verification on, region
code fixed to <code>{DEFAULT_MESHRANK_IATA}</code>.
</div>
<div className="space-y-2">
<Label htmlFor="fanout-meshrank-topic-template">Packet Topic Template</Label>
<Input
id="fanout-meshrank-topic-template"
type="text"
placeholder="meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets"
value={(config.topic_template as string) || ''}
onChange={(e) =>
onChange({
...config,
iata: DEFAULT_MESHRANK_IATA,
topic_template: e.target.value,
})
}
/>
<p className="text-xs text-muted-foreground">
Paste the full topic template from your MeshRank config, for example{' '}
<code>meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets</code>.
</p>
</div>
</div>
);
}
function LetsMeshConfigEditor({
config,
onChange,
brokerHost,
}: {
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
brokerHost: string;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created,
but only asks for the values LetsMesh expects from you.
</p>
<div className="rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Broker <code>{brokerHost}</code> on port <code>{DEFAULT_COMMUNITY_BROKER_PORT}</code> via{' '}
<code>{DEFAULT_COMMUNITY_TRANSPORT}</code>, auth <code>{DEFAULT_COMMUNITY_AUTH_MODE}</code>,
TLS on, certificate verification on, token audience fixed to <code>{brokerHost}</code>.
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fanout-letsmesh-email">Email</Label>
<Input
id="fanout-letsmesh-email"
type="email"
placeholder="you@example.com"
value={(config.email as string) || ''}
onChange={(e) =>
onChange({ ...config, email: e.target.value, broker_host: brokerHost })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-letsmesh-iata">Region Code (IATA)</Label>
<Input
id="fanout-letsmesh-iata"
type="text"
maxLength={3}
placeholder="e.g. DEN, LAX, NYC"
value={(config.iata as string) || ''}
onChange={(e) =>
onChange({
...config,
broker_host: brokerHost,
token_audience: brokerHost,
iata: e.target.value.toUpperCase(),
})
}
className="w-32"
/>
</div>
</div>
</div>
);
}
function BotConfigEditor({
config,
onChange,
@@ -1120,7 +1493,7 @@ export function SettingsFanoutSection({
}) {
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftType, setDraftType] = useState<string | null>(null);
const [draftType, setDraftType] = useState<DraftType | null>(null);
const [editConfig, setEditConfig] = useState<Record<string, unknown>>({});
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
const [editName, setEditName] = useState('');
@@ -1190,7 +1563,17 @@ export function SettingsFanoutSection({
};
const handleBackToList = () => {
if (!confirm('Leave without saving?')) return;
const shouldConfirm =
draftType !== null ||
fanoutDraftHasUnsavedChanges(
editingId ? (configs.find((c) => c.id === editingId) ?? null) : null,
{
name: editName,
config: editConfig,
scope: editScope,
}
);
if (shouldConfirm && !confirm('Leave without saving?')) return;
setEditingId(null);
setDraftType(null);
};
@@ -1228,11 +1611,12 @@ export function SettingsFanoutSection({
setBusy(true);
try {
if (currentDraftType) {
const recipe = DRAFT_RECIPES[currentDraftType];
await api.createFanoutConfig({
type: currentDraftType,
name: editName,
config: editConfig,
scope: editScope,
type: recipe.savedType,
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
config: normalizeDraftConfig(currentDraftType, editConfig),
scope: normalizeDraftScope(currentDraftType, editScope),
enabled: enabled ?? true,
});
} else {
@@ -1280,74 +1664,26 @@ export function SettingsFanoutSection({
};
const handleAddCreate = async (type: string) => {
const defaults: Record<string, Record<string, unknown>> = {
mqtt_private: {
broker_host: '',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
mqtt_community: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
transport: DEFAULT_COMMUNITY_TRANSPORT,
use_tls: true,
tls_verify: true,
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
username: '',
password: '',
iata: '',
email: '',
token_audience: '',
topic_template: DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
},
bot: {
code: DEFAULT_BOT_CODE,
},
webhook: {
url: '',
method: 'POST',
headers: {},
hmac_secret: '',
hmac_header: '',
},
apprise: {
urls: '',
preserve_identity: true,
include_path: true,
},
sqs: {
queue_url: '',
region_name: '',
endpoint_url: '',
access_key_id: '',
secret_access_key: '',
session_token: '',
},
};
const defaultScopes: Record<string, Record<string, unknown>> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
bot: { messages: 'all', raw_packets: 'none' },
webhook: { messages: 'all', raw_packets: 'none' },
apprise: { messages: 'all', raw_packets: 'none' },
sqs: { messages: 'all', raw_packets: 'none' },
};
if (!isDraftType(type)) return;
const defaults = cloneDraftDefaults(type);
setAddMenuOpen(false);
setEditingId(null);
setDraftType(type);
setEditName(getDefaultIntegrationName(type, configs));
setEditConfig(defaults[type] || {});
setEditScope(defaultScopes[type] || {});
setEditName(
type === 'mqtt_community_meshrank' ||
type === 'mqtt_community_letsmesh_us' ||
type === 'mqtt_community_letsmesh_eu'
? DRAFT_RECIPES[type].defaultName
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
);
setEditConfig(defaults.config);
setEditScope(defaults.scope);
};
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
const detailType = draftType ?? editingConfig?.type ?? null;
const isDraft = draftType !== null;
const configGroups = TYPE_OPTIONS.map((opt) => ({
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
type: opt.value,
label: opt.label,
configs: configs
@@ -1377,9 +1713,7 @@ export function SettingsFanoutSection({
/>
</div>
<div className="text-xs text-muted-foreground">
Type: {TYPE_LABELS[detailType] || detailType}
</div>
<div className="text-xs text-muted-foreground">Type: {getDetailTypeLabel(detailType)}</div>
<Separator />
@@ -1396,6 +1730,26 @@ export function SettingsFanoutSection({
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{detailType === 'mqtt_community_meshrank' && (
<MeshRankConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{detailType === 'mqtt_community_letsmesh_us' && (
<LetsMeshConfigEditor
config={editConfig}
onChange={setEditConfig}
brokerHost={DEFAULT_COMMUNITY_BROKER_HOST}
/>
)}
{detailType === 'mqtt_community_letsmesh_eu' && (
<LetsMeshConfigEditor
config={editConfig}
onChange={setEditConfig}
brokerHost={DEFAULT_COMMUNITY_BROKER_HOST_EU}
/>
)}
{detailType === 'bot' && <BotConfigEditor config={editConfig} onChange={setEditConfig} />}
{detailType === 'apprise' && (
@@ -1481,9 +1835,9 @@ export function SettingsFanoutSection({
{addMenuOpen && (
<div
role="menu"
className="absolute left-0 top-full z-10 mt-2 min-w-56 rounded-md border border-input bg-background p-1 shadow-md"
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
>
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
(opt) => (
<button
key={opt.value}

View File

@@ -11,6 +11,12 @@ import {
} from '../../utils/lastViewedConversation';
import { ThemeSelector } from './ThemeSelector';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
import {
DISTANCE_UNIT_LABELS,
DISTANCE_UNITS,
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -19,6 +25,7 @@ export function SettingsLocalSection({
onLocalLabelChange?: (label: LocalLabel) => void;
className?: string;
}) {
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
@@ -82,6 +89,31 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
id="distance-units"
value={distanceUnit}
onChange={(event) => {
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
setSavedDistanceUnit(nextUnit);
setDistanceUnit(nextUnit);
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{DISTANCE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{DISTANCE_UNIT_LABELS[unit]}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Controls how distances are shown throughout the app.
</p>
</div>
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -5,12 +5,14 @@ import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { Checkbox } from '../ui/checkbox';
import { RADIO_PRESETS } from '../../utils/radioPresets';
import { stripRegionScopePrefix } from '../../utils/regionScope';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -45,7 +47,7 @@ export function SettingsRadioSection({
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -63,6 +65,7 @@ export function SettingsRadioSection({
const [cr, setCr] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
@@ -82,7 +85,7 @@ export function SettingsRadioSection({
const [floodError, setFloodError] = useState<string | null>(null);
// Advertise state
const [advertising, setAdvertising] = useState(false);
const [advertisingMode, setAdvertisingMode] = useState<RadioAdvertMode | null>(null);
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [connectionBusy, setConnectionBusy] = useState(false);
@@ -97,6 +100,7 @@ export function SettingsRadioSection({
setCr(String(config.radio.cr));
setPathHashMode(String(config.path_hash_mode));
setAdvertLocationSource(config.advert_location_source ?? 'current');
setMultiAcksEnabled(config.multi_acks_enabled ?? false);
}, [config]);
useEffect(() => {
@@ -189,6 +193,9 @@ export function SettingsRadioSection({
...(advertLocationSource !== (config.advert_location_source ?? 'current')
? { advert_location_source: advertLocationSource }
: {}),
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
? { multi_acks_enabled: multiAcksEnabled }
: {}),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -295,12 +302,12 @@ export function SettingsRadioSection({
}
};
const handleAdvertise = async () => {
setAdvertising(true);
const handleAdvertise = async (mode: RadioAdvertMode) => {
setAdvertisingMode(mode);
try {
await onAdvertise();
await onAdvertise(mode);
} finally {
setAdvertising(false);
setAdvertisingMode(null);
}
};
@@ -578,6 +585,24 @@ export function SettingsRadioSection({
library.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div>
{config.path_hash_mode_supported && (
@@ -742,15 +767,25 @@ export function SettingsRadioSection({
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { DistanceUnit } from '../utils/distanceUnits';
interface DistanceUnitContextValue {
distanceUnit: DistanceUnit;
setDistanceUnit: (unit: DistanceUnit) => void;
}
const noop = () => {};
const DistanceUnitContext = createContext<DistanceUnitContextValue>({
distanceUnit: 'metric',
setDistanceUnit: noop,
});
export function DistanceUnitProvider({
distanceUnit,
setDistanceUnit,
children,
}: DistanceUnitContextValue & { children: ReactNode }) {
return (
<DistanceUnitContext.Provider value={{ distanceUnit, setDistanceUnit }}>
{children}
</DistanceUnitContext.Provider>
);
}
export function useDistanceUnit() {
return useContext(DistanceUnitContext);
}

View File

@@ -1,5 +1,5 @@
export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useConversationMessages } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';
export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppShell } from './useAppShell';
@@ -10,3 +10,4 @@ export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';
export { useRawPacketStatsSession } from './useRawPacketStatsSession';

View File

@@ -1,7 +1,9 @@
import { startTransition, useCallback, useState } from 'react';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
interface UseAppShellResult {
showNewMessage: boolean;
@@ -11,10 +13,12 @@ interface UseAppShellResult {
showCracker: boolean;
crackerRunning: boolean;
localLabel: LocalLabel;
distanceUnit: DistanceUnit;
setSettingsSection: (section: SettingsSection) => void;
setSidebarOpen: (open: boolean) => void;
setCrackerRunning: (running: boolean) => void;
setLocalLabel: (label: LocalLabel) => void;
setDistanceUnit: (unit: DistanceUnit) => void;
handleCloseSettingsView: () => void;
handleToggleSettingsView: () => void;
handleOpenNewMessage: () => void;
@@ -23,25 +27,48 @@ interface UseAppShellResult {
}
export function useAppShell(): UseAppShellResult {
const initialSettingsSection = typeof window === 'undefined' ? null : parseHashSettingsSection();
const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
const [showSettings, setShowSettings] = useState(() => initialSettingsSection !== null);
const [settingsSection, setSettingsSection] = useState<SettingsSection>(
() => initialSettingsSection ?? 'radio'
);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
const previousHashRef = useRef('');
useEffect(() => {
if (showSettings) {
updateSettingsHash(settingsSection);
}
}, [settingsSection, showSettings]);
const handleCloseSettingsView = useCallback(() => {
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
}
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
}, []);
const handleToggleSettingsView = useCallback(() => {
if (showSettings) {
handleCloseSettingsView();
return;
}
if (typeof window !== 'undefined') {
previousHashRef.current =
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
}
startTransition(() => {
setShowSettings((prev) => !prev);
setShowSettings(true);
});
setSidebarOpen(false);
}, []);
}, [handleCloseSettingsView, showSettings]);
const handleOpenNewMessage = useCallback(() => {
setShowNewMessage(true);
@@ -64,10 +91,12 @@ export function useAppShell(): UseAppShellResult {
showCracker,
crackerRunning,
localLabel,
distanceUnit,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,

View File

@@ -2,7 +2,6 @@ import { useState, useCallback, type MutableRefObject } from 'react';
import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import * as messageCache from '../messageCache';
import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { Channel, Contact, Conversation } from '../types';
@@ -11,12 +10,14 @@ interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>;
removeConversationMessages: (conversationId: string) => void;
}
export function useContactsAndChannels({
setActiveConversation,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
removeConversationMessages,
}: UseContactsAndChannelsArgs) {
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactsLoaded, setContactsLoaded] = useState(false);
@@ -117,7 +118,7 @@ export function useContactsAndChannels({
try {
pendingDeleteFallbackRef.current = true;
await api.deleteChannel(key);
messageCache.remove(key);
removeConversationMessages(key);
const refreshedChannels = await api.getChannels();
setChannels(refreshedChannels);
const publicChannel = findPublicChannel(refreshedChannels);
@@ -135,7 +136,12 @@ export function useContactsAndChannels({
});
}
},
[setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation]
[
hasSetDefaultConversation,
pendingDeleteFallbackRef,
removeConversationMessages,
setActiveConversation,
]
);
const handleDeleteContact = useCallback(
@@ -144,7 +150,7 @@ export function useContactsAndChannels({
try {
pendingDeleteFallbackRef.current = true;
await api.deleteContact(publicKey);
messageCache.remove(publicKey);
removeConversationMessages(publicKey);
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
const refreshedChannels = await api.getChannels();
setChannels(refreshedChannels);
@@ -163,7 +169,12 @@ export function useContactsAndChannels({
});
}
},
[setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation]
[
hasSetDefaultConversation,
pendingDeleteFallbackRef,
removeConversationMessages,
setActiveConversation,
]
);
return {

View File

@@ -10,7 +10,7 @@ interface UseConversationActionsArgs {
activeConversationRef: MutableRefObject<Conversation | null>;
setContacts: React.Dispatch<React.SetStateAction<Contact[]>>;
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
addMessageIfNew: (msg: Message) => boolean;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
messageInputRef: RefObject<MessageInputHandle | null>;
}
@@ -31,7 +31,7 @@ export function useConversationActions({
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
observeMessage,
messageInputRef,
}: UseConversationActionsArgs): UseConversationActionsResult {
const mergeChannelIntoList = useCallback(
@@ -60,10 +60,10 @@ export function useConversationActions({
: await api.sendDirectMessage(activeConversation.id, text);
if (activeConversationRef.current?.id === conversationId) {
addMessageIfNew(sent);
observeMessage(sent);
}
},
[activeConversation, activeConversationRef, addMessageIfNew]
[activeConversation, activeConversationRef, observeMessage]
);
const handleResendChannelMessage = useCallback(
@@ -77,7 +77,7 @@ export function useConversationActions({
activeConversationRef.current?.type === 'channel' &&
activeConversationRef.current.id === resentMessage.conversation_key
) {
addMessageIfNew(resentMessage);
observeMessage(resentMessage);
}
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
} catch (err) {
@@ -86,7 +86,7 @@ export function useConversationActions({
});
}
},
[activeConversationRef, addMessageIfNew]
[activeConversationRef, observeMessage]
);
const handleSetChannelFloodScopeOverride = useCallback(

View File

@@ -1,19 +1,181 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from '../components/ui/sonner';
import { api, isAbortError } from '../api';
import * as messageCache from '../messageCache';
import type { Conversation, Message, MessagePath } from '../types';
import { getMessageContentKey } from '../utils/messageIdentity';
const MAX_PENDING_ACKS = 500;
const MESSAGE_PAGE_SIZE = 200;
export const MAX_CACHED_CONVERSATIONS = 20;
export const MAX_MESSAGES_PER_ENTRY = 200;
interface CachedConversationEntry {
messages: Message[];
hasOlderMessages: boolean;
}
interface InternalCachedConversationEntry extends CachedConversationEntry {
contentKeys: Set<string>;
}
export class ConversationMessageCache {
private readonly cache = new Map<string, InternalCachedConversationEntry>();
private normalizeEntry(entry: CachedConversationEntry): InternalCachedConversationEntry {
let messages = entry.messages;
let hasOlderMessages = entry.hasOlderMessages;
if (messages.length > MAX_MESSAGES_PER_ENTRY) {
messages = [...messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
hasOlderMessages = true;
}
return {
messages,
hasOlderMessages,
contentKeys: new Set(messages.map((message) => getMessageContentKey(message))),
};
}
get(id: string): CachedConversationEntry | undefined {
const entry = this.cache.get(id);
if (!entry) return undefined;
this.cache.delete(id);
this.cache.set(id, entry);
return {
messages: entry.messages,
hasOlderMessages: entry.hasOlderMessages,
};
}
set(id: string, entry: CachedConversationEntry): void {
const internalEntry = this.normalizeEntry(entry);
this.cache.delete(id);
this.cache.set(id, internalEntry);
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = this.cache.keys().next().value as string;
this.cache.delete(lruKey);
}
}
addMessage(id: string, msg: Message): boolean {
const entry = this.cache.get(id);
const contentKey = getMessageContentKey(msg);
if (!entry) {
this.cache.set(id, {
messages: [msg],
hasOlderMessages: true,
contentKeys: new Set([contentKey]),
});
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = this.cache.keys().next().value as string;
this.cache.delete(lruKey);
}
return true;
}
if (entry.contentKeys.has(contentKey)) return false;
if (entry.messages.some((message) => message.id === msg.id)) return false;
const nextEntry = this.normalizeEntry({
messages: [...entry.messages, msg],
hasOlderMessages: entry.hasOlderMessages,
});
this.cache.delete(id);
this.cache.set(id, nextEntry);
return true;
}
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
for (const entry of this.cache.values()) {
const index = entry.messages.findIndex((message) => message.id === messageId);
if (index < 0) continue;
const current = entry.messages[index];
const updated = [...entry.messages];
updated[index] = {
...current,
acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
};
entry.messages = updated;
return;
}
}
remove(id: string): void {
this.cache.delete(id);
}
rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = this.cache.get(oldId);
if (!oldEntry) return;
const newEntry = this.cache.get(newId);
if (!newEntry) {
this.cache.delete(oldId);
this.cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
this.cache.delete(oldId);
this.cache.set(
newId,
this.normalizeEntry({
messages: mergedMessages,
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
})
);
}
clear(): void {
this.cache.clear();
}
}
export function reconcileConversationMessages(
current: Message[],
fetched: Message[]
): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
for (const message of current) {
currentById.set(message.id, {
acked: message.acked,
pathsLen: message.paths?.length ?? 0,
text: message.text,
});
}
let needsUpdate = false;
for (const message of fetched) {
const currentMessage = currentById.get(message.id);
if (
!currentMessage ||
currentMessage.acked !== message.acked ||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
currentMessage.text !== message.text
) {
needsUpdate = true;
break;
}
}
if (!needsUpdate) return null;
const fetchedIds = new Set(fetched.map((message) => message.id));
const olderMessages = current.filter((message) => !fetchedIds.has(message.id));
return [...fetched, ...olderMessages];
}
export const conversationMessageCache = new ConversationMessageCache();
interface PendingAckUpdate {
ackCount: number;
@@ -56,15 +218,6 @@ export function mergePendingAck(
return existing;
}
// Generate a key for deduplicating messages by content
export function getMessageContentKey(msg: Message): string {
// When sender_timestamp exists, dedup by content (catches radio-path duplicates with different IDs).
// When null, include msg.id so each message gets a unique key — avoids silently dropping
// different messages that share the same text and received_at second.
const ts = msg.sender_timestamp ?? `r${msg.received_at}-${msg.id}`;
return `${msg.type}-${msg.conversation_key}-${msg.text}-${ts}`;
}
interface UseConversationMessagesResult {
messages: Message[];
messagesLoading: boolean;
@@ -72,21 +225,36 @@ interface UseConversationMessagesResult {
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: MutableRefObject<boolean>;
setMessages: Dispatch<SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
reloadCurrentConversation: () => void;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
triggerReconcile: () => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
reconcileOnReconnect: () => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
clearConversationMessages: () => void;
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
}
function isActiveConversationMessage(
activeConversation: Conversation | null,
msg: Message
): boolean {
if (!activeConversation) return false;
if (msg.type === 'CHAN' && activeConversation.type === 'channel') {
return msg.conversation_key === activeConversation.id;
}
if (msg.type === 'PRIV' && activeConversation.type === 'contact') {
return msg.conversation_key === activeConversation.id;
}
return false;
}
function appendUniqueMessages(current: Message[], incoming: Message[]): Message[] {
if (incoming.length === 0) return current;
@@ -165,8 +333,10 @@ export function useConversationMessages(
const newerAbortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const latestReconcileRequestIdRef = useRef(0);
const pendingReconnectReconcileRef = useRef(false);
const messagesRef = useRef<Message[]>([]);
const loadingOlderRef = useRef(false);
const loadingNewerRef = useRef(false);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
const prevConversationIdRef = useRef<string | null>(null);
@@ -181,6 +351,10 @@ export function useConversationMessages(
loadingOlderRef.current = loadingOlder;
}, [loadingOlder]);
useEffect(() => {
loadingNewerRef.current = loadingNewer;
}, [loadingNewer]);
useEffect(() => {
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
@@ -208,6 +382,7 @@ export function useConversationMessages(
}
const conversationId = activeConversation.id;
pendingReconnectReconcileRef.current = false;
if (showLoading) {
setMessagesLoading(true);
@@ -229,7 +404,7 @@ export function useConversationMessages(
}
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
const merged = reconcileConversationMessages(messagesRef.current, messagesWithPendingAck);
const nextMessages = merged ?? messagesRef.current;
if (merged) {
setMessages(merged);
@@ -271,7 +446,7 @@ export function useConversationMessages(
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
const merged = reconcileConversationMessages(messagesRef.current, dataWithPendingAck);
if (!merged) return;
setMessages(merged);
@@ -295,7 +470,7 @@ export function useConversationMessages(
}
const conversationId = activeConversation.id;
const oldestMessage = messages.reduce(
const oldestMessage = messagesRef.current.reduce(
(oldest, msg) => {
if (!oldest) return msg;
if (msg.received_at < oldest.received_at) return msg;
@@ -356,13 +531,19 @@ export function useConversationMessages(
loadingOlderRef.current = false;
setLoadingOlder(false);
}
}, [activeConversation, applyPendingAck, messages, syncSeenContent]);
}, [activeConversation, applyPendingAck, syncSeenContent]);
const fetchNewerMessages = useCallback(async () => {
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
if (
!isMessageConversation(activeConversation) ||
loadingNewerRef.current ||
!hasNewerMessagesRef.current
) {
return;
}
const conversationId = activeConversation.id;
const newestMessage = messages.reduce(
const newestMessage = messagesRef.current.reduce(
(newest, msg) => {
if (!newest) return msg;
if (msg.received_at > newest.received_at) return msg;
@@ -373,6 +554,7 @@ export function useConversationMessages(
);
if (!newestMessage) return;
loadingNewerRef.current = true;
setLoadingNewer(true);
const controller = new AbortController();
newerAbortControllerRef.current = controller;
@@ -401,7 +583,15 @@ export function useConversationMessages(
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
const stillHasNewerMessages = dataWithPendingAck.length >= MESSAGE_PAGE_SIZE;
setHasNewerMessages(stillHasNewerMessages);
if (!stillHasNewerMessages && pendingReconnectReconcileRef.current) {
pendingReconnectReconcileRef.current = false;
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
const reconcileController = new AbortController();
reconcileFromBackend(activeConversation, reconcileController.signal, requestId);
}
} catch (err) {
if (isAbortError(err)) {
return;
@@ -414,26 +604,36 @@ export function useConversationMessages(
if (newerAbortControllerRef.current === controller) {
newerAbortControllerRef.current = null;
}
loadingNewerRef.current = false;
setLoadingNewer(false);
}
}, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]);
}, [activeConversation, applyPendingAck, reconcileFromBackend]);
const jumpToBottom = useCallback(() => {
if (!activeConversation) return;
setHasNewerMessages(false);
messageCache.remove(activeConversation.id);
conversationMessageCache.remove(activeConversation.id);
void fetchLatestMessages(true);
}, [activeConversation, fetchLatestMessages]);
const reloadCurrentConversation = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
setHasNewerMessages(false);
messageCache.remove(activeConversation.id);
conversationMessageCache.remove(activeConversation.id);
setReloadVersion((current) => current + 1);
}, [activeConversation]);
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const reconcileOnReconnect = useCallback(() => {
if (!isMessageConversation(activeConversation)) {
return;
}
if (hasNewerMessagesRef.current) {
pendingReconnectReconcileRef.current = true;
return;
}
pendingReconnectReconcileRef.current = false;
const controller = new AbortController();
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
@@ -461,6 +661,7 @@ export function useConversationMessages(
prevConversationIdRef.current = newId;
prevReloadVersionRef.current = reloadVersion;
latestReconcileRequestIdRef.current = 0;
pendingReconnectReconcileRef.current = false;
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
if (!conversationChanged && !targetMessageId && !reloadRequested) {
@@ -480,9 +681,8 @@ export function useConversationMessages(
messagesRef.current.length > 0 &&
!hasNewerMessagesRef.current
) {
messageCache.set(prevId, {
conversationMessageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContent.current),
hasOlderMessages: hasOlderMessagesRef.current,
});
}
@@ -524,10 +724,12 @@ export function useConversationMessages(
setMessagesLoading(false);
});
} else {
const cached = messageCache.get(activeConversation.id);
const cached = conversationMessageCache.get(activeConversation.id);
if (cached) {
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
seenMessageContent.current = new Set(
cached.messages.map((message) => getMessageContentKey(message))
);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
const requestId = latestReconcileRequestIdRef.current + 1;
@@ -544,9 +746,8 @@ export function useConversationMessages(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type, targetMessageId, reloadVersion]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
const addMessageIfNew = useCallback(
// Add a message to the active conversation if it is new.
const appendActiveMessageIfNew = useCallback(
(msg: Message): boolean => {
const msgWithPendingAck = applyPendingAck(msg);
const contentKey = getMessageContentKey(msgWithPendingAck);
@@ -616,6 +817,56 @@ export function useConversationMessages(
[messagesRef, setMessages, setPendingAck]
);
const receiveMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
conversationMessageCache.updateAck(messageId, ackCount, paths);
},
[updateMessageAck]
);
const observeMessage = useCallback(
(msg: Message): { added: boolean; activeConversation: boolean } => {
const msgWithPendingAck = applyPendingAck(msg);
const activeConversationMessage = isActiveConversationMessage(
activeConversation,
msgWithPendingAck
);
if (activeConversationMessage) {
if (hasNewerMessagesRef.current) {
return { added: false, activeConversation: true };
}
return {
added: appendActiveMessageIfNew(msgWithPendingAck),
activeConversation: true,
};
}
return {
added: conversationMessageCache.addMessage(
msgWithPendingAck.conversation_key,
msgWithPendingAck
),
activeConversation: false,
};
},
[activeConversation, appendActiveMessageIfNew, applyPendingAck, hasNewerMessagesRef]
);
const renameConversationMessages = useCallback((oldId: string, newId: string) => {
conversationMessageCache.rename(oldId, newId);
}, []);
const removeConversationMessages = useCallback((conversationId: string) => {
conversationMessageCache.remove(conversationId);
}, []);
const clearConversationMessages = useCallback(() => {
conversationMessageCache.clear();
}, []);
return {
messages,
messagesLoading,
@@ -623,14 +874,15 @@ export function useConversationMessages(
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
reloadCurrentConversation,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
observeMessage,
receiveMessageAck,
reconcileOnReconnect,
renameConversationMessages,
removeConversationMessages,
clearConversationMessages,
};
}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import {
parseHashConversation,
parseHashSettingsSection,
updateUrlHash,
resolveChannelFromHashToken,
resolveContactFromHashToken,
@@ -18,6 +19,7 @@ interface UseConversationRouterArgs {
channels: Channel[];
contacts: Contact[];
contactsLoaded: boolean;
suspendHashSync: boolean;
setSidebarOpen: (open: boolean) => void;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>;
@@ -27,6 +29,7 @@ export function useConversationRouter({
channels,
contacts,
contactsLoaded,
suspendHashSync,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -34,7 +37,9 @@ export function useConversationRouter({
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const hashSyncEnabledRef = useRef(
typeof window !== 'undefined' ? window.location.hash.length > 0 : false
typeof window !== 'undefined'
? window.location.hash.length > 0 && parseHashSettingsSection() === null
: false
);
const setActiveConversation = useCallback((conv: Conversation | null) => {
@@ -58,7 +63,7 @@ export function useConversationRouter({
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashConversation();
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
// Handle non-data views immediately
if (hashConv?.type === 'raw') {
@@ -141,7 +146,7 @@ export function useConversationRouter({
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
const hashConv = parseHashConversation();
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
if (hashConv?.type === 'contact') {
if (!contactsLoaded) return;
@@ -203,14 +208,14 @@ export function useConversationRouter({
useEffect(() => {
activeConversationRef.current = activeConversation;
if (activeConversation) {
if (hashSyncEnabledRef.current) {
if (hashSyncEnabledRef.current && !suspendHashSync) {
updateUrlHash(activeConversation);
}
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
if (activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}
}, [activeConversation]);
}, [activeConversation, suspendHashSync]);
// If a delete action left us without an active conversation, recover to Public
useEffect(() => {

View File

@@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import type {
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -93,13 +94,14 @@ export function useRadioControl() {
}
}, [fetchConfig]);
const handleAdvertise = useCallback(async () => {
const handleAdvertise = useCallback(async (mode: RadioAdvertMode = 'flood') => {
try {
await api.sendAdvertisement();
toast.success('Advertisement sent');
await api.sendAdvertisement(mode);
toast.success(mode === 'zero_hop' ? 'Zero-hop advertisement sent' : 'Advertisement sent');
} catch (err) {
console.error('Failed to send advertisement:', err);
toast.error('Failed to send advertisement', {
const label = mode === 'zero_hop' ? 'zero-hop advertisement' : 'advertisement';
console.error(`Failed to send ${label}:`, err);
toast.error(`Failed to send ${label}`, {
description: err instanceof Error ? err.message : 'Check radio connection',
});
}

View File

@@ -0,0 +1,52 @@
import { useCallback, useState } from 'react';
import type { RawPacket } from '../types';
import {
MAX_RAW_PACKET_STATS_OBSERVATIONS,
summarizeRawPacketForStats,
type RawPacketStatsSessionState,
} from '../utils/rawPacketStats';
export function useRawPacketStatsSession() {
const [session, setSession] = useState<RawPacketStatsSessionState>(() => ({
sessionStartedAt: Date.now(),
totalObservedPackets: 0,
trimmedObservationCount: 0,
observations: [],
}));
const recordRawPacketObservation = useCallback((packet: RawPacket) => {
setSession((prev) => {
const observation = summarizeRawPacketForStats(packet);
if (
prev.observations.some(
(candidate) => candidate.observationKey === observation.observationKey
)
) {
return prev;
}
const observations = [...prev.observations, observation];
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
return {
...prev,
totalObservedPackets: prev.totalObservedPackets + 1,
observations,
};
}
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
return {
...prev,
totalObservedPackets: prev.totalObservedPackets + 1,
trimmedObservationCount: prev.trimmedObservationCount + overflow,
observations: observations.slice(overflow),
};
});
}, []);
return {
rawPacketStatsSession: session,
recordRawPacketObservation,
};
}

View File

@@ -6,14 +6,12 @@ import {
type SetStateAction,
} from 'react';
import { api } from '../api';
import * as messageCache from '../messageCache';
import type { UseWebSocketOptions } from '../useWebSocket';
import { toast } from '../components/ui/sonner';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import { getContactDisplayName } from '../utils/pubkey';
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
import { getMessageContentKey } from './useConversationMessages';
import type {
Channel,
Contact,
@@ -29,7 +27,7 @@ interface UseRealtimeAppStateArgs {
setHealth: Dispatch<SetStateAction<HealthStatus | null>>;
fetchConfig: () => void | Promise<void>;
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
triggerReconcile: () => void;
reconcileOnReconnect: () => void;
refreshUnreads: () => Promise<void>;
setChannels: Dispatch<SetStateAction<Channel[]>>;
fetchAllContacts: () => Promise<Contact[]>;
@@ -37,16 +35,22 @@ interface UseRealtimeAppStateArgs {
blockedKeysRef: MutableRefObject<string[]>;
blockedNamesRef: MutableRefObject<string[]>;
activeConversationRef: MutableRefObject<Conversation | null>;
hasNewerMessagesRef: MutableRefObject<boolean>;
addMessageIfNew: (msg: Message) => boolean;
trackNewMessage: (msg: Message) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
recordRawPacketObservation?: (packet: RawPacket) => void;
maxRawPackets?: number;
}
@@ -71,30 +75,12 @@ function isMessageBlocked(msg: Message, blockedKeys: string[], blockedNames: str
return blockedNames.length > 0 && !!msg.sender_name && blockedNames.includes(msg.sender_name);
}
function isActiveConversationMessage(
activeConversation: Conversation | null,
msg: Message
): boolean {
if (!activeConversation) return false;
if (msg.type === 'CHAN' && activeConversation.type === 'channel') {
return msg.conversation_key === activeConversation.id;
}
if (msg.type === 'PRIV' && activeConversation.type === 'contact') {
return msg.conversation_key === activeConversation.id;
}
return false;
}
function isMessageConversation(conversation: Conversation | null): boolean {
return conversation?.type === 'channel' || conversation?.type === 'contact';
}
export function useRealtimeAppState({
prevHealthRef,
setHealth,
fetchConfig,
setRawPackets,
triggerReconcile,
reconcileOnReconnect,
refreshUnreads,
setChannels,
fetchAllContacts,
@@ -102,16 +88,17 @@ export function useRealtimeAppState({
blockedKeysRef,
blockedNamesRef,
activeConversationRef,
hasNewerMessagesRef,
addMessageIfNew,
trackNewMessage,
incrementUnread,
observeMessage,
recordMessageEvent,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
renameConversationMessages,
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
recordRawPacketObservation,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
@@ -184,11 +171,7 @@ export function useRealtimeAppState({
},
onReconnect: () => {
setRawPackets([]);
if (
!(hasNewerMessagesRef.current && isMessageConversation(activeConversationRef.current))
) {
triggerReconcile();
}
reconcileOnReconnect();
refreshUnreads();
api.getChannels().then(setChannels).catch(console.error);
fetchAllContacts()
@@ -200,34 +183,14 @@ export function useRealtimeAppState({
return;
}
const isForActiveConversation = isActiveConversationMessage(
activeConversationRef.current,
msg
);
let isNewMessage = false;
if (isForActiveConversation && !hasNewerMessagesRef.current) {
isNewMessage = addMessageIfNew(msg);
}
trackNewMessage(msg);
const contentKey = getMessageContentKey(msg);
if (!isForActiveConversation) {
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (stateKey) {
incrementUnread(stateKey, checkMention(msg.text));
}
}
}
const { added: isNewMessage, activeConversation: isForActiveConversation } =
observeMessage(msg);
recordMessageEvent({
msg,
activeConversation: isForActiveConversation,
isNewMessage,
hasMention: checkMention(msg.text),
});
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
@@ -243,7 +206,7 @@ export function useRealtimeAppState({
contact
)
);
messageCache.rename(previousPublicKey, contact.public_key);
renameConversationMessages(previousPublicKey, contact.public_key);
renameConversationState(
getStateKey('contact', previousPublicKey),
getStateKey('contact', contact.public_key)
@@ -263,7 +226,7 @@ export function useRealtimeAppState({
},
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
messageCache.remove(publicKey);
removeConversationMessages(publicKey);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
@@ -272,7 +235,7 @@ export function useRealtimeAppState({
},
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
messageCache.remove(key);
removeConversationMessages(key);
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
@@ -280,37 +243,38 @@ export function useRealtimeAppState({
}
},
onRawPacket: (packet: RawPacket) => {
recordRawPacketObservation?.(packet);
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
},
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
messageCache.updateAck(messageId, ackCount, paths);
receiveMessageAck(messageId, ackCount, paths);
},
}),
[
activeConversationRef,
addMessageIfNew,
blockedKeysRef,
blockedNamesRef,
checkMention,
fetchAllContacts,
fetchConfig,
hasNewerMessagesRef,
incrementUnread,
renameConversationState,
renameConversationMessages,
maxRawPackets,
mergeChannelIntoList,
pendingDeleteFallbackRef,
prevHealthRef,
recordMessageEvent,
recordRawPacketObservation,
receiveMessageAck,
observeMessage,
refreshUnreads,
reconcileOnReconnect,
removeConversationMessages,
setActiveConversation,
setChannels,
setContacts,
setHealth,
setRawPackets,
trackNewMessage,
triggerReconcile,
updateMessageAck,
notifyIncomingMessage,
]
);

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
type ServerLoginKind = 'repeater' | 'room';
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
type StoredPassword = {
password: string;
};
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
}
function loadStoredPassword(kind: ServerLoginKind, publicKey: string): StoredPassword | null {
try {
const raw = localStorage.getItem(getStorageKey(kind, publicKey));
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredPassword>;
if (typeof parsed.password !== 'string' || parsed.password.length === 0) {
return null;
}
return { password: parsed.password };
} catch {
return null;
}
}
export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: string) {
const storageKey = useMemo(() => getStorageKey(kind, publicKey), [kind, publicKey]);
const [password, setPassword] = useState('');
const [rememberPassword, setRememberPassword] = useState(false);
useEffect(() => {
const stored = loadStoredPassword(kind, publicKey);
if (!stored) {
setPassword('');
setRememberPassword(false);
return;
}
setPassword(stored.password);
setRememberPassword(true);
}, [kind, publicKey]);
const persistAfterLogin = useCallback(
(submittedPassword: string) => {
if (!rememberPassword) {
try {
localStorage.removeItem(storageKey);
} catch {
// localStorage may be unavailable
}
setPassword('');
return;
}
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
setPassword(trimmedPassword);
},
[rememberPassword, storageKey]
);
return {
password,
setPassword,
rememberPassword,
setRememberPassword,
persistAfterLogin,
};
}

View File

@@ -16,10 +16,14 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
unreadLastReadAts: Record<string, number | null>;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
markAllRead: () => void;
trackNewMessage: (msg: Message) => void;
refreshUnreads: () => Promise<void>;
}
@@ -162,7 +166,6 @@ export function useUnreadCounts(
}
}, [activeConversation]);
// Increment unread count for a conversation
const incrementUnread = useCallback((stateKey: string, hasMention?: boolean) => {
setUnreadCounts((prev) => ({
...prev,
@@ -176,6 +179,40 @@ export function useUnreadCounts(
}
}, []);
const recordMessageEvent = useCallback(
({
msg,
activeConversation: isActiveConversation,
isNewMessage,
hasMention,
}: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (!stateKey) {
return;
}
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(stateKey, timestamp);
setLastMessageTimes(updated);
if (!isActiveConversation && !msg.outgoing && isNewMessage) {
incrementUnread(stateKey, hasMention);
}
},
[incrementUnread]
);
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
if (oldStateKey === newStateKey) return;
@@ -212,31 +249,14 @@ export function useUnreadCounts(
});
}, []);
// Track a new incoming message for unread counts
const trackNewMessage = useCallback((msg: Message) => {
let conversationKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
conversationKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
conversationKey = getStateKey('contact', msg.conversation_key);
}
if (conversationKey) {
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(conversationKey, timestamp);
setLastMessageTimes(updated);
}
}, []);
return {
unreadCounts,
mentions,
lastMessageTimes,
unreadLastReadAts,
incrementUnread,
recordMessageEvent,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads: fetchUnreads,
};
}

View File

@@ -1,174 +0,0 @@
/**
* LRU message cache for recently-visited conversations.
*
* Uses Map insertion-order semantics: the most recently used entry
* is always at the end. Eviction removes the first (least-recently-used) entry.
*
* Cache size: 20 conversations, 200 messages each (~2.4MB worst case).
*/
import type { Message, MessagePath } from './types';
export const MAX_CACHED_CONVERSATIONS = 20;
export const MAX_MESSAGES_PER_ENTRY = 200;
interface CacheEntry {
messages: Message[];
seenContent: Set<string>;
hasOlderMessages: boolean;
}
const cache = new Map<string, CacheEntry>();
/** Get a cached entry and promote it to most-recently-used. */
export function get(id: string): CacheEntry | undefined {
const entry = cache.get(id);
if (!entry) return undefined;
// Promote to MRU: delete and re-insert
cache.delete(id);
cache.set(id, entry);
return entry;
}
/** Insert or update an entry at MRU position, evicting LRU if over capacity. */
export function set(id: string, entry: CacheEntry): void {
// Trim to most recent messages to bound memory
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
const trimmed = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
}
// Remove first so re-insert moves to end
cache.delete(id);
cache.set(id, entry);
// Evict LRU (first entry) if over capacity
if (cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = cache.keys().next().value as string;
cache.delete(lruKey);
}
}
/** Add a message to a cached conversation with dedup. Returns true if new, false if duplicate. */
export function addMessage(id: string, msg: Message, contentKey: string): boolean {
const entry = cache.get(id);
if (!entry) {
// Auto-create a minimal entry for never-visited conversations
cache.set(id, {
messages: [msg],
seenContent: new Set([contentKey]),
hasOlderMessages: true,
});
// Evict LRU if over capacity
if (cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = cache.keys().next().value as string;
cache.delete(lruKey);
}
return true;
}
if (entry.seenContent.has(contentKey)) return false;
if (entry.messages.some((m) => m.id === msg.id)) return false;
entry.seenContent.add(contentKey);
entry.messages = [...entry.messages, msg];
// Trim if over limit (drop oldest by received_at)
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
entry.messages = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
}
// Promote to MRU so actively-messaged conversations aren't evicted
cache.delete(id);
cache.set(id, entry);
return true;
}
/** Scan all cached entries for a message ID and update its ack/paths. */
export function updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
for (const entry of cache.values()) {
const idx = entry.messages.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const current = entry.messages[idx];
const updated = [...entry.messages];
updated[idx] = {
...current,
acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
};
entry.messages = updated;
return; // Message IDs are unique, stop after first match
}
}
}
/**
* Compare fetched messages against current state.
* Returns merged array if there are differences (new messages or ack changes),
* or null if the cache is already consistent (happy path — no rerender needed).
* Preserves any older paginated messages not present in the fetched page.
*/
export function reconcile(current: Message[], fetched: Message[]): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
for (const m of current) {
currentById.set(m.id, { acked: m.acked, pathsLen: m.paths?.length ?? 0, text: m.text });
}
let needsUpdate = false;
for (const m of fetched) {
const cur = currentById.get(m.id);
if (
!cur ||
cur.acked !== m.acked ||
cur.pathsLen !== (m.paths?.length ?? 0) ||
cur.text !== m.text
) {
needsUpdate = true;
break;
}
}
if (!needsUpdate) return null;
// Merge: fresh recent page + any older paginated messages not in the fetch
const fetchedIds = new Set(fetched.map((m) => m.id));
const olderMessages = current.filter((m) => !fetchedIds.has(m.id));
return [...fetched, ...olderMessages];
}
/** Evict a specific conversation from the cache. */
export function remove(id: string): void {
cache.delete(id);
}
/** Move cached conversation state to a new conversation id. */
export function rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = cache.get(oldId);
if (!oldEntry) return;
const newEntry = cache.get(newId);
if (!newEntry) {
cache.delete(oldId);
cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
cache.delete(oldId);
cache.set(newId, {
messages: mergedMessages,
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
});
}
/** Clear the entire cache. */
export function clear(): void {
cache.clear();
}

View File

@@ -305,7 +305,7 @@ describe('fetchJson (via api methods)', () => {
expect(options.method).toBe('DELETE');
});
it('sends POST without body for sendAdvertisement', async () => {
it('sends POST with flood mode for sendAdvertisement', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -317,7 +317,22 @@ describe('fetchJson (via api methods)', () => {
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
});
it('sends POST with zero-hop mode for sendAdvertisement', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok' }),
});
await api.sendAdvertisement('zero_hop');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
});
});

View File

@@ -31,15 +31,15 @@ const mocks = vi.hoisted(() => ({
error: vi.fn(),
},
hookFns: {
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
incrementUnread: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
recordMessageEvent: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
},
}));
@@ -63,38 +63,30 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: mocks.hookFns.setMessages,
fetchMessages: mocks.hookFns.fetchMessages,
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: mocks.hookFns.addMessageIfNew,
updateMessageAck: mocks.hookFns.updateMessageAck,
triggerReconcile: mocks.hookFns.triggerReconcile,
observeMessage: mocks.hookFns.observeMessage,
receiveMessageAck: mocks.hookFns.receiveMessageAck,
reconcileOnReconnect: mocks.hookFns.reconcileOnReconnect,
renameConversationMessages: mocks.hookFns.renameConversationMessages,
removeConversationMessages: mocks.hookFns.removeConversationMessages,
clearConversationMessages: mocks.hookFns.clearConversationMessages,
}),
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: mocks.hookFns.incrementUnread,
recordMessageEvent: mocks.hookFns.recordMessageEvent,
renameConversationState: vi.fn(),
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
refreshUnreads: mocks.hookFns.refreshUnreads,
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: ({
settingsMode,
@@ -172,7 +164,10 @@ vi.mock('../components/ui/sonner', () => ({
vi.mock('../utils/urlHash', () => ({
parseHashConversation: () => null,
parseHashSettingsSection: () => null,
updateUrlHash: vi.fn(),
updateSettingsHash: vi.fn(),
getSettingsHash: (section: string) => `#settings/${section}`,
getMapFocusHash: () => '#map',
}));
@@ -295,7 +290,7 @@ describe('App favorite toggle flow', () => {
await waitFor(() => {
expect(mocks.api.getChannels).toHaveBeenCalledTimes(2);
});
expect(mocks.hookFns.triggerReconcile).toHaveBeenCalledTimes(1);
expect(mocks.hookFns.reconcileOnReconnect).toHaveBeenCalledTimes(1);
expect(mocks.hookFns.refreshUnreads).toHaveBeenCalledTimes(1);
});

View File

@@ -37,15 +37,16 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
};
},
useUnreadCounts: () => ({
@@ -53,22 +54,14 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));

View File

@@ -32,38 +32,30 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
}),
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
@@ -98,7 +90,9 @@ vi.mock('../components/NewMessageModal', () => ({
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: 'Radio',
@@ -262,14 +256,45 @@ describe('App startup hash resolution', () => {
expect(window.location.hash).toBe('');
});
it('tracks the current conversation in local storage even before reopen is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',
name: 'Ops',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
window.location.hash = '';
mocks.api.getChannels.mockResolvedValue([publicChannel, chatChannel]);
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'channel',
id: chatChannel.key,
name: chatChannel.name,
})
);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
}
});
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toContain(publicChannel.key);
});
it('restores last viewed contact from legacy name token when hash is empty and reopen is enabled', async () => {
const aliceContact = {
public_key: 'b'.repeat(64),
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
last_advert: null,
lat: null,
lon: null,
@@ -301,4 +326,18 @@ describe('App startup hash resolution', () => {
});
expect(window.location.hash).toBe('');
});
it('stays on radio settings section even when radio is disconnected', async () => {
window.location.hash = '#settings/radio';
mocks.api.getRadioConfig.mockRejectedValue(new Error('radio offline'));
render(<App />);
await waitFor(() => {
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('radio');
});
// Section stays on radio (no redirect to local) and hash is preserved
expect(window.location.hash).toBe('#settings/radio');
});
});

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, 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 {
@@ -170,6 +171,38 @@ describe('ChatHeader key visibility', () => {
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('hides trace and notification controls for room-server contacts', () => {
const pubKey = '41'.repeat(32);
const contact: Contact = {
public_key: pubKey,
name: 'Ops Board',
type: CONTACT_TYPE_ROOM,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Ops Board' };
render(
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
);
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
).not.toBeInTheDocument();
});
it('hides the delete button for the canonical Public channel', () => {
const channel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public', false);
const conversation: Conversation = { type: 'channel', id: PUBLIC_CHANNEL_KEY, name: 'Public' };
@@ -196,9 +229,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -242,9 +275,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'BBDD',
route_override_len: 2,
route_override_hash_mode: 0,

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { getContactAvatar } from '../utils/contactAvatar';
import { CONTACT_TYPE_REPEATER } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
describe('getContactAvatar', () => {
it('returns complete avatar info', () => {
@@ -30,6 +30,13 @@ describe('getContactAvatar', () => {
expect(avatar1.background).toBe(avatar2.background);
});
it('returns room avatar for type=3', () => {
const avatar = getContactAvatar('Ops Board', 'abc123def456', CONTACT_TYPE_ROOM);
expect(avatar.text).toBe('🛖');
expect(avatar.background).toBe('#6b4f2a');
expect(avatar.textColor).toBe('#ffffff');
});
it('non-repeater types use normal avatar', () => {
const avatar0 = getContactAvatar('John', 'abc123', 0);
const avatar1 = getContactAvatar('John', 'abc123', 1);

View File

@@ -39,9 +39,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -104,7 +104,7 @@ describe('ContactInfoPane', () => {
});
it('shows hop width when contact has a stored path hash mode', async () => {
const contact = createContact({ out_path_hash_mode: 1 });
const contact = createContact({ direct_path_hash_mode: 1, direct_path_len: 1 });
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -117,7 +117,7 @@ describe('ContactInfoPane', () => {
});
it('does not show hop width for flood-routed contacts', async () => {
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
const contact = createContact({ direct_path_len: -1, direct_path_hash_mode: -1 });
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -131,8 +131,8 @@ describe('ContactInfoPane', () => {
it('shows forced routing override and learned route separately', async () => {
const contact = createContact({
last_path_len: 1,
out_path_hash_mode: 0,
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'ae92f13e',
route_override_len: 2,
route_override_hash_mode: 1,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
@@ -12,6 +12,7 @@ import type {
Message,
RadioConfig,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
@@ -40,6 +41,21 @@ vi.mock('../components/RepeaterDashboard', () => ({
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
}));
vi.mock('../components/RoomServerPanel', () => ({
RoomServerPanel: ({
onAuthenticatedChange,
}: {
onAuthenticatedChange?: (value: boolean) => void;
}) => (
<div>
<div data-testid="room-server-panel" />
<button type="button" onClick={() => onAuthenticatedChange?.(true)}>
Authenticate room
</button>
</div>
),
}));
vi.mock('../components/MapView', () => ({
MapView: () => <div data-testid="map-view" />,
}));
@@ -95,12 +111,20 @@ const message: Message = {
sender_name: null,
};
const rawPacketStatsSession: RawPacketStatsSessionState = {
sessionStartedAt: 1_700_000_000_000,
totalObservedPackets: 0,
trimmedObservationCount: 0,
observations: [],
};
function createProps(overrides: Partial<React.ComponentProps<typeof ConversationPane>> = {}) {
return {
activeConversation: null as Conversation | null,
contacts: [] as Contact[],
channels: [channel],
rawPackets: [],
rawPacketStatsSession,
config,
health,
notificationsSupported: true,
@@ -166,9 +190,9 @@ describe('ConversationPane', () => {
name: 'Repeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -207,6 +231,54 @@ describe('ConversationPane', () => {
});
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: 'Ops Board',
},
contacts: [
{
public_key: 'cc'.repeat(32),
name: 'Ops Board',
type: 3,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
],
})}
/>
);
await waitFor(() => {
expect(screen.getByTestId('room-server-panel')).toBeInTheDocument();
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
});
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Authenticate room' }));
await waitFor(() => {
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
it('passes unread marker props to MessageList only for channel conversations', async () => {
render(
<ConversationPane
@@ -268,9 +340,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -304,9 +376,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
DISTANCE_UNIT_KEY,
getSavedDistanceUnit,
setSavedDistanceUnit,
} from '../utils/distanceUnits';
describe('distanceUnits utilities', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults to metric when unset', () => {
expect(getSavedDistanceUnit()).toBe('metric');
});
it('returns the stored unit when valid', () => {
localStorage.setItem(DISTANCE_UNIT_KEY, 'metric');
expect(getSavedDistanceUnit()).toBe('metric');
});
it('falls back to metric for invalid stored values', () => {
localStorage.setItem(DISTANCE_UNIT_KEY, 'parsecs');
expect(getSavedDistanceUnit()).toBe('metric');
});
it('stores the selected distance unit', () => {
setSavedDistanceUnit('smoots');
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
});

View File

@@ -85,8 +85,11 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'meshcoretomqtt/LetsMesh/MeshRank' })
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
@@ -339,11 +342,12 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled();
});
it('back to list asks for confirmation before leaving', async () => {
it('back to list does not ask for confirmation when an existing integration is unchanged', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
@@ -353,11 +357,28 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
});
it('back to list asks for confirmation after editing an existing integration', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('URL'), {
target: { value: 'https://example.com/new' },
});
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
});
it('back to list stays on the edit screen when confirmation is cancelled', async () => {
it('back to list stays on the edit screen when confirmation is cancelled after edits', async () => {
vi.mocked(window.confirm).mockReturnValue(false);
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
@@ -366,6 +387,9 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('URL'), {
target: { value: 'https://example.com/new' },
});
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
@@ -630,6 +654,238 @@ describe('SettingsFanoutSection', () => {
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
});
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('MeshRank');
expect(screen.getByLabelText('Packet Topic Template')).toHaveValue('');
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
});
it('creates MeshRank preset as a regular mqtt_community config', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-meshrank',
type: 'mqtt_community',
name: 'MeshRank',
enabled: true,
config: {
broker_host: 'meshrank.net',
broker_port: 8883,
transport: 'tcp',
use_tls: true,
tls_verify: true,
auth_mode: 'none',
username: '',
password: '',
iata: 'XYZ',
email: '',
token_audience: '',
topic_template: 'meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
target: {
value: 'meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets',
},
});
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_community',
name: 'MeshRank',
config: {
broker_host: 'meshrank.net',
broker_port: 8883,
transport: 'tcp',
use_tls: true,
tls_verify: true,
auth_mode: 'none',
username: '',
password: '',
iata: 'XYZ',
email: '',
token_audience: '',
topic_template: 'meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('LetsMesh (US) preset pre-fills the expected broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-us',
type: 'mqtt_community',
name: 'LetsMesh (US)',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'LAX',
email: 'user@example.com',
token_audience: 'mqtt-us-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
expect(screen.queryByLabelText('Authentication')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Packet Topic Template')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText('Region Code (IATA)'), { target: { value: 'lax' } });
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_community',
name: 'LetsMesh (US)',
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'LAX',
email: 'user@example.com',
token_audience: 'mqtt-us-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: false,
})
);
});
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-eu',
type: 'mqtt_community',
name: 'LetsMesh (EU)',
enabled: true,
config: {
broker_host: 'mqtt-eu-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'AMS',
email: 'user@example.com',
token_audience: 'mqtt-eu-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText('Region Code (IATA)'), { target: { value: 'ams' } });
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_community',
name: 'LetsMesh (EU)',
config: {
broker_host: 'mqtt-eu-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'AMS',
email: 'user@example.com',
token_audience: 'mqtt-eu-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('generic Community MQTT entry still opens the full editor', async () => {
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('Community MQTT #1');
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
expect(screen.getByLabelText('Authentication')).toBeInTheDocument();
expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument();
});
it('private MQTT list shows broker and topic summary', async () => {
const privateConfig: FanoutConfig = {
id: 'mqtt-1',

View File

@@ -8,12 +8,12 @@
* between backend and frontend - both sides test against the same data.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import fixtures from './fixtures/websocket_events.json';
import { getMessageContentKey } from '../hooks/useConversationMessages';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import * as messageCache from '../messageCache';
import { ConversationMessageCache } from '../hooks/useConversationMessages';
import { getMessageContentKey } from '../utils/messageIdentity';
import type { Contact, Message } from '../types';
/**
@@ -25,6 +25,7 @@ interface MockState {
unreadCounts: Record<string, number>;
lastMessageTimes: Record<string, number>;
seenActiveContent: Set<string>;
messageCache: ConversationMessageCache;
}
function createMockState(): MockState {
@@ -33,6 +34,7 @@ function createMockState(): MockState {
unreadCounts: {},
lastMessageTimes: {},
seenActiveContent: new Set(),
messageCache: new ConversationMessageCache(),
};
}
@@ -68,7 +70,7 @@ function handleMessageEvent(
state.lastMessageTimes[stateKey] = msg.received_at;
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
const isNew = state.messageCache.addMessage(msg.conversation_key, msg);
if (!msg.outgoing && isNew) {
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
unreadIncremented = true;
@@ -78,11 +80,6 @@ function handleMessageEvent(
return { added, unreadIncremented };
}
// Clear messageCache between tests to avoid cross-test contamination
beforeEach(() => {
messageCache.clear();
});
describe('Integration: Channel Message Events', () => {
const fixture = fixtures.channel_message;
@@ -170,17 +167,15 @@ describe('Integration: Duplicate Message Handling', () => {
});
});
describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regression)', () => {
it('does not increment unread when a mesh echo arrives after many unique messages', () => {
describe('Integration: Trimmed cache entries can reappear (hitlist #7 regression)', () => {
it('increments unread when an evicted inactive-conversation message arrives again', () => {
const state = createMockState();
const convKey = 'channel_busy';
// Deliver 1001 unique messages — exceeding the old global
// seenMessageContentRef prune threshold (1000→500). Under the old
// dual-set design the global set would drop msg-0's key during pruning,
// so a later mesh echo of msg-0 would pass the global check and
// phantom-increment unread. With the fix, messageCache's per-conversation
// seenContent is the single source of truth and is never pruned.
// Deliver enough unique messages to evict msg-0 from the inactive
// conversation cache. Once it falls out of that window, a later arrival
// with the same content should be allowed back in instead of being
// suppressed forever by a stale content key.
const MESSAGE_COUNT = 1001;
for (let i = 0; i < MESSAGE_COUNT; i++) {
const msg: Message = {
@@ -222,9 +217,8 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
};
const result = handleMessageEvent(state, echo, 'other_active_conv');
// Must NOT increment unread — the echo is a duplicate
expect(result.unreadIncremented).toBe(false);
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT);
expect(result.unreadIncremented).toBe(true);
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT + 1);
});
});
@@ -277,8 +271,8 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
name: 'TestNode',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
direct_path: null,
direct_path_len: 0,
last_advert: null,
lat: null,
lon: null,
@@ -288,7 +282,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
direct_path_hash_mode: overrides.direct_path_hash_mode ?? 0,
};
}
@@ -342,11 +336,8 @@ describe('Integration: Contact Merge', () => {
// --- ACK + messageCache propagation tests ---
describe('Integration: ACK + messageCache propagation', () => {
beforeEach(() => {
messageCache.clear();
});
it('updateAck updates acked count on cached message', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 100,
type: 'PRIV',
@@ -362,7 +353,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-100');
messageCache.addMessage('pk_abc', msg);
messageCache.updateAck(100, 1);
@@ -372,6 +363,7 @@ describe('Integration: ACK + messageCache propagation', () => {
});
it('updateAck updates paths when longer', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 101,
type: 'PRIV',
@@ -387,7 +379,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 1,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-101');
messageCache.addMessage('pk_abc', msg);
const longerPaths = [
{ path: 'aa', received_at: 1700000001 },
@@ -401,6 +393,7 @@ describe('Integration: ACK + messageCache propagation', () => {
});
it('preserves higher existing ack count (max semantics)', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 102,
type: 'PRIV',
@@ -416,7 +409,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 5,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-102');
messageCache.addMessage('pk_abc', msg);
// Try to update with a lower ack count
messageCache.updateAck(102, 3);
@@ -426,6 +419,7 @@ describe('Integration: ACK + messageCache propagation', () => {
});
it('is a no-op for unknown message ID', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 103,
type: 'PRIV',
@@ -441,7 +435,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-103');
messageCache.addMessage('pk_abc', msg);
// Update a non-existent message ID — should not throw or modify anything
messageCache.updateAck(999, 1);

View File

@@ -29,9 +29,9 @@ describe('MapView', () => {
name: 'Mystery Node',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
@@ -63,9 +63,9 @@ describe('MapView', () => {
name: 'Almost Stale',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,

View File

@@ -3,8 +3,12 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as messageCache from '../messageCache';
import { MAX_CACHED_CONVERSATIONS, MAX_MESSAGES_PER_ENTRY } from '../messageCache';
import {
ConversationMessageCache,
MAX_CACHED_CONVERSATIONS,
MAX_MESSAGES_PER_ENTRY,
reconcileConversationMessages,
} from '../hooks/useConversationMessages';
import type { Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
@@ -27,16 +31,14 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}
function createEntry(messages: Message[] = [], hasOlderMessages = false) {
const seenContent = new Set<string>();
for (const msg of messages) {
seenContent.add(`${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`);
}
return { messages, seenContent, hasOlderMessages };
return { messages, hasOlderMessages };
}
describe('messageCache', () => {
let messageCache: ConversationMessageCache;
beforeEach(() => {
messageCache.clear();
messageCache = new ConversationMessageCache();
});
describe('get/set', () => {
@@ -155,11 +157,7 @@ describe('messageCache', () => {
messageCache.set('conv1', createEntry([]));
const msg = createMessage({ id: 10, text: 'New message' });
const result = messageCache.addMessage(
'conv1',
msg,
'CHAN-channel123-New message-1700000000'
);
const result = messageCache.addMessage('conv1', msg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
@@ -171,12 +169,11 @@ describe('messageCache', () => {
messageCache.set('conv1', createEntry([]));
const msg1 = createMessage({ id: 10, text: 'Hello' });
const contentKey = 'CHAN-channel123-Hello-1700000000';
expect(messageCache.addMessage('conv1', msg1, contentKey)).toBe(true);
expect(messageCache.addMessage('conv1', msg1)).toBe(true);
// Same content key, different message id
const msg2 = createMessage({ id: 11, text: 'Hello' });
expect(messageCache.addMessage('conv1', msg2, contentKey)).toBe(false);
expect(messageCache.addMessage('conv1', msg2)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
@@ -187,9 +184,7 @@ describe('messageCache', () => {
// Same id, different content key
const msg = createMessage({ id: 10, text: 'Different' });
expect(messageCache.addMessage('conv1', msg, 'CHAN-channel123-Different-1700000000')).toBe(
false
);
expect(messageCache.addMessage('conv1', msg)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
@@ -208,11 +203,7 @@ describe('messageCache', () => {
text: 'newest',
received_at: 1700000000 + MAX_MESSAGES_PER_ENTRY,
});
const result = messageCache.addMessage(
'conv1',
newMsg,
`CHAN-channel123-newest-${newMsg.sender_timestamp}`
);
const result = messageCache.addMessage('conv1', newMsg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
@@ -223,13 +214,79 @@ describe('messageCache', () => {
expect(entry!.messages.some((m) => m.id === 0)).toBe(false);
});
it('allows a trimmed-out message to be re-added after set() trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 1 }, (_, i) =>
createMessage({
id: i,
text: `message-${i}`,
received_at: 1700000000 + i,
sender_timestamp: 1700000000 + i,
})
);
messageCache.set('conv1', createEntry(messages));
const trimmedOut = createMessage({
id: 10_000,
text: 'message-0',
received_at: 1800000000,
sender_timestamp: 1700000000,
});
expect(messageCache.addMessage('conv1', trimmedOut)).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages.some((m) => m.id === 10_000)).toBe(true);
});
it('allows a trimmed-out message to be re-added after addMessage() trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY - 1 }, (_, i) =>
createMessage({
id: i,
text: `message-${i}`,
received_at: 1700000000 + i,
sender_timestamp: 1700000000 + i,
})
);
messageCache.set('conv1', createEntry(messages));
expect(
messageCache.addMessage(
'conv1',
createMessage({
id: MAX_MESSAGES_PER_ENTRY,
text: 'newest-a',
received_at: 1800000000,
sender_timestamp: 1800000000,
})
)
).toBe(true);
expect(
messageCache.addMessage(
'conv1',
createMessage({
id: MAX_MESSAGES_PER_ENTRY + 1,
text: 'newest-b',
received_at: 1800000001,
sender_timestamp: 1800000001,
})
)
).toBe(true);
const readdedTrimmedMessage = createMessage({
id: 10_001,
text: 'message-0',
received_at: 1900000000,
sender_timestamp: 1700000000,
});
expect(messageCache.addMessage('conv1', readdedTrimmedMessage)).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages.some((m) => m.id === 10_001)).toBe(true);
});
it('auto-creates a minimal entry for never-visited conversations and returns true', () => {
const msg = createMessage({ id: 10, text: 'First contact' });
const result = messageCache.addMessage(
'new_conv',
msg,
'CHAN-channel123-First contact-1700000000'
);
const result = messageCache.addMessage('new_conv', msg);
expect(result).toBe(true);
const entry = messageCache.get('new_conv');
@@ -237,7 +294,6 @@ describe('messageCache', () => {
expect(entry!.messages).toHaveLength(1);
expect(entry!.messages[0].text).toBe('First contact');
expect(entry!.hasOlderMessages).toBe(true);
expect(entry!.seenContent.has('CHAN-channel123-First contact-1700000000')).toBe(true);
});
it('promotes entry to MRU on addMessage', () => {
@@ -248,7 +304,7 @@ describe('messageCache', () => {
// addMessage to conv0 (currently LRU) should promote it
const msg = createMessage({ id: 999, text: 'Incoming WS message' });
messageCache.addMessage('conv0', msg, 'CHAN-channel123-Incoming WS message-1700000000');
messageCache.addMessage('conv0', msg);
// Add one more — conv1 should now be LRU and get evicted, not conv0
messageCache.set('conv_new', createEntry());
@@ -259,11 +315,10 @@ describe('messageCache', () => {
it('returns false for duplicate delivery to auto-created entry', () => {
const msg = createMessage({ id: 10, text: 'Echo' });
const contentKey = 'CHAN-channel123-Echo-1700000000';
expect(messageCache.addMessage('new_conv', msg, contentKey)).toBe(true);
expect(messageCache.addMessage('new_conv', msg)).toBe(true);
// Duplicate via mesh echo
expect(messageCache.addMessage('new_conv', msg, contentKey)).toBe(false);
expect(messageCache.addMessage('new_conv', msg)).toBe(false);
const entry = messageCache.get('new_conv');
expect(entry!.messages).toHaveLength(1);
@@ -358,7 +413,7 @@ describe('messageCache', () => {
createMessage({ id: 3, acked: 1 }),
];
expect(messageCache.reconcile(msgs, fetched)).toBeNull();
expect(reconcileConversationMessages(msgs, fetched)).toBeNull();
});
it('detects new messages missing from cache', () => {
@@ -369,7 +424,7 @@ describe('messageCache', () => {
createMessage({ id: 3, text: 'missed via WS' }),
];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged!.map((m) => m.id)).toEqual([1, 2, 3]);
});
@@ -378,7 +433,7 @@ describe('messageCache', () => {
const current = [createMessage({ id: 1, acked: 0 })];
const fetched = [createMessage({ id: 1, acked: 3 })];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].acked).toBe(3);
});
@@ -397,20 +452,20 @@ describe('messageCache', () => {
createMessage({ id: 2 }),
];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
// Should have fetched page + older paginated message
expect(merged!.map((m) => m.id)).toEqual([4, 3, 2, 1]);
});
it('returns null for empty fetched and empty current', () => {
expect(messageCache.reconcile([], [])).toBeNull();
expect(reconcileConversationMessages([], [])).toBeNull();
});
it('detects difference when current is empty but fetch has messages', () => {
const fetched = [createMessage({ id: 1 })];
const merged = messageCache.reconcile([], fetched);
const merged = reconcileConversationMessages([], fetched);
expect(merged).not.toBeNull();
expect(merged!).toHaveLength(1);
});
@@ -430,7 +485,7 @@ describe('messageCache', () => {
}),
];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].paths).toHaveLength(2);
});
@@ -439,7 +494,7 @@ describe('messageCache', () => {
const current = [createMessage({ id: 1, text: '[encrypted]' })];
const fetched = [createMessage({ id: 1, text: 'Hello world' })];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].text).toBe('Hello world');
});
@@ -449,7 +504,7 @@ describe('messageCache', () => {
const current = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
const fetched = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
expect(messageCache.reconcile(current, fetched)).toBeNull();
expect(reconcileConversationMessages(current, fetched)).toBeNull();
});
});
});

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
const scrollIntoViewMock = vi.fn();
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
@@ -81,6 +81,46 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('A')).toBeInTheDocument();
});
it('renders room-server DM messages using stored sender attribution instead of the room contact', () => {
const roomContact: Contact = {
public_key: 'ab'.repeat(32),
name: 'Ops Board',
type: CONTACT_TYPE_ROOM,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
render(
<MessageList
messages={[
createMessage({
type: 'PRIV',
conversation_key: roomContact.public_key,
text: 'status update: ready',
sender_name: 'Alice',
sender_key: '12'.repeat(32),
}),
]}
contacts={[roomContact]}
loading={false}
/>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Ops Board')).not.toBeInTheDocument();
expect(screen.getByText('status update: ready')).toBeInTheDocument();
});
it('gives clickable sender avatars an accessible label', () => {
render(
<MessageList

View File

@@ -23,9 +23,9 @@ const mockContact: Contact = {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -51,9 +51,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
name,
type,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,

View File

@@ -22,8 +22,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Test Contact',
type: CONTACT_TYPE_REPEATER,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -33,7 +34,6 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
};
}
@@ -139,9 +139,9 @@ describe('contact routing helpers', () => {
it('prefers routing override over learned route', () => {
const effective = getEffectiveContactRoute(
createContact({
last_path: 'AABB',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AABB',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'AE92F13E',
route_override_len: 2,
route_override_hash_mode: 1,
@@ -685,22 +685,39 @@ describe('isValidLocation', () => {
});
describe('formatDistance', () => {
it('formats distances under 1km in meters', () => {
expect(formatDistance(0.5)).toBe('500m');
expect(formatDistance(0.123)).toBe('123m');
expect(formatDistance(0.9999)).toBe('1000m');
const formatInteger = (value: number) => value.toLocaleString();
const formatOneDecimal = (value: number) =>
value.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
it('defaults to imperial formatting', () => {
expect(formatDistance(0.01)).toBe(`${formatInteger(33)}ft`);
expect(formatDistance(0.5)).toBe(`${formatOneDecimal(0.5 * 0.621371)}mi`);
expect(formatDistance(1)).toBe(`${formatOneDecimal(0.621371)}mi`);
});
it('formats distances at or above 1km with one decimal', () => {
expect(formatDistance(1)).toBe('1.0km');
expect(formatDistance(1.5)).toBe('1.5km');
expect(formatDistance(12.34)).toBe('12.3km');
expect(formatDistance(100)).toBe('100.0km');
it('formats metric distances in meters and kilometers', () => {
expect(formatDistance(0.5, 'metric')).toBe(`${formatInteger(500)}m`);
expect(formatDistance(0.123, 'metric')).toBe(`${formatInteger(123)}m`);
expect(formatDistance(0.9999, 'metric')).toBe(`${formatInteger(1000)}m`);
expect(formatDistance(1, 'metric')).toBe(`${formatOneDecimal(1)}km`);
expect(formatDistance(12.34, 'metric')).toBe(`${formatOneDecimal(12.34)}km`);
});
it('rounds meters to nearest integer', () => {
expect(formatDistance(0.4567)).toBe('457m');
expect(formatDistance(0.001)).toBe('1m');
it('formats smoot distances using 1.7018 meters per smoot', () => {
expect(formatDistance(0.0017018, 'smoots')).toBe(`${formatOneDecimal(1)} smoot`);
expect(formatDistance(0.001, 'smoots')).toBe(`${formatOneDecimal(0.6)} smoots`);
expect(formatDistance(1, 'smoots')).toBe(`${formatInteger(588)} smoots`);
});
it('applies locale separators to large values', () => {
expect(formatDistance(1.234, 'metric')).toBe(`${formatOneDecimal(1.234)}km`);
expect(formatDistance(1234, 'metric')).toBe(`${formatOneDecimal(1234)}km`);
expect(formatDistance(2.1, 'smoots')).toContain(
formatInteger(Math.round((2.1 * 1000) / 1.7018))
);
});
});

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