mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 20:06:13 +02:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 462ba8945f | |||
| f95745cb05 | |||
| 39ba88bc4b | |||
| e814653300 | |||
| e76d922752 | |||
| d0e02a42f8 | |||
| dbf14259dc | |||
| a9ac87e668 | |||
| f710a1f2d9 | |||
| 9f6c0f12c5 | |||
| 466f693c21 | |||
| 16f87e640f | |||
| 761fd82da6 | |||
| 2c1279eb9e | |||
| 047d713003 | |||
| 25041e1367 | |||
| b3fe717416 | |||
| 9a4e78c504 | |||
| d436de67a2 | |||
| 89cee49725 | |||
| b37ce89c96 | |||
| f0b7842c60 | |||
| 4eb29f376e | |||
| 82a6553539 | |||
| a69eb9c534 | |||
| 70aabb78aa | |||
| cafd9678ee | |||
| a8e346d0c5 | |||
| 55f05bf03b | |||
| 091ba06ccf | |||
| c5c828a4ed | |||
| 7eac3a9754 | |||
| 329df1a0d2 | |||
| ecb4c99a43 | |||
| 2f412e1a93 | |||
| 0353a98e87 | |||
| 3e2258c34b | |||
| e695d629b9 | |||
| 300677aca3 | |||
| b89f7ce76b | |||
| 82bd25a09f | |||
| 7528e4121f | |||
| b8f0228f68 | |||
| 25089930f1 | |||
| 291bd85c78 | |||
| 4bc87b4a0f | |||
| 6d0434d59e | |||
| f22184c166 | |||
| d10de8abf7 | |||
| 5f78294cd1 | |||
| 6b81dd3082 | |||
| cc2b16e53f | |||
| 330007e120 | |||
| f5a2a21f11 | |||
| a3e62885d4 | |||
| dbdd722c48 | |||
| c8c8e6b549 | |||
| b8683e57d8 | |||
| 491f159463 | |||
| ead74e975b | |||
| 4fbd245ee4 | |||
| dc7ec13cc5 | |||
| cfa2bf575c | |||
| e9ef68432a | |||
| 476adf393f | |||
| f7a311d74b | |||
| 09f807230b | |||
| c098f9eeb5 | |||
| 05493d06fc | |||
| 6c1b8bd7e9 |
@@ -0,0 +1,10 @@
|
|||||||
|
name: "RemoteTerm CodeQL config"
|
||||||
|
|
||||||
|
# Exclude rules that flag intentional design decisions:
|
||||||
|
# - AES-ECB is required by the MeshCore radio protocol wire format
|
||||||
|
# - Repeater/room passwords are not meaningfully sensitive secrets
|
||||||
|
query-filters:
|
||||||
|
- exclude:
|
||||||
|
id: py/weak-cryptographic-algorithm
|
||||||
|
- exclude:
|
||||||
|
id: js/clear-text-storage-of-sensitive-data
|
||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-checks:
|
backend-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [javascript-typescript, python]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
config-file: .github/codeql/codeql-config.yml
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
@@ -25,6 +25,9 @@ concurrency:
|
|||||||
group: publish-aur
|
group: publish-aur
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-aur:
|
publish-aur:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ references/
|
|||||||
|
|
||||||
# ancillary LLM files
|
# ancillary LLM files
|
||||||
.claude/
|
.claude/
|
||||||
|
.codex
|
||||||
|
|
||||||
# local Docker compose files
|
# local Docker compose files
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||||
|
| GET | `/api/radio/private-key` | Export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`) |
|
||||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
@@ -379,6 +380,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||||
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
| GET | `/api/settings/tracked-telemetry/schedule` | Current telemetry scheduling derivation and next-run-at timestamp |
|
||||||
|
| POST | `/api/settings/muted-channels/toggle` | Toggle muted status for a channel |
|
||||||
| GET | `/api/fanout` | List all fanout configs |
|
| GET | `/api/fanout` | List all fanout configs |
|
||||||
| POST | `/api/fanout` | Create new fanout config |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
@@ -503,6 +505,8 @@ mc.subscribe(EventType.ACK, handler)
|
|||||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||||
|
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||||
|
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||||
|
|
||||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,43 @@
|
|||||||
|
## [3.13.0] - 2026-04-30
|
||||||
|
|
||||||
|
* Feature: Error counts included in repeater telemetry
|
||||||
|
* Feature: RX error rate + percentage surfaced and tracked for repeaters
|
||||||
|
* Feature: Dynamic as-you-type text replacement for Cyrillic byte optimization
|
||||||
|
* Feature: Permit hourly checks for direct/routed repeaters
|
||||||
|
* Feature: Allow newlines in input
|
||||||
|
* Feature: Packet-send radio time added to packet analyzer
|
||||||
|
* Feature: Enable forced plaintext for Apprise
|
||||||
|
* Bugfix: Less annoying MQTT failure notifications with backoff
|
||||||
|
* Bugfis: Don't obscure input; use dvh everywhere
|
||||||
|
* Bugfix: Clearer save button for advert interval
|
||||||
|
* Misc: Library updates
|
||||||
|
* Misc: Rewrite 5xx to 4xx to avoid issues with proxies that don't react well to 503/504
|
||||||
|
|
||||||
|
## [3.12.3] - 2026-04-24
|
||||||
|
|
||||||
|
* Feature: Customizable Apprise strings
|
||||||
|
* Feature: Choose contact addition type
|
||||||
|
* Featuer: Make bulk-delete sortable by last-heard
|
||||||
|
* Misc: Bypass error on fail-to-unload-contact when it's not there
|
||||||
|
* Misc: Docs & test updates
|
||||||
|
|
||||||
|
## [3.12.2] - 2026-04-21
|
||||||
|
|
||||||
|
* Feature: Auto-disambiguate colliding LPP sensor names
|
||||||
|
* Feature: Radio config import/export
|
||||||
|
* Bugfix: Don't push stale firmware version/model on community MQTT
|
||||||
|
* Misc: Expose env vars in debug blob
|
||||||
|
* Misc: Longer linger for web push error
|
||||||
|
* Misc: Docs, test, & CI/CD improvements
|
||||||
|
|
||||||
|
## [3.12.1] - 2026-04-19
|
||||||
|
|
||||||
|
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
|
||||||
|
* Feature: Channel mute
|
||||||
|
* Misc: HA Documentation improvements
|
||||||
|
* Misc: Bump deps & update tests
|
||||||
|
* Misc: Improve warnings around web push in untrusted contexts
|
||||||
|
|
||||||
## [3.12.0] - 2026-04-17
|
## [3.12.0] - 2026-04-17
|
||||||
|
|
||||||
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
|
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
|
||||||
|
|||||||
+22
-13
@@ -9,26 +9,35 @@ COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build \
|
||||||
|
&& find dist -name '*.map' -delete
|
||||||
|
|
||||||
|
|
||||||
# Stage 2: Python runtime
|
# Stage 2: Install Python dependencies (uv stays in this stage only)
|
||||||
|
FROM python:3.13-slim AS python-deps
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /usr/local/bin/uv
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 3: Final runtime (no uv, no source maps)
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
ARG COMMIT_HASH=unknown
|
ARG COMMIT_HASH=unknown
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV COMMIT_HASH=${COMMIT_HASH}
|
ENV COMMIT_HASH=${COMMIT_HASH} \
|
||||||
|
PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
# Install uv
|
# Copy installed venv from deps stage
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /usr/local/bin/uv
|
COPY --from=python-deps /app/.venv ./.venv
|
||||||
|
|
||||||
# Copy dependency files first for layer caching
|
# Copy dependency metadata (pyproject.toml needed by app for version info)
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
# Install dependencies (no dev/test deps)
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
@@ -36,7 +45,7 @@ COPY app/ ./app/
|
|||||||
# Copy license attributions
|
# Copy license attributions
|
||||||
COPY LICENSES.md ./
|
COPY LICENSES.md ./
|
||||||
|
|
||||||
# Copy built frontend from first stage
|
# Copy built frontend from first stage (source maps already stripped)
|
||||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||||
|
|
||||||
# Create data directory for SQLite database
|
# Create data directory for SQLite database
|
||||||
@@ -44,5 +53,5 @@ RUN mkdir -p /app/data
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run the application (we retain root for max compatibility)
|
# Run uvicorn directly from the venv (no uv needed at runtime)
|
||||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
+1
-1
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### meshcore (2.3.2) — MIT
|
### meshcore (2.3.7) — MIT
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Full license text</summary>
|
<summary>Full license text</summary>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# RemoteTerm for MeshCore
|
# RemoteTerm for MeshCore
|
||||||
|
|
||||||
Backend server + browser interface for MeshCore mesh radio networks. Connect your radio over Serial, TCP, or BLE, and then you can:
|
Backend server + browser interface for MeshCore mesh radio networks, providing a rich, web-based power-user management and messaging system through a companion radio.
|
||||||
|
|
||||||
|
Connect your radio over Serial, TCP, or BLE, and then you can:
|
||||||
|
|
||||||
* Send and receive DMs and channel messages
|
* Send and receive DMs and channel messages
|
||||||
* Cache all received packets, decrypting as you gain keys
|
* Cache all received packets, decrypting as you gain keys
|
||||||
@@ -8,8 +10,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||||
* Access your radio remotely over your network or VPN
|
* Access your radio remotely over your network or VPN
|
||||||
* Search for hashtag channel names for channels you don't have keys for yet
|
* Search for hashtag channel names for channels you don't have keys for yet
|
||||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
* Forward packets, messages, and automatic repeater telemetry to MQTT, Home Assistant, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
* Use the more recent 1.14+ firmwares which support multibyte pathing
|
||||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||||
|
|
||||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
@@ -83,7 +85,7 @@ Access the app at http://localhost:8000.
|
|||||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
> Running on lightweight hardware, or just don't want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||||
@@ -118,7 +120,7 @@ bash scripts/setup/install_docker.sh
|
|||||||
|
|
||||||
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
|
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
|
||||||
|
|
||||||
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
Your local `docker-compose.yml` is gitignored so future pulls don't overwrite your Docker settings.
|
||||||
|
|
||||||
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||||
|
|
||||||
@@ -240,6 +242,7 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
|
|||||||
## Where To Go Next
|
## Where To Go Next
|
||||||
|
|
||||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||||
|
- Home Assistant-specific guidance and entity/sensor naming schemes: [README_HA.md](README_HA.md)
|
||||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
- 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
|
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||||
|
|
||||||
|
|||||||
+49
-6
@@ -1,24 +1,67 @@
|
|||||||
# Advanced Setup And Troubleshooting
|
# Advanced Setup And Troubleshooting
|
||||||
|
|
||||||
## Remediation Environment Variables
|
## Remediation & Advanced Environment Variables
|
||||||
|
|
||||||
These are intended for diagnosing or working around radios that behave oddly.
|
These are intended for diagnosing or working around radios that behave oddly, or enabling advanced functionality.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages ([docs](#message-poll-fallback)) |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send ([docs](#force-channel-slot-reconfigure)) |
|
||||||
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
|
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading ([docs](#autoevict-mode)) |
|
||||||
|
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot ([docs](#clock-wraparound)) |
|
||||||
|
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex for backup or migration. Only enable on a trusted network. Import via `PUT /api/radio/private-key` is always available. ([docs](#private-key-export)) |
|
||||||
|
|
||||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||||
|
|
||||||
- whether messages were left on the radio without reaching the app through event subscription
|
- whether messages were left on the radio without reaching the app through event subscription
|
||||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||||
|
|
||||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
If the audit finds a mismatch, you'll see an error in the application UI and your logs.
|
||||||
|
|
||||||
|
### Message Poll Fallback
|
||||||
|
|
||||||
|
If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net.
|
||||||
|
|
||||||
|
### Force Channel Slot Reconfigure
|
||||||
|
|
||||||
|
If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||||
|
|
||||||
|
### Clock Wraparound
|
||||||
|
|
||||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||||
|
|
||||||
|
### Private Key Export
|
||||||
|
|
||||||
|
`MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true` enables `GET /api/radio/private-key`, which returns the in-memory private key as hex for backup or migration. The key is held in memory only (exported from the radio on connect) and is never persisted to disk. Only enable this on a trusted network when you need to retrieve the key.
|
||||||
|
|
||||||
|
Import via `PUT /api/radio/private-key` is always available regardless of this setting — it is write-only and does not expose key material.
|
||||||
|
|
||||||
|
The Radio Settings config export/import feature uses these endpoints. When export is disabled, config exports will omit the private key and show a notice.
|
||||||
|
|
||||||
|
## Contact Loading Issues
|
||||||
|
|
||||||
|
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
|
||||||
|
|
||||||
|
On BLE connections with many contacts (or radios with large contact tables from organic advertisements), the initial contact enumeration may time out. If this happens, the app will still attempt to load your favorites and recent contacts onto the radio on a best-effort basis, but without a full snapshot of what's already on the radio, some adds may be redundant or fail.
|
||||||
|
|
||||||
|
If the radio's contact table is already full (from contacts added by advertisements or another client), the app may not be able to load all desired contacts. In this case you'll see a warning that auto-DM acking may not work for all contacts. To resolve this:
|
||||||
|
|
||||||
|
- **Clear the radio's contact table** using another MeshCore client (e.g., the official companion app), then restart RemoteTerm
|
||||||
|
- **Lower the contact fill target** in Radio Settings to reduce how many contacts the app tries to load
|
||||||
|
- **Enable autoevict mode** (see below) to let the radio automatically make room
|
||||||
|
- If you don't need auto-DM acking, you can safely ignore these warnings — **sending and receiving messages is never affected**
|
||||||
|
|
||||||
|
### Autoevict Mode
|
||||||
|
|
||||||
|
Setting `MESHCORE_LOAD_WITH_AUTOEVICT=true` enables an alternative contact loading strategy that avoids TABLE_FULL errors entirely. On connect, the app enables the radio's `AUTO_ADD_OVERWRITE_OLDEST` preference, which makes the radio automatically evict the oldest non-favorite contact when the contact table is full. This means:
|
||||||
|
|
||||||
|
- Contact adds never fail — the radio always makes room by evicting stale contacts
|
||||||
|
- The app can load contacts even when it can't enumerate the radio's existing contact table (e.g., on slow BLE connections)
|
||||||
|
- No contact removal step is needed during reconciliation
|
||||||
|
|
||||||
|
**Trade-off:** Contacts loaded by the app are not marked as radio-side favorites, so they are eviction candidates if the radio receives a new advertisement while full. In practice, freshly-loaded contacts have a recent `lastmod` timestamp and will be among the last to be evicted. If you disconnect the radio from RemoteTerm and use it standalone, your contacts will not be protected from eviction by newer advertisements.
|
||||||
|
|
||||||
## Sub-Path Reverse Proxy
|
## Sub-Path Reverse Proxy
|
||||||
|
|
||||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||||
|
|||||||
+194
-39
@@ -21,25 +21,23 @@ Devices will appear in HA under **Settings > Devices & Services > MQTT** within
|
|||||||
|
|
||||||
## How MeshCore IDs Map Into Home Assistant
|
## How MeshCore IDs Map Into Home Assistant
|
||||||
|
|
||||||
RemoteTerm uses each node's public key to derive a stable short identifier:
|
RemoteTerm uses each node's public key to derive a stable short identifier for MQTT topics:
|
||||||
|
|
||||||
- Full public key: `ae92577bae6c4f1d...`
|
- Full public key: `ae92577bae6c4f1d...`
|
||||||
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
||||||
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
|
- Example MQTT topic: `meshcore/ae92577bae6c/gps`
|
||||||
- Example runtime topic: `meshcore/ae92577bae6c/gps`
|
|
||||||
|
|
||||||
When this README shows `<node_id>`, it always means that 12-character value.
|
When this README shows `<node_id>`, it always means that 12-character value. Node IDs appear in:
|
||||||
|
|
||||||
The same node ID appears in:
|
- MQTT discovery topics under `homeassistant/...`
|
||||||
|
|
||||||
- Home Assistant entity IDs
|
|
||||||
- Home Assistant discovery topics under `homeassistant/...`
|
|
||||||
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
|
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
|
||||||
|
|
||||||
You can also see these IDs in RemoteTerm's Home Assistant integration UI:
|
**Entity IDs** are different — HA auto-generates them from the device name and entity name, not from the node ID. For example, a radio named "MyRadio" produces entities like `binary_sensor.myradio_connected` and `event.myradio_messages`. A contact named "Alice" produces `device_tracker.alice`. You can find your actual entity IDs in **Settings > Devices & Services > MQTT** in HA, and you can rename them in HA's UI without affecting the integration.
|
||||||
|
|
||||||
|
You can also see the MQTT topic IDs in RemoteTerm's Home Assistant integration UI:
|
||||||
|
|
||||||
- `What gets created in Home Assistant`
|
- `What gets created in Home Assistant`
|
||||||
- `Published Topic Summary`
|
- `Published topic summary`
|
||||||
|
|
||||||
## What Gets Created
|
## What Gets Created
|
||||||
|
|
||||||
@@ -49,8 +47,8 @@ Always created. Updates every 60 seconds.
|
|||||||
|
|
||||||
| Entity | Type | Description |
|
| Entity | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
|
| `binary_sensor.<radio_name>_connected` | Connectivity | Radio online/offline |
|
||||||
| `sensor.meshcore_<radio_node_id>_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
| `sensor.<radio_name>_noise_floor` | Signal strength | Radio noise floor (dBm) |
|
||||||
|
|
||||||
### Repeater Devices
|
### Repeater Devices
|
||||||
|
|
||||||
@@ -60,13 +58,13 @@ Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm'
|
|||||||
|
|
||||||
| Entity | Type | Unit | Description |
|
| Entity | Type | Unit | Description |
|
||||||
|--------|------|------|-------------|
|
|--------|------|------|-------------|
|
||||||
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
|
| `sensor.<repeater_name>_battery_voltage` | Voltage | V | Battery level |
|
||||||
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
|
| `sensor.<repeater_name>_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||||
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
| `sensor.<repeater_name>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||||
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
| `sensor.<repeater_name>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||||
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
|
| `sensor.<repeater_name>_packets_received` | -- | count | Total packets received |
|
||||||
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
|
| `sensor.<repeater_name>_packets_sent` | -- | count | Total packets sent |
|
||||||
| `sensor.meshcore_<repeater_node_id>_uptime` | Duration | s | Uptime since last reboot |
|
| `sensor.<repeater_name>_uptime` | Duration | s | Uptime since last reboot |
|
||||||
|
|
||||||
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
|
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
|
||||||
|
|
||||||
@@ -76,11 +74,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
|
|||||||
|
|
||||||
| Entity | Description |
|
| Entity | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `device_tracker.meshcore_<contact_node_id>` | GPS position (latitude/longitude) |
|
| `device_tracker.<contact_name>` | GPS position (latitude/longitude) |
|
||||||
|
|
||||||
### Message Event Entity
|
### Message Event Entity
|
||||||
|
|
||||||
A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
|
A single radio-scoped event entity, `event.<radio_name>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
|
||||||
|
|
||||||
| Attribute | Example | Description |
|
| Attribute | Example | Description |
|
||||||
|-----------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
@@ -95,9 +93,9 @@ A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, f
|
|||||||
|
|
||||||
## Entity Naming
|
## Entity Naming
|
||||||
|
|
||||||
Entity IDs use the first 12 characters of the node's public key as an identifier. For example, a contact with public key `ae92577bae6c...` gets entity ID `device_tracker.meshcore_ae92577bae6c`. You can rename entities in HA's UI without affecting the integration.
|
HA auto-generates entity IDs by slugifying the device name and entity name. For a radio named "My Radio", entities look like `binary_sensor.my_radio_connected` and `event.my_radio_messages`. For a repeater named "Hilltop", `sensor.hilltop_battery_voltage`. For a contact named "Alice", `device_tracker.alice`. You can rename entities in HA's UI without affecting the integration.
|
||||||
|
|
||||||
That same 12-character node ID is also used in the MQTT topic paths. For example:
|
MQTT topic paths use the 12-character node ID (first 12 hex characters of the public key). For example:
|
||||||
|
|
||||||
- Local radio health: `meshcore/<radio_node_id>/health`
|
- Local radio health: `meshcore/<radio_node_id>/health`
|
||||||
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
|
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
|
||||||
@@ -117,7 +115,7 @@ That same 12-character node ID is also used in the MQTT topic paths. For example
|
|||||||
|
|
||||||
Notify when a tracked repeater's battery drops below a threshold.
|
Notify when a tracked repeater's battery drops below a threshold.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_<repeater_node_id>_battery_voltage`, below `3.8`, action: notification.
|
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.<repeater_name>_battery_voltage`, below `3.8`, action: notification.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -125,22 +123,22 @@ automation:
|
|||||||
- alias: "Repeater battery low"
|
- alias: "Repeater battery low"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: numeric_state
|
- platform: numeric_state
|
||||||
entity_id: sensor.meshcore_aabbccddeeff_battery_voltage
|
entity_id: sensor.hilltop_battery_voltage
|
||||||
below: 3.8
|
below: 3.8
|
||||||
action:
|
action:
|
||||||
- service: notify.mobile_app_your_phone
|
- service: notify.mobile_app_your_phone
|
||||||
data:
|
data:
|
||||||
title: "Repeater Battery Low"
|
title: "Repeater Battery Low"
|
||||||
message: >-
|
message: >-
|
||||||
{{ state_attr('sensor.meshcore_aabbccddeeff_battery_voltage', 'friendly_name') }}
|
{{ state_attr('sensor.hilltop_battery_voltage', 'friendly_name') }}
|
||||||
is at {{ states('sensor.meshcore_aabbccddeeff_battery_voltage') }}V
|
is at {{ states('sensor.hilltop_battery_voltage') }}V
|
||||||
```
|
```
|
||||||
|
|
||||||
### Radio offline alert
|
### Radio offline alert
|
||||||
|
|
||||||
Notify if the radio has been disconnected for more than 5 minutes.
|
Notify if the radio has been disconnected for more than 5 minutes.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_<radio_node_id>_connected`, to `off`, for `00:05:00`, action: notification.
|
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.<radio_name>_connected`, to `off`, for `00:05:00`, action: notification.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -148,7 +146,7 @@ automation:
|
|||||||
- alias: "Radio offline"
|
- alias: "Radio offline"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: binary_sensor.meshcore_aabbccddeeff_connected
|
entity_id: binary_sensor.myradio_connected
|
||||||
to: "off"
|
to: "off"
|
||||||
for: "00:05:00"
|
for: "00:05:00"
|
||||||
action:
|
action:
|
||||||
@@ -166,7 +164,7 @@ Trigger when a message arrives in a specific channel. Two approaches:
|
|||||||
|
|
||||||
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
|
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages`, action: notification.
|
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages`, action: notification.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -174,7 +172,7 @@ automation:
|
|||||||
- alias: "Emergency channel alert"
|
- alias: "Emergency channel alert"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
action:
|
action:
|
||||||
- service: notify.mobile_app_your_phone
|
- service: notify.mobile_app_your_phone
|
||||||
data:
|
data:
|
||||||
@@ -188,7 +186,7 @@ automation:
|
|||||||
|
|
||||||
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
|
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
|
||||||
|
|
||||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages` > Add condition > Template > enter the template below.
|
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages` > Add condition > Template > enter the template below.
|
||||||
|
|
||||||
**YAML:**
|
**YAML:**
|
||||||
```yaml
|
```yaml
|
||||||
@@ -196,7 +194,7 @@ automation:
|
|||||||
- alias: "Emergency channel alert"
|
- alias: "Emergency channel alert"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
condition:
|
condition:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: >-
|
value_template: >-
|
||||||
@@ -218,7 +216,7 @@ automation:
|
|||||||
- alias: "DM from Alice"
|
- alias: "DM from Alice"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
condition:
|
condition:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: >-
|
value_template: >-
|
||||||
@@ -239,7 +237,7 @@ automation:
|
|||||||
- alias: "Keyword alert"
|
- alias: "Keyword alert"
|
||||||
trigger:
|
trigger:
|
||||||
- platform: state
|
- platform: state
|
||||||
entity_id: event.meshcore_aabbccddeeff_messages
|
entity_id: event.myradio_messages
|
||||||
condition:
|
condition:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: >-
|
value_template: >-
|
||||||
@@ -264,7 +262,7 @@ Add a sensor card to any dashboard:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: sensor
|
type: sensor
|
||||||
entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
entity: sensor.hilltop_battery_voltage
|
||||||
name: "Hilltop Repeater Battery"
|
name: "Hilltop Repeater Battery"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -274,14 +272,171 @@ Or an entities card for multiple repeaters:
|
|||||||
type: entities
|
type: entities
|
||||||
title: "Repeater Status"
|
title: "Repeater Status"
|
||||||
entities:
|
entities:
|
||||||
- entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
- entity: sensor.hilltop_battery_voltage
|
||||||
name: "Hilltop"
|
name: "Hilltop"
|
||||||
- entity: sensor.meshcore_ccdd11223344_battery_voltage
|
- entity: sensor.valley_battery_voltage
|
||||||
name: "Valley"
|
name: "Valley"
|
||||||
- entity: sensor.meshcore_eeff55667788_battery_voltage
|
- entity: sensor.ridge_battery_voltage
|
||||||
name: "Ridge"
|
name: "Ridge"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Full monitoring dashboard with message feed
|
||||||
|
|
||||||
|
This example creates a dashboard with repeater vitals, a live message feed, and a network activity graph. Replace the three slug values below to match your setup — find your entity IDs in **Settings > Devices & Services > MQTT**.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ┌─────────────────────────────────────────────────────┐
|
||||||
|
# │ Replace these three values to match your entities │
|
||||||
|
# │ │
|
||||||
|
# │ radio_slug: the prefix on your radio sensors │
|
||||||
|
# │ e.g. sensor.MYRADIO_noise_floor │
|
||||||
|
# │ repeater_slug: the prefix on your repeater sensors │
|
||||||
|
# │ e.g. sensor.HILLTOP_battery_voltage │
|
||||||
|
# │ message_event: your message event entity ID │
|
||||||
|
# │ e.g. event.MYRADIO_messages │
|
||||||
|
# └─────────────────────────────────────────────────────┘
|
||||||
|
#
|
||||||
|
# radio_slug: myradio
|
||||||
|
# repeater_slug: hilltop
|
||||||
|
# message_event: event.myradio_messages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1 — Dashboard YAML** (Settings > Dashboards > Add > edit in YAML):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
views:
|
||||||
|
- title: MeshCore
|
||||||
|
icon: mdi:radio-tower
|
||||||
|
cards:
|
||||||
|
- type: entities
|
||||||
|
title: Hilltop — Current # ← repeater name
|
||||||
|
state_color: true
|
||||||
|
entities:
|
||||||
|
- entity: sensor.hilltop_battery_voltage # ← repeater_slug
|
||||||
|
name: Battery
|
||||||
|
- entity: sensor.hilltop_noise_floor # ← repeater_slug
|
||||||
|
name: Noise Floor
|
||||||
|
- entity: sensor.hilltop_last_rssi # ← repeater_slug
|
||||||
|
name: Last RSSI
|
||||||
|
- entity: sensor.hilltop_last_snr # ← repeater_slug
|
||||||
|
name: Last SNR
|
||||||
|
- entity: sensor.hilltop_uptime # ← repeater_slug
|
||||||
|
name: Uptime
|
||||||
|
- entity: sensor.hilltop_packets_received # ← repeater_slug
|
||||||
|
name: Packets Rx
|
||||||
|
- entity: sensor.hilltop_packets_sent # ← repeater_slug
|
||||||
|
name: Packets Tx
|
||||||
|
|
||||||
|
- type: statistics-graph
|
||||||
|
title: Battery Voltage
|
||||||
|
entities:
|
||||||
|
- sensor.hilltop_battery_voltage # ← repeater_slug
|
||||||
|
stat_types: [mean, min, max]
|
||||||
|
days_to_show: 7
|
||||||
|
period: hour
|
||||||
|
|
||||||
|
- type: statistics-graph
|
||||||
|
title: Noise Floor
|
||||||
|
entities:
|
||||||
|
- sensor.hilltop_noise_floor # ← repeater_slug
|
||||||
|
stat_types: [mean, min, max]
|
||||||
|
days_to_show: 7
|
||||||
|
period: hour
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
title: Message Feed (Last 10)
|
||||||
|
content: |
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
{% set msg = states('input_text.meshcore_msg_' ~ i) %}
|
||||||
|
{% if msg and msg not in ['unknown', '', 'unavailable'] %}
|
||||||
|
{{ msg }}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if states('input_text.meshcore_msg_1') in ['unknown', '', 'unavailable'] %}
|
||||||
|
*No messages yet.*
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
- type: statistics-graph
|
||||||
|
title: Overall Packets Received
|
||||||
|
entities:
|
||||||
|
- sensor.myradio_packets_received # ← radio_slug
|
||||||
|
stat_types: [change]
|
||||||
|
days_to_show: 7
|
||||||
|
period: hour
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2 — Message feed helpers**: create 10 text helpers named `MeshCore Msg 1` through `MeshCore Msg 10` (Settings > Helpers > Add > Text). These act as a rolling buffer for the Markdown card above.
|
||||||
|
|
||||||
|
**Step 3 — Message feed automation** (Settings > Automations > Create > edit in YAML):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: MeshCore Message Feed Buffer
|
||||||
|
description: Rolling buffer of recent mesh messages for dashboard display
|
||||||
|
mode: queued
|
||||||
|
max: 10
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: event.myradio_messages # ← message_event
|
||||||
|
actions:
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_10
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_9') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_9
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_8') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_8
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_7') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_7
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_6') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_6
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_5') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_5
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_4') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_4
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_3') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_3
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_2') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_2
|
||||||
|
data:
|
||||||
|
value: "{{ states('input_text.meshcore_msg_1') }}"
|
||||||
|
- action: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: input_text.meshcore_msg_1
|
||||||
|
data:
|
||||||
|
value: >-
|
||||||
|
{{ as_timestamp(trigger.to_state.last_changed) |
|
||||||
|
timestamp_custom('%-I:%M %p') }} |
|
||||||
|
**{% if trigger.to_state.attributes.channel_name %}{{
|
||||||
|
trigger.to_state.attributes.channel_name }}{% else %}DM{% endif %}** |
|
||||||
|
{{ trigger.to_state.attributes.sender_name or 'Unknown' }}:
|
||||||
|
{{ (trigger.to_state.attributes.text or '')[:180] }}
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Devices don't appear in HA
|
### Devices don't appear in HA
|
||||||
|
|||||||
+9
-1
@@ -196,6 +196,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
### Radio
|
### Radio
|
||||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||||
|
- `GET /radio/private-key` — export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`)
|
||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
@@ -266,6 +267,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
- `POST /settings/blocked-names/toggle`
|
- `POST /settings/blocked-names/toggle`
|
||||||
- `POST /settings/tracked-telemetry/toggle`
|
- `POST /settings/tracked-telemetry/toggle`
|
||||||
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
|
||||||
|
- `POST /settings/muted-channels/toggle`
|
||||||
|
|
||||||
### Fanout
|
### Fanout
|
||||||
- `GET /fanout` — list all fanout configs
|
- `GET /fanout` — list all fanout configs
|
||||||
@@ -396,7 +398,7 @@ tests/
|
|||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
├── test_migrations.py # Schema migration system
|
├── test_mqtt_ha.py # MQTT HA (high-availability) behavior
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
@@ -415,7 +417,13 @@ tests/
|
|||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
|
├── test_push_send.py # Web Push send/dispatch
|
||||||
|
├── test_radio_stats.py # Radio stats sampling and noise-floor history
|
||||||
|
├── test_repeater_telemetry.py # Repeater telemetry history recording
|
||||||
|
├── test_service_installer.py # Service installer script behavior
|
||||||
|
├── test_sqs_fanout.py # SQS fanout module
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
|
├── test_telemetry_interval.py # Telemetry interval scheduling math
|
||||||
├── test_version_info.py # Version/build metadata resolution
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
└── test_websocket_route.py # WS endpoint lifecycle
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||||
)
|
)
|
||||||
|
enable_local_private_key_export: bool = False
|
||||||
|
load_with_autoevict: bool = False
|
||||||
skip_post_connect_sync: bool = False
|
skip_post_connect_sync: bool = False
|
||||||
basic_auth_username: str = ""
|
basic_auth_username: str = ""
|
||||||
basic_auth_password: str = ""
|
basic_auth_password: str = ""
|
||||||
|
|||||||
+18
-2
@@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
flood_scope_override TEXT,
|
flood_scope_override TEXT,
|
||||||
path_hash_mode_override INTEGER,
|
path_hash_mode_override INTEGER,
|
||||||
last_read_at INTEGER,
|
last_read_at INTEGER,
|
||||||
favorite INTEGER DEFAULT 0
|
favorite INTEGER DEFAULT 0,
|
||||||
|
muted INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -112,7 +113,10 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
discovery_blocked_types TEXT DEFAULT '[]',
|
discovery_blocked_types TEXT DEFAULT '[]',
|
||||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||||
auto_resend_channel INTEGER DEFAULT 0,
|
auto_resend_channel INTEGER DEFAULT 0,
|
||||||
telemetry_interval_hours INTEGER DEFAULT 8
|
telemetry_interval_hours INTEGER DEFAULT 8,
|
||||||
|
vapid_private_key TEXT DEFAULT '',
|
||||||
|
vapid_public_key TEXT DEFAULT '',
|
||||||
|
push_conversations TEXT DEFAULT '[]'
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||||
|
|
||||||
@@ -134,6 +138,18 @@ CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_success_at INTEGER,
|
||||||
|
failure_count INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(endpoint)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Indexes are created after migrations so that legacy databases have all
|
# Indexes are created after migrations so that legacy databases have all
|
||||||
|
|||||||
+207
-47
@@ -11,6 +11,37 @@ from app.path_utils import split_path_hex
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MAX_SEND_ATTEMPTS = 3
|
||||||
|
_RETRY_DELAY_S = 2
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
# Plain-text variants (no markdown formatting)
|
||||||
|
DEFAULT_BODY_FORMAT_DM_PLAIN = "DM: {sender_name}: {text} via: [{hops}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN = "{channel_name}: {sender_name}: {text} via: [{hops}]"
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN = "DM: {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN = "{channel_name}: {sender_name}: {text}"
|
||||||
|
|
||||||
|
# Variables available for user format strings
|
||||||
|
FORMAT_VARIABLES = (
|
||||||
|
"type",
|
||||||
|
"text",
|
||||||
|
"sender_name",
|
||||||
|
"sender_key",
|
||||||
|
"channel_name",
|
||||||
|
"conversation_key",
|
||||||
|
"hops",
|
||||||
|
"hops_backticked",
|
||||||
|
"hop_count",
|
||||||
|
"rssi",
|
||||||
|
"snr",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_urls(raw: str) -> list[str]:
|
def _parse_urls(raw: str) -> list[str]:
|
||||||
"""Split multi-line URL string into individual URLs."""
|
"""Split multi-line URL string into individual URLs."""
|
||||||
@@ -36,46 +67,111 @@ def _normalize_discord_url(url: str) -> str:
|
|||||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
def _compute_hops(data: dict) -> tuple[str, str, int]:
|
||||||
"""Build a human-readable notification body from message data."""
|
"""Extract hop info from message data. Returns (hops, hops_backticked, hop_count)."""
|
||||||
|
paths = data.get("paths")
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
path_str = first_path.get("path", "")
|
||||||
|
path_len = first_path.get("path_len")
|
||||||
|
else:
|
||||||
|
path_str = None
|
||||||
|
path_len = None
|
||||||
|
|
||||||
|
if path_str is None or path_str.strip() == "":
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
path_str = path_str.strip().lower()
|
||||||
|
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
||||||
|
hops = split_path_hex(path_str, hop_count)
|
||||||
|
if not hops:
|
||||||
|
return ("direct", "`direct`", 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
", ".join(hops),
|
||||||
|
", ".join(f"`{h}`" for h in hops),
|
||||||
|
len(hops),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_vars(data: dict) -> dict[str, str]:
|
||||||
|
"""Build the variable dict for format string substitution."""
|
||||||
|
hops_raw, hops_bt, hop_count = _compute_hops(data)
|
||||||
|
|
||||||
|
paths = data.get("paths")
|
||||||
|
rssi = ""
|
||||||
|
snr = ""
|
||||||
|
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||||
|
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
||||||
|
rssi_val = first_path.get("rssi")
|
||||||
|
snr_val = first_path.get("snr")
|
||||||
|
if rssi_val is not None:
|
||||||
|
rssi = str(rssi_val)
|
||||||
|
if snr_val is not None:
|
||||||
|
snr = str(snr_val)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": data.get("type", ""),
|
||||||
|
"text": get_fanout_message_text(data),
|
||||||
|
"sender_name": data.get("sender_name") or "Unknown",
|
||||||
|
"sender_key": data.get("sender_key") or "",
|
||||||
|
"channel_name": data.get("channel_name") or data.get("conversation_key", "channel"),
|
||||||
|
"conversation_key": data.get("conversation_key", ""),
|
||||||
|
"hops": hops_raw,
|
||||||
|
"hops_backticked": hops_bt,
|
||||||
|
"hop_count": str(hop_count),
|
||||||
|
"rssi": rssi,
|
||||||
|
"snr": snr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_format(fmt: str, variables: dict[str, str]) -> str:
|
||||||
|
"""Apply template variables in a single pass to avoid re-expanding substituted values."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _replacer(m: re.Match[str]) -> str:
|
||||||
|
key = m.group(1)
|
||||||
|
return variables.get(key, m.group(0))
|
||||||
|
|
||||||
|
return re.sub(r"\{(\w+)\}", _replacer, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_body(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
body_format_dm: str | None = None,
|
||||||
|
body_format_channel: str | None = None,
|
||||||
|
markdown: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Build a notification body from message data using format strings."""
|
||||||
|
if body_format_dm is None:
|
||||||
|
body_format_dm = DEFAULT_BODY_FORMAT_DM if markdown else DEFAULT_BODY_FORMAT_DM_PLAIN
|
||||||
|
if body_format_channel is None:
|
||||||
|
body_format_channel = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL if markdown else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
|
||||||
|
)
|
||||||
|
variables = _build_template_vars(data)
|
||||||
msg_type = data.get("type", "")
|
msg_type = data.get("type", "")
|
||||||
text = get_fanout_message_text(data)
|
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
|
||||||
sender_name = data.get("sender_name") or "Unknown"
|
try:
|
||||||
|
return _apply_format(fmt, variables)
|
||||||
via = ""
|
except Exception:
|
||||||
if include_path:
|
logger.warning("Apprise format string error, falling back to default")
|
||||||
paths = data.get("paths")
|
if markdown:
|
||||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
first_path = paths[0] if isinstance(paths[0], dict) else {}
|
|
||||||
path_str = first_path.get("path", "")
|
|
||||||
path_len = first_path.get("path_len")
|
|
||||||
else:
|
else:
|
||||||
path_str = None
|
default = (
|
||||||
path_len = None
|
DEFAULT_BODY_FORMAT_DM_PLAIN
|
||||||
|
if msg_type == "PRIV"
|
||||||
if msg_type == "PRIV" and path_str is None:
|
else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
|
||||||
via = " **via:** [`direct`]"
|
)
|
||||||
elif path_str is not None:
|
return _apply_format(default, variables)
|
||||||
path_str = path_str.strip().lower()
|
|
||||||
if path_str == "":
|
|
||||||
via = " **via:** [`direct`]"
|
|
||||||
else:
|
|
||||||
hop_count = path_len if isinstance(path_len, int) else len(path_str) // 2
|
|
||||||
hops = split_path_hex(path_str, hop_count)
|
|
||||||
if hops:
|
|
||||||
hop_list = ", ".join(f"`{h}`" for h in hops)
|
|
||||||
via = f" **via:** [{hop_list}]"
|
|
||||||
|
|
||||||
if msg_type == "PRIV":
|
|
||||||
return f"**DM:** {sender_name}: {text}{via}"
|
|
||||||
|
|
||||||
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
|
|
||||||
return f"**{channel_name}:** {sender_name}: {text}{via}"
|
|
||||||
|
|
||||||
|
|
||||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: bool = True) -> bool:
|
||||||
"""Send notification synchronously via Apprise. Returns True on success."""
|
"""Send notification synchronously via Apprise. Returns True on success."""
|
||||||
import apprise as apprise_lib
|
import apprise as apprise_lib
|
||||||
|
from apprise import NotifyFormat
|
||||||
|
|
||||||
urls = _parse_urls(urls_raw)
|
urls = _parse_urls(urls_raw)
|
||||||
if not urls:
|
if not urls:
|
||||||
@@ -87,7 +183,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
|||||||
url = _normalize_discord_url(url)
|
url = _normalize_discord_url(url)
|
||||||
notifier.add(url)
|
notifier.add(url)
|
||||||
|
|
||||||
return bool(notifier.notify(title="", body=body))
|
body_fmt = NotifyFormat.MARKDOWN if markdown else NotifyFormat.TEXT
|
||||||
|
return bool(notifier.notify(title="", body=body, body_format=body_fmt))
|
||||||
|
|
||||||
|
|
||||||
class AppriseModule(FanoutModule):
|
class AppriseModule(FanoutModule):
|
||||||
@@ -106,19 +203,82 @@ class AppriseModule(FanoutModule):
|
|||||||
return
|
return
|
||||||
|
|
||||||
preserve_identity = self.config.get("preserve_identity", True)
|
preserve_identity = self.config.get("preserve_identity", True)
|
||||||
include_path = self.config.get("include_path", True)
|
markdown = self.config.get("markdown_format", True)
|
||||||
body = _format_body(data, include_path=include_path)
|
|
||||||
|
|
||||||
try:
|
# Read format strings; treat empty/whitespace as unset (use default).
|
||||||
success = await asyncio.to_thread(
|
# Fall back to legacy include_path for pre-migration configs.
|
||||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
body_format_dm = (self.config.get("body_format_dm") or "").strip() or None
|
||||||
)
|
body_format_channel = (self.config.get("body_format_channel") or "").strip() or None
|
||||||
self._set_last_error(None if success else "Apprise notify returned failure")
|
if body_format_dm is None or body_format_channel is None:
|
||||||
if not success:
|
include_path = self.config.get("include_path", True)
|
||||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
if body_format_dm is None:
|
||||||
except Exception as exc:
|
if markdown:
|
||||||
self._set_last_error(str(exc))
|
body_format_dm = (
|
||||||
logger.exception("Apprise send error for module %s", self.config_id)
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body_format_dm = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM_PLAIN
|
||||||
|
if include_path
|
||||||
|
else _DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN
|
||||||
|
)
|
||||||
|
if body_format_channel is None:
|
||||||
|
if markdown:
|
||||||
|
body_format_channel = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL
|
||||||
|
if include_path
|
||||||
|
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
body_format_channel = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
|
||||||
|
if include_path
|
||||||
|
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
body = _format_body(
|
||||||
|
data,
|
||||||
|
body_format_dm=body_format_dm,
|
||||||
|
body_format_channel=body_format_channel,
|
||||||
|
markdown=markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(_MAX_SEND_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
success = await asyncio.to_thread(
|
||||||
|
_send_sync,
|
||||||
|
urls,
|
||||||
|
body,
|
||||||
|
preserve_identity=preserve_identity,
|
||||||
|
markdown=markdown,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
self._set_last_error(None)
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"Apprise notification failed for module %s (attempt %d/%d)",
|
||||||
|
self.config_id,
|
||||||
|
attempt + 1,
|
||||||
|
_MAX_SEND_ATTEMPTS,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
logger.warning(
|
||||||
|
"Apprise send error for module %s (attempt %d/%d): %s",
|
||||||
|
self.config_id,
|
||||||
|
attempt + 1,
|
||||||
|
_MAX_SEND_ATTEMPTS,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
if attempt < _MAX_SEND_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(_RETRY_DELAY_S)
|
||||||
|
|
||||||
|
# All attempts exhausted
|
||||||
|
if last_exc is not None:
|
||||||
|
self._set_last_error(str(last_exc))
|
||||||
|
else:
|
||||||
|
self._set_last_error("Apprise notify returned failure")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ def _get_client_version() -> str:
|
|||||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||||
"""Manages the community MQTT connection and publishes raw packets."""
|
"""Manages the community MQTT connection and publishes raw packets."""
|
||||||
|
|
||||||
_backoff_max = 60
|
_backoff_max = 3600
|
||||||
_log_prefix = "Community MQTT"
|
_log_prefix = "Community MQTT"
|
||||||
_not_configured_timeout: float | None = 30
|
_not_configured_timeout: float | None = 30
|
||||||
|
|
||||||
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
|||||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||||
|
|
||||||
device_info = await self._fetch_device_info()
|
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
|
||||||
|
# radio_lifecycle) over the per-module _cached_device_info, which was only
|
||||||
|
# cleared on module restart and therefore served stale firmware versions after
|
||||||
|
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
|
||||||
|
# where device_info_loaded is False.
|
||||||
|
if radio_manager.device_info_loaded:
|
||||||
|
raw_ver = radio_manager.firmware_version or "unknown"
|
||||||
|
fw_build = radio_manager.firmware_build or ""
|
||||||
|
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
|
||||||
|
device_info = {
|
||||||
|
"model": radio_manager.device_model or "unknown",
|
||||||
|
"firmware_version": fw_str,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
device_info = await self._fetch_device_info()
|
||||||
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
||||||
|
|
||||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ class PrivateMqttSettings(Protocol):
|
|||||||
class MqttPublisher(BaseMqttPublisher):
|
class MqttPublisher(BaseMqttPublisher):
|
||||||
"""Manages an MQTT connection and publishes mesh network events."""
|
"""Manages an MQTT connection and publishes mesh network events."""
|
||||||
|
|
||||||
_backoff_max = 30
|
_backoff_max = 3600
|
||||||
_log_prefix = "MQTT"
|
_log_prefix = "MQTT"
|
||||||
|
|
||||||
def _is_configured(self) -> bool:
|
def _is_configured(self) -> bool:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
self.connected: bool = False
|
self.connected: bool = False
|
||||||
self.integration_name: str = ""
|
self.integration_name: str = ""
|
||||||
self._last_error: str | None = None
|
self._last_error: str | None = None
|
||||||
|
self._error_notified: bool = False
|
||||||
|
|
||||||
def set_integration_name(self, name: str) -> None:
|
def set_integration_name(self, name: str) -> None:
|
||||||
"""Attach the configured fanout-module name for operator-facing logs."""
|
"""Attach the configured fanout-module name for operator-facing logs."""
|
||||||
@@ -104,6 +105,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
self._client = None
|
self._client = None
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
|
self._error_notified = False
|
||||||
|
|
||||||
async def restart(self, settings: object) -> None:
|
async def restart(self, settings: object) -> None:
|
||||||
"""Called when settings change — stop + start."""
|
"""Called when settings change — stop + start."""
|
||||||
@@ -217,6 +219,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
self._client = client
|
self._client = client
|
||||||
self.connected = True
|
self.connected = True
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
|
self._error_notified = False
|
||||||
backoff = _BACKOFF_MIN
|
backoff = _BACKOFF_MIN
|
||||||
|
|
||||||
title, detail = self._on_connected(settings)
|
title, detail = self._on_connected(settings)
|
||||||
@@ -281,9 +284,11 @@ class BaseMqttPublisher(ABC):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
title, detail = self._on_error()
|
if not self._error_notified:
|
||||||
broadcast_error(title, detail)
|
title, detail = self._on_error()
|
||||||
_broadcast_health()
|
broadcast_error(title, detail)
|
||||||
|
_broadcast_health()
|
||||||
|
self._error_notified = True
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s connection error. This is usually transient network noise; "
|
"%s connection error. This is usually transient network noise; "
|
||||||
"if it self-resolves, it is generally not a concern: %s "
|
"if it self-resolves, it is generally not a concern: %s "
|
||||||
|
|||||||
+35
-10
@@ -81,6 +81,15 @@ _REPEATER_SENSORS: list[dict[str, Any]] = [
|
|||||||
"unit": None,
|
"unit": None,
|
||||||
"precision": 0,
|
"precision": 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"field": "recv_errors",
|
||||||
|
"name": "RX Errors",
|
||||||
|
"object_id": "recv_errors",
|
||||||
|
"device_class": None,
|
||||||
|
"state_class": "total_increasing",
|
||||||
|
"unit": None,
|
||||||
|
"precision": 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"field": "uptime_seconds",
|
"field": "uptime_seconds",
|
||||||
"name": "Uptime",
|
"name": "Uptime",
|
||||||
@@ -115,6 +124,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
|||||||
return f"lpp_{type_name}_ch{channel}"
|
return f"lpp_{type_name}_ch{channel}"
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]:
|
||||||
|
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
|
||||||
|
|
||||||
|
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
|
||||||
|
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
result: list[tuple[dict, str, int]] = []
|
||||||
|
for sensor in lpp_sensors:
|
||||||
|
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||||
|
n = counts.get(base, 0) + 1
|
||||||
|
counts[base] = n
|
||||||
|
result.append((sensor, base if n == 1 else f"{base}_{n}", n))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
@@ -123,8 +148,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if field is not None:
|
if field is not None:
|
||||||
payload[field] = data.get(field)
|
payload[field] = data.get(field)
|
||||||
|
|
||||||
for sensor in data.get("lpp_sensors", []) or []:
|
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
|
||||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
|
||||||
payload[key] = sensor.get("value")
|
payload[key] = sensor.get("value")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
@@ -139,16 +163,19 @@ def _lpp_discovery_configs(
|
|||||||
) -> list[tuple[str, dict]]:
|
) -> list[tuple[str, dict]]:
|
||||||
"""Build HA discovery configs for a repeater's LPP sensors."""
|
"""Build HA discovery configs for a repeater's LPP sensors."""
|
||||||
configs: list[tuple[str, dict]] = []
|
configs: list[tuple[str, dict]] = []
|
||||||
for sensor in lpp_sensors:
|
for sensor, field, occurrence in _assign_lpp_keys(lpp_sensors):
|
||||||
type_name = sensor.get("type_name", "unknown")
|
type_name = sensor.get("type_name", "unknown")
|
||||||
channel = sensor.get("channel", 0)
|
channel = sensor.get("channel", 0)
|
||||||
field = _lpp_sensor_key(type_name, channel)
|
|
||||||
meta = _LPP_HA_META.get(type_name, {})
|
meta = _LPP_HA_META.get(type_name, {})
|
||||||
|
|
||||||
nid = _node_id(pub_key)
|
nid = _node_id(pub_key)
|
||||||
object_id = field
|
object_id = field
|
||||||
display = type_name.replace("_", " ").title()
|
display = type_name.replace("_", " ").title()
|
||||||
name = f"{display} (Ch {channel})"
|
name = (
|
||||||
|
f"{display} (Ch {channel})"
|
||||||
|
if occurrence == 1
|
||||||
|
else f"{display} (Ch {channel}) #{occurrence}"
|
||||||
|
)
|
||||||
|
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -289,7 +316,7 @@ def _device_payload(
|
|||||||
class _HaMqttPublisher(BaseMqttPublisher):
|
class _HaMqttPublisher(BaseMqttPublisher):
|
||||||
"""Thin MQTT lifecycle wrapper for the HA discovery module."""
|
"""Thin MQTT lifecycle wrapper for the HA discovery module."""
|
||||||
|
|
||||||
_backoff_max = 30
|
_backoff_max = 3600
|
||||||
_log_prefix = "HA-MQTT"
|
_log_prefix = "HA-MQTT"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -450,7 +477,7 @@ def _message_event_discovery_config(
|
|||||||
device = _device_payload(radio_key, radio_name, "Radio")
|
device = _device_payload(radio_key, radio_name, "Radio")
|
||||||
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
"name": "MeshCore Messages",
|
"name": "Messages",
|
||||||
"unique_id": f"meshcore_{nid}_messages",
|
"unique_id": f"meshcore_{nid}_messages",
|
||||||
"device": device,
|
"device": device,
|
||||||
"state_topic": f"{prefix}/{nid}/events/message",
|
"state_topic": f"{prefix}/{nid}/events/message",
|
||||||
@@ -731,9 +758,7 @@ class MqttHaModule(FanoutModule):
|
|||||||
payload = _repeater_telemetry_payload(data)
|
payload = _repeater_telemetry_payload(data)
|
||||||
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
||||||
rediscover = False
|
rediscover = False
|
||||||
for sensor in lpp_sensors:
|
for _, key, _ in _assign_lpp_keys(lpp_sensors):
|
||||||
# Check if discovery for this sensor has been published yet
|
|
||||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
|
||||||
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
||||||
if expected_topic not in self._discovery_topics:
|
if expected_topic not in self._discovery_topics:
|
||||||
rediscover = True
|
rediscover = True
|
||||||
|
|||||||
+21
-2
@@ -176,8 +176,27 @@ app.add_middleware(
|
|||||||
|
|
||||||
@app.exception_handler(RadioDisconnectedError)
|
@app.exception_handler(RadioDisconnectedError)
|
||||||
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
|
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
|
||||||
"""Return 503 when a radio disconnect race occurs during an operation."""
|
"""Return 423 when a radio disconnect race occurs during an operation."""
|
||||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
return JSONResponse(status_code=423, content={"detail": "Radio not connected"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_server_errors(request: Request, call_next):
|
||||||
|
"""Capture 5xx errors and unhandled exceptions into the log ring buffer.
|
||||||
|
|
||||||
|
Starlette writes unhandled-exception tracebacks to stderr, bypassing
|
||||||
|
Python logging, so they never reach the debug dump. This middleware
|
||||||
|
catches them and logs via ``logger.exception()`` so the full traceback
|
||||||
|
is preserved in the ring buffer for the ``GET /api/debug`` snapshot.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||||
|
raise
|
||||||
|
if response.status_code >= 500:
|
||||||
|
logger.error("HTTP %d on %s %s", response.status_code, request.method, request.url.path)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# API routes - all prefixed with /api for production compatibility
|
# API routes - all prefixed with /api for production compatibility
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add muted column to channels table."""
|
||||||
|
table_check = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
|
||||||
|
)
|
||||||
|
if not await table_check.fetchone():
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(channels)")
|
||||||
|
columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
|
||||||
|
if "muted" not in columns:
|
||||||
|
await conn.execute("ALTER TABLE channels ADD COLUMN muted INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||||
|
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||||
|
)
|
||||||
|
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||||
|
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Migrate apprise fanout configs from include_path boolean to format strings."""
|
||||||
|
table_check = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='fanout_configs'"
|
||||||
|
)
|
||||||
|
if not await table_check.fetchone():
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("SELECT id, config FROM fanout_configs WHERE type = 'apprise'")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
config_id = row["id"] if isinstance(row, dict) else row[0]
|
||||||
|
config_raw = row["config"] if isinstance(row, dict) else row[1]
|
||||||
|
try:
|
||||||
|
config = json.loads(config_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if already migrated
|
||||||
|
if "body_format_dm" in config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
include_path = config.get("include_path", True)
|
||||||
|
config["body_format_dm"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||||
|
)
|
||||||
|
config["body_format_channel"] = (
|
||||||
|
DEFAULT_BODY_FORMAT_CHANNEL if include_path else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||||
|
)
|
||||||
|
config.pop("include_path", None)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE fanout_configs SET config = ? WHERE id = ?",
|
||||||
|
(json.dumps(config), config_id),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Migrated apprise config %s: include_path=%s -> format strings", config_id, include_path
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add telemetry_routed_hourly boolean column to app_settings."""
|
||||||
|
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||||
|
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||||
|
if "telemetry_routed_hourly" not in columns:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE app_settings ADD COLUMN telemetry_routed_hourly INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
@@ -221,6 +221,9 @@ class CreateContactRequest(BaseModel):
|
|||||||
|
|
||||||
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
public_key: str = Field(min_length=64, max_length=64, description="Public key (64-char hex)")
|
||||||
name: str | None = Field(default=None, description="Display name for the contact")
|
name: str | None = Field(default=None, description="Display name for the contact")
|
||||||
|
type: int = Field(
|
||||||
|
default=0, ge=0, le=3, description="Contact type (0=unknown, 1=client, 2=repeater, 3=room)"
|
||||||
|
)
|
||||||
try_historical: bool = Field(
|
try_historical: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Attempt to decrypt historical DM packets for this contact",
|
description="Attempt to decrypt historical DM packets for this contact",
|
||||||
@@ -346,6 +349,7 @@ class Channel(BaseModel):
|
|||||||
)
|
)
|
||||||
last_read_at: int | None = None # Server-side read state tracking
|
last_read_at: int | None = None # Server-side read state tracking
|
||||||
favorite: bool = False
|
favorite: bool = False
|
||||||
|
muted: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ChannelMessageCounts(BaseModel):
|
class ChannelMessageCounts(BaseModel):
|
||||||
@@ -444,6 +448,8 @@ class RawPacketDecryptedInfo(BaseModel):
|
|||||||
sender: str | None = None
|
sender: str | None = None
|
||||||
channel_key: str | None = None
|
channel_key: str | None = None
|
||||||
contact_key: str | None = None
|
contact_key: str | None = None
|
||||||
|
sender_timestamp: int | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RawPacketBroadcast(BaseModel):
|
class RawPacketBroadcast(BaseModel):
|
||||||
@@ -536,6 +542,7 @@ class RepeaterStatusResponse(BaseModel):
|
|||||||
flood_dups: int = Field(description="Duplicate flood packets")
|
flood_dups: int = Field(description="Duplicate flood packets")
|
||||||
direct_dups: int = Field(description="Duplicate direct packets")
|
direct_dups: int = Field(description="Duplicate direct packets")
|
||||||
full_events: int = Field(description="Full event queue count")
|
full_events: int = Field(description="Full event queue count")
|
||||||
|
recv_errors: int | None = Field(default=None, description="Radio-level RX packet errors")
|
||||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||||
default_factory=list, description="Recent telemetry history snapshots"
|
default_factory=list, description="Recent telemetry history snapshots"
|
||||||
)
|
)
|
||||||
@@ -850,6 +857,13 @@ class AppSettings(BaseModel):
|
|||||||
"tracked repeaters so daily checks stay under a 24/day ceiling."
|
"tracked repeaters so daily checks stay under a 24/day ceiling."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
telemetry_routed_hourly: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"When enabled, tracked repeaters with a direct or routed (non-flood) "
|
||||||
|
"path are polled every hour instead of on the normal scheduled interval."
|
||||||
|
),
|
||||||
|
)
|
||||||
auto_resend_channel: bool = Field(
|
auto_resend_channel: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
|
|||||||
@@ -366,6 +366,8 @@ async def process_raw_packet(
|
|||||||
sender=result["sender"],
|
sender=result["sender"],
|
||||||
channel_key=result.get("channel_key"),
|
channel_key=result.get("channel_key"),
|
||||||
contact_key=result.get("contact_key"),
|
contact_key=result.get("contact_key"),
|
||||||
|
sender_timestamp=result.get("sender_timestamp"),
|
||||||
|
message=result.get("message"),
|
||||||
)
|
)
|
||||||
if result["decrypted"]
|
if result["decrypted"]
|
||||||
else None,
|
else None,
|
||||||
@@ -428,6 +430,8 @@ async def _process_group_text(
|
|||||||
"sender": decrypted.sender,
|
"sender": decrypted.sender,
|
||||||
"message_id": msg_id, # None if duplicate, msg_id if new
|
"message_id": msg_id, # None if duplicate, msg_id if new
|
||||||
"channel_key": channel.key,
|
"channel_key": channel.key,
|
||||||
|
"sender_timestamp": decrypted.timestamp,
|
||||||
|
"message": decrypted.message,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Couldn't decrypt with any known key
|
# Couldn't decrypt with any known key
|
||||||
@@ -694,6 +698,8 @@ async def _process_direct_message(
|
|||||||
"sender": contact.name or contact.public_key[:12],
|
"sender": contact.name or contact.public_key[:12],
|
||||||
"message_id": msg_id,
|
"message_id": msg_id,
|
||||||
"contact_key": contact.public_key,
|
"contact_key": contact.public_key,
|
||||||
|
"sender_timestamp": result.timestamp,
|
||||||
|
"message": result.message,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Couldn't decrypt with any known contact
|
# Couldn't decrypt with any known contact
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pywebpush import WebPushException
|
|||||||
|
|
||||||
from app.push.send import send_push
|
from app.push.send import send_push
|
||||||
from app.push.vapid import get_vapid_private_key
|
from app.push.vapid import get_vapid_private_key
|
||||||
|
from app.repository.channels import ChannelRepository
|
||||||
from app.repository.push_subscriptions import PushSubscriptionRepository
|
from app.repository.push_subscriptions import PushSubscriptionRepository
|
||||||
from app.repository.settings import AppSettingsRepository
|
from app.repository.settings import AppSettingsRepository
|
||||||
|
|
||||||
@@ -102,6 +103,15 @@ class PushManager:
|
|||||||
if state_key not in push_conversations:
|
if state_key not in push_conversations:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Skip muted channels
|
||||||
|
if data.get("type") == "CHAN" and data.get("conversation_key"):
|
||||||
|
try:
|
||||||
|
ch = await ChannelRepository.get_by_key(data["conversation_key"])
|
||||||
|
if ch and ch.muted:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Push dispatch: failed to check channel mute state", exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subs = await PushSubscriptionRepository.get_all()
|
subs = await PushSubscriptionRepository.get_all()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+339
-85
@@ -43,9 +43,41 @@ from app.websocket import broadcast_error, broadcast_event
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_MAX_CHANNELS = 40
|
DEFAULT_MAX_CHANNELS = 40
|
||||||
|
_GET_CONTACTS_TIMEOUT = 10
|
||||||
|
|
||||||
AdvertMode = Literal["flood", "zero_hop"]
|
AdvertMode = Literal["flood", "zero_hop"]
|
||||||
|
|
||||||
|
_AUTO_ADD_OVERWRITE_OLDEST = 0x01
|
||||||
|
_RADIO_CONTACT_FAVORITE = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
async def _enable_autoevict_on_radio(mc: MeshCore) -> bool:
|
||||||
|
"""Ensure the radio's AUTO_ADD_OVERWRITE_OLDEST preference bit is set."""
|
||||||
|
try:
|
||||||
|
current = await mc.commands.get_autoadd_config()
|
||||||
|
if current is None or current.type == EventType.ERROR:
|
||||||
|
logger.warning("Could not read autoadd config from radio: %s", current)
|
||||||
|
return False
|
||||||
|
current_flags = current.payload.get("config", 0)
|
||||||
|
if current_flags & _AUTO_ADD_OVERWRITE_OLDEST:
|
||||||
|
logger.debug("Radio autoevict already enabled (autoadd_config=0x%02x)", current_flags)
|
||||||
|
return True
|
||||||
|
new_flags = current_flags | _AUTO_ADD_OVERWRITE_OLDEST
|
||||||
|
result = await mc.commands.set_autoadd_config(new_flags)
|
||||||
|
if result is not None and result.type == EventType.OK:
|
||||||
|
logger.info(
|
||||||
|
"Enabled radio autoevict (autoadd_config 0x%02x -> 0x%02x)",
|
||||||
|
current_flags,
|
||||||
|
new_flags,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to enable radio autoevict: %s", result)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error enabling radio autoevict: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||||
"""Return key contact fields for sync failure diagnostics."""
|
"""Return key contact fields for sync failure diagnostics."""
|
||||||
@@ -239,7 +271,7 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
|||||||
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||||
|
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts(timeout=_GET_CONTACTS_TIMEOUT)
|
||||||
if result is None or result.type == EventType.ERROR:
|
if result is None or result.type == EventType.ERROR:
|
||||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||||
return False
|
return False
|
||||||
@@ -430,6 +462,16 @@ async def ensure_default_channels() -> None:
|
|||||||
|
|
||||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||||
"""Run fast startup sync, then background contact reconcile."""
|
"""Run fast startup sync, then background contact reconcile."""
|
||||||
|
autoevict_requested = settings.load_with_autoevict
|
||||||
|
autoevict = False
|
||||||
|
|
||||||
|
if autoevict_requested:
|
||||||
|
autoevict = await _enable_autoevict_on_radio(mc)
|
||||||
|
if not autoevict:
|
||||||
|
logger.warning(
|
||||||
|
"Autoevict requested but unavailable; falling back to snapshot-based "
|
||||||
|
"background contact reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||||
@@ -441,9 +483,25 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
|||||||
# Ensure default channels exist
|
# Ensure default channels exist
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
|
||||||
|
snapshot_failed = "error" in contacts_result
|
||||||
|
if snapshot_failed and not autoevict:
|
||||||
|
logger.warning(
|
||||||
|
"Radio contact snapshot failed — attempting best-effort contact "
|
||||||
|
"loading without a full picture of what's already on the radio"
|
||||||
|
)
|
||||||
|
broadcast_error(
|
||||||
|
"Could not enumerate radio contacts",
|
||||||
|
"Loading favorites and recent contacts on a best-effort basis — "
|
||||||
|
"some adds may be redundant or fail if the radio's contact table "
|
||||||
|
"is already full. Set MESHCORE_LOAD_WITH_AUTOEVICT=true for more "
|
||||||
|
"reliable loading without needing to read the radio first. "
|
||||||
|
"See 'Contact Loading Issues' in the Advanced Setup documentation.",
|
||||||
|
)
|
||||||
|
|
||||||
start_background_contact_reconciliation(
|
start_background_contact_reconciliation(
|
||||||
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||||
expected_mc=mc,
|
expected_mc=mc,
|
||||||
|
autoevict=autoevict,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1045,7 +1103,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
|||||||
synced = 0
|
synced = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts(timeout=_GET_CONTACTS_TIMEOUT)
|
||||||
|
|
||||||
if result is None or result.type == EventType.ERROR:
|
if result is None or result.type == EventType.ERROR:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1108,12 +1166,24 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
*,
|
*,
|
||||||
initial_radio_contacts: dict[str, dict],
|
initial_radio_contacts: dict[str, dict],
|
||||||
expected_mc: MeshCore,
|
expected_mc: MeshCore,
|
||||||
|
autoevict: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Converge radio contacts toward the desired favorites+recents working set."""
|
"""Converge radio contacts toward the desired favorites+recents working set.
|
||||||
|
|
||||||
|
When *autoevict* is ``True`` the removal phase is skipped entirely and the
|
||||||
|
desired working set is blind-refreshed. Re-adding the full desired list
|
||||||
|
refreshes each contact's recency on supported firmware, so one successful
|
||||||
|
full pass converges the radio toward the desired working set without relying
|
||||||
|
on a stale contact snapshot.
|
||||||
|
"""
|
||||||
radio_contacts = dict(initial_radio_contacts)
|
radio_contacts = dict(initial_radio_contacts)
|
||||||
removed = 0
|
removed = 0
|
||||||
loaded = 0
|
loaded = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
|
table_full = False
|
||||||
|
autoevict_next_index = 0
|
||||||
|
autoevict_full_pass_retries = 0
|
||||||
|
_MAX_AUTOEVICT_RETRIES = 3
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -1121,18 +1191,32 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
logger.info("Stopping background contact reconcile: radio transport changed")
|
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Pre-lock snapshot for quick-exit checks; authoritative list is
|
||||||
|
# re-fetched inside the radio lock below.
|
||||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_fill_contacts = [
|
||||||
|
contact for contact in selected_contacts if len(contact.public_key) >= 64
|
||||||
|
]
|
||||||
|
|
||||||
|
if autoevict:
|
||||||
|
if not desired_fill_contacts:
|
||||||
|
logger.info(
|
||||||
|
"Background contact blind fill complete: no desired contacts selected"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if autoevict_next_index >= len(desired_fill_contacts):
|
||||||
|
autoevict_next_index = 0
|
||||||
desired_contacts = {
|
desired_contacts = {
|
||||||
contact.public_key.lower(): contact
|
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||||
for contact in selected_contacts
|
|
||||||
if len(contact.public_key) >= 64
|
|
||||||
}
|
}
|
||||||
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
removable_keys = (
|
||||||
|
[] if autoevict else [key for key in radio_contacts if key not in desired_contacts]
|
||||||
|
)
|
||||||
missing_contacts = [
|
missing_contacts = [
|
||||||
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||||
]
|
]
|
||||||
|
|
||||||
if not removable_keys and not missing_contacts:
|
if not autoevict and not removable_keys and not missing_contacts:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Background contact reconcile complete: %d contacts on radio working set",
|
"Background contact reconcile complete: %d contacts on radio working set",
|
||||||
len(radio_contacts),
|
len(radio_contacts),
|
||||||
@@ -1140,6 +1224,8 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
break
|
break
|
||||||
|
|
||||||
progressed = False
|
progressed = False
|
||||||
|
autoevict_pass_complete = False
|
||||||
|
autoevict_pass_failed = False
|
||||||
try:
|
try:
|
||||||
async with radio_manager.radio_operation(
|
async with radio_manager.radio_operation(
|
||||||
"background_contact_reconcile",
|
"background_contact_reconcile",
|
||||||
@@ -1153,100 +1239,237 @@ async def _reconcile_radio_contacts_in_background(
|
|||||||
|
|
||||||
budget = CONTACT_RECONCILE_BATCH_SIZE
|
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_fill_contacts = [
|
||||||
|
contact for contact in selected_contacts if len(contact.public_key) >= 64
|
||||||
|
]
|
||||||
|
if autoevict and autoevict_next_index >= len(desired_fill_contacts):
|
||||||
|
autoevict_next_index = 0
|
||||||
desired_contacts = {
|
desired_contacts = {
|
||||||
contact.public_key.lower(): contact
|
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||||
for contact in selected_contacts
|
|
||||||
if len(contact.public_key) >= 64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for public_key in list(radio_contacts):
|
if not autoevict:
|
||||||
if budget <= 0:
|
for public_key in list(radio_contacts):
|
||||||
break
|
|
||||||
if public_key in desired_contacts:
|
|
||||||
continue
|
|
||||||
|
|
||||||
remove_payload = (
|
|
||||||
mc.get_contact_by_key_prefix(public_key[:12])
|
|
||||||
or radio_contacts.get(public_key)
|
|
||||||
or {"public_key": public_key}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
|
||||||
except Exception as exc:
|
|
||||||
failed += 1
|
|
||||||
budget -= 1
|
|
||||||
logger.warning(
|
|
||||||
"Error removing contact %s during background reconcile: %s",
|
|
||||||
public_key[:12],
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
budget -= 1
|
|
||||||
if remove_result.type == EventType.OK:
|
|
||||||
radio_contacts.pop(public_key, None)
|
|
||||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
|
||||||
removed += 1
|
|
||||||
progressed = True
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
logger.warning(
|
|
||||||
"Failed to remove contact %s during background reconcile: %s",
|
|
||||||
public_key[:12],
|
|
||||||
remove_result.payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
if budget > 0:
|
|
||||||
for public_key, contact in desired_contacts.items():
|
|
||||||
if budget <= 0:
|
if budget <= 0:
|
||||||
break
|
break
|
||||||
if public_key in radio_contacts:
|
if public_key in desired_contacts:
|
||||||
continue
|
|
||||||
|
|
||||||
if mc.get_contact_by_key_prefix(public_key[:12]):
|
|
||||||
radio_contacts[public_key] = {"public_key": public_key}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
remove_payload = (
|
||||||
|
mc.get_contact_by_key_prefix(public_key[:12])
|
||||||
|
or radio_contacts.get(public_key)
|
||||||
|
or {"public_key": public_key}
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
add_payload = contact.to_radio_dict()
|
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||||
add_result = await mc.commands.add_contact(add_payload)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
budget -= 1
|
budget -= 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Error adding contact %s during background reconcile: %s",
|
"Error removing contact %s during background reconcile: %s",
|
||||||
public_key[:12],
|
public_key[:12],
|
||||||
exc,
|
exc,
|
||||||
exc_info=True,
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
budget -= 1
|
budget -= 1
|
||||||
if add_result.type == EventType.OK:
|
not_found = (
|
||||||
radio_contacts[public_key] = add_payload
|
remove_result.type != EventType.OK
|
||||||
loaded += 1
|
and isinstance(remove_result.payload, dict)
|
||||||
|
and remove_result.payload.get("error_code") == 2
|
||||||
|
)
|
||||||
|
if remove_result.type == EventType.OK or not_found:
|
||||||
|
radio_contacts.pop(public_key, None)
|
||||||
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
|
removed += 1
|
||||||
progressed = True
|
progressed = True
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
reason = add_result.payload
|
|
||||||
hint = ""
|
|
||||||
if reason is None:
|
|
||||||
hint = (
|
|
||||||
" (no response from radio — if this repeats, check for "
|
|
||||||
"serial port contention from another process or try a "
|
|
||||||
"power cycle)"
|
|
||||||
)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to add contact %s during background reconcile: %s%s",
|
"Failed to remove contact %s during background reconcile: %s",
|
||||||
public_key[:12],
|
public_key[:12],
|
||||||
reason,
|
remove_result.payload,
|
||||||
hint,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if budget > 0:
|
||||||
|
if autoevict:
|
||||||
|
# Budget is consumed by the slice bound rather than
|
||||||
|
# per-operation decrement — autoevict skips the
|
||||||
|
# removal phase so the full budget is always available.
|
||||||
|
batch_contacts = desired_fill_contacts[
|
||||||
|
autoevict_next_index : autoevict_next_index + budget
|
||||||
|
]
|
||||||
|
processed_contacts = 0
|
||||||
|
for contact in batch_contacts:
|
||||||
|
public_key = contact.public_key.lower()
|
||||||
|
try:
|
||||||
|
add_payload = contact.to_radio_dict()
|
||||||
|
# In autoevict mode, app-loaded contacts should
|
||||||
|
# remain evictable by the radio even if the
|
||||||
|
# stored contact record carries the favorite bit.
|
||||||
|
add_payload["flags"] = (
|
||||||
|
int(add_payload.get("flags", 0)) & ~_RADIO_CONTACT_FAVORITE
|
||||||
|
)
|
||||||
|
add_result = await mc.commands.add_contact(add_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"Error blind-filling contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
autoevict_pass_failed = True
|
||||||
|
processed_contacts += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if add_result.type == EventType.OK:
|
||||||
|
radio_contacts[public_key] = add_payload
|
||||||
|
loaded += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
autoevict_pass_failed = True
|
||||||
|
reason = add_result.payload
|
||||||
|
if isinstance(reason, dict) and reason.get("error_code") == 3:
|
||||||
|
logger.warning(
|
||||||
|
"Radio contact table full — stopping "
|
||||||
|
"contact reconcile (loaded %d this cycle)",
|
||||||
|
loaded,
|
||||||
|
)
|
||||||
|
table_full = True
|
||||||
|
break
|
||||||
|
hint = ""
|
||||||
|
if reason is None:
|
||||||
|
hint = (
|
||||||
|
" (no response from radio — if this repeats, check for "
|
||||||
|
"serial port contention from another process or try a "
|
||||||
|
"power cycle)"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Failed to blind-fill contact %s during background reconcile: %s%s",
|
||||||
|
public_key[:12],
|
||||||
|
reason,
|
||||||
|
hint,
|
||||||
|
)
|
||||||
|
processed_contacts += 1
|
||||||
|
|
||||||
|
autoevict_next_index += processed_contacts
|
||||||
|
autoevict_pass_complete = autoevict_next_index >= len(
|
||||||
|
desired_fill_contacts
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for public_key, contact in desired_contacts.items():
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
if public_key in radio_contacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||||
|
radio_contacts[public_key] = {"public_key": public_key}
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_payload = contact.to_radio_dict()
|
||||||
|
add_result = await mc.commands.add_contact(add_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
budget -= 1
|
||||||
|
logger.warning(
|
||||||
|
"Error adding contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget -= 1
|
||||||
|
if add_result.type == EventType.OK:
|
||||||
|
radio_contacts[public_key] = add_payload
|
||||||
|
loaded += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
reason = add_result.payload
|
||||||
|
if isinstance(reason, dict) and reason.get("error_code") == 3:
|
||||||
|
logger.warning(
|
||||||
|
"Radio contact table full — stopping "
|
||||||
|
"contact reconcile (loaded %d this cycle)",
|
||||||
|
loaded,
|
||||||
|
)
|
||||||
|
table_full = True
|
||||||
|
break
|
||||||
|
hint = ""
|
||||||
|
if reason is None:
|
||||||
|
hint = (
|
||||||
|
" (no response from radio — if this repeats, check for "
|
||||||
|
"serial port contention from another process or try a "
|
||||||
|
"power cycle)"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Failed to add contact %s during background reconcile: %s%s",
|
||||||
|
public_key[:12],
|
||||||
|
reason,
|
||||||
|
hint,
|
||||||
|
)
|
||||||
except RadioOperationBusyError:
|
except RadioOperationBusyError:
|
||||||
logger.debug("Background contact reconcile yielding: radio busy")
|
logger.debug("Background contact reconcile yielding: radio busy")
|
||||||
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if table_full:
|
||||||
|
if autoevict:
|
||||||
|
logger.error(
|
||||||
|
"We're expecting the radio to be in AUTO_ADD_OVERWRITE_OLDEST mode, "
|
||||||
|
"so a full-table error means we have no idea what is going on with "
|
||||||
|
"this radio; it is misbehaving. You should consider DM auto-acking "
|
||||||
|
"to be unreliable and/or not working for this radio. Sending and "
|
||||||
|
"receiving messages are not impacted by this error unless other "
|
||||||
|
"things are broken on your radio."
|
||||||
|
)
|
||||||
|
broadcast_error(
|
||||||
|
"Could not load all desired contacts onto the radio for auto-DM ack",
|
||||||
|
"Despite having auto-evict enabled, we got a contact-table-full error "
|
||||||
|
"from your radio. DM auto-ack is likely unavailable.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normal_table_full_message = (
|
||||||
|
"The radio's contact table is full. Clearing your radio contacts "
|
||||||
|
"using another client, lowering your contact fill target in "
|
||||||
|
"settings, or setting MESHCORE_LOAD_WITH_AUTOEVICT=true may "
|
||||||
|
"relieve this. See 'Contact Loading Issues' in the Advanced "
|
||||||
|
"README.md"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"Contact reconcile hit TABLE_FULL. %s",
|
||||||
|
normal_table_full_message,
|
||||||
|
)
|
||||||
|
broadcast_error(
|
||||||
|
"Could not load all desired contacts onto the radio for auto-DM ack",
|
||||||
|
normal_table_full_message,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if autoevict and autoevict_pass_complete:
|
||||||
|
if autoevict_pass_failed:
|
||||||
|
autoevict_full_pass_retries += 1
|
||||||
|
if autoevict_full_pass_retries >= _MAX_AUTOEVICT_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
"Background contact blind fill giving up after %d full passes "
|
||||||
|
"with persistent failures (loaded %d, failed %d)",
|
||||||
|
autoevict_full_pass_retries,
|
||||||
|
loaded,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
autoevict_next_index = 0
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Background contact blind fill complete: refreshed %d desired contacts",
|
||||||
|
len(desired_fill_contacts),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||||
if not progressed:
|
if not progressed:
|
||||||
continue
|
continue
|
||||||
@@ -1269,6 +1492,7 @@ def start_background_contact_reconciliation(
|
|||||||
*,
|
*,
|
||||||
initial_radio_contacts: dict[str, dict],
|
initial_radio_contacts: dict[str, dict],
|
||||||
expected_mc: MeshCore,
|
expected_mc: MeshCore,
|
||||||
|
autoevict: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start or replace the background contact reconcile task for the current radio."""
|
"""Start or replace the background contact reconcile task for the current radio."""
|
||||||
global _contact_reconcile_task
|
global _contact_reconcile_task
|
||||||
@@ -1280,11 +1504,13 @@ def start_background_contact_reconciliation(
|
|||||||
_reconcile_radio_contacts_in_background(
|
_reconcile_radio_contacts_in_background(
|
||||||
initial_radio_contacts=initial_radio_contacts,
|
initial_radio_contacts=initial_radio_contacts,
|
||||||
expected_mc=expected_mc,
|
expected_mc=expected_mc,
|
||||||
|
autoevict=autoevict,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started background contact reconcile for %d radio contact(s)",
|
"Started background contact reconcile for %d radio contact(s)%s",
|
||||||
len(initial_radio_contacts),
|
len(initial_radio_contacts),
|
||||||
|
" (autoevict mode)" if autoevict else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1595,6 +1821,7 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
|||||||
"flood_dups": status.get("flood_dups", 0),
|
"flood_dups": status.get("flood_dups", 0),
|
||||||
"direct_dups": status.get("direct_dups", 0),
|
"direct_dups": status.get("direct_dups", 0),
|
||||||
"full_events": status.get("full_evts", 0),
|
"full_events": status.get("full_evts", 0),
|
||||||
|
"recv_errors": status.get("recv_errors"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Best-effort LPP sensor fetch — failure here does not fail the overall
|
# Best-effort LPP sensor fetch — failure here does not fail the overall
|
||||||
@@ -1663,8 +1890,13 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _run_telemetry_cycle() -> None:
|
async def _run_telemetry_cycle(*, routed_only: bool = False) -> None:
|
||||||
"""Collect one telemetry sample from every tracked repeater."""
|
"""Collect one telemetry sample from tracked repeaters.
|
||||||
|
|
||||||
|
When *routed_only* is True, only repeaters whose effective route is
|
||||||
|
``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected.
|
||||||
|
This is used by the hourly routed-path fast-poll feature.
|
||||||
|
"""
|
||||||
if not radio_manager.is_connected:
|
if not radio_manager.is_connected:
|
||||||
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||||
return
|
return
|
||||||
@@ -1674,9 +1906,7 @@ async def _run_telemetry_cycle() -> None:
|
|||||||
if not tracked:
|
if not tracked:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
candidates: list[tuple[str, Contact]] = []
|
||||||
collected = 0
|
|
||||||
|
|
||||||
for pub_key in tracked:
|
for pub_key in tracked:
|
||||||
contact = await ContactRepository.get_by_key(pub_key)
|
contact = await ContactRepository.get_by_key(pub_key)
|
||||||
if not contact or contact.type != 2:
|
if not contact or contact.type != 2:
|
||||||
@@ -1685,7 +1915,24 @@ async def _run_telemetry_cycle() -> None:
|
|||||||
pub_key[:12],
|
pub_key[:12],
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0):
|
||||||
|
continue
|
||||||
|
candidates.append((pub_key, contact))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
if routed_only:
|
||||||
|
logger.debug("Telemetry collect: no routed repeaters to poll this hour")
|
||||||
|
return
|
||||||
|
|
||||||
|
label = "routed" if routed_only else "full"
|
||||||
|
logger.info(
|
||||||
|
"Telemetry collect: starting %s cycle for %d repeater(s)",
|
||||||
|
label,
|
||||||
|
len(candidates),
|
||||||
|
)
|
||||||
|
collected = 0
|
||||||
|
|
||||||
|
for _pub_key, contact in candidates:
|
||||||
try:
|
try:
|
||||||
async with radio_manager.radio_operation(
|
async with radio_manager.radio_operation(
|
||||||
"telemetry_collect",
|
"telemetry_collect",
|
||||||
@@ -1697,13 +1944,14 @@ async def _run_telemetry_cycle() -> None:
|
|||||||
except RadioOperationBusyError:
|
except RadioOperationBusyError:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Telemetry collect: radio busy, skipping %s",
|
"Telemetry collect: radio busy, skipping %s",
|
||||||
pub_key[:12],
|
contact.public_key[:12],
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Telemetry collect: cycle complete, %d/%d successful",
|
"Telemetry collect: %s cycle complete, %d/%d successful",
|
||||||
|
label,
|
||||||
collected,
|
collected,
|
||||||
len(tracked),
|
len(candidates),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1733,9 +1981,15 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None:
|
|||||||
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count)
|
effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count)
|
||||||
if effective_hours <= 0:
|
if effective_hours <= 0:
|
||||||
return
|
return
|
||||||
if now.hour % effective_hours != 0:
|
|
||||||
return
|
is_normal_cycle = now.hour % effective_hours == 0
|
||||||
await _run_telemetry_cycle()
|
|
||||||
|
if is_normal_cycle:
|
||||||
|
# Normal scheduled boundary: collect ALL tracked repeaters.
|
||||||
|
await _run_telemetry_cycle()
|
||||||
|
elif app_settings.telemetry_routed_hourly:
|
||||||
|
# Hourly routed-path fast-poll: only repeaters with a non-flood route.
|
||||||
|
await _run_telemetry_cycle(routed_only=True)
|
||||||
|
|
||||||
|
|
||||||
async def _telemetry_collect_loop() -> None:
|
async def _telemetry_collect_loop() -> None:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ChannelRepository:
|
|||||||
async with db.readonly() as conn:
|
async with db.readonly() as conn:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
|
||||||
FROM channels
|
FROM channels
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
""",
|
""",
|
||||||
@@ -45,6 +45,7 @@ class ChannelRepository:
|
|||||||
path_hash_mode_override=row["path_hash_mode_override"],
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
favorite=bool(row["favorite"]),
|
favorite=bool(row["favorite"]),
|
||||||
|
muted=bool(row["muted"]),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class ChannelRepository:
|
|||||||
async with db.readonly() as conn:
|
async with db.readonly() as conn:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
|
||||||
FROM channels
|
FROM channels
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
"""
|
||||||
@@ -69,6 +70,7 @@ class ChannelRepository:
|
|||||||
path_hash_mode_override=row["path_hash_mode_override"],
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
favorite=bool(row["favorite"]),
|
favorite=bool(row["favorite"]),
|
||||||
|
muted=bool(row["muted"]),
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -84,6 +86,17 @@ class ChannelRepository:
|
|||||||
rowcount = cursor.rowcount
|
rowcount = cursor.rowcount
|
||||||
return rowcount > 0
|
return rowcount > 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_muted(key: str, value: bool) -> bool:
|
||||||
|
"""Set or clear the muted flag for a channel. Returns True if row was found."""
|
||||||
|
async with db.tx() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"UPDATE channels SET muted = ? WHERE key = ?",
|
||||||
|
(1 if value else 0, key.upper()),
|
||||||
|
) as cursor:
|
||||||
|
rowcount = cursor.rowcount
|
||||||
|
return rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(key: str) -> None:
|
async def delete(key: str) -> None:
|
||||||
"""Delete a channel by key."""
|
"""Delete a channel by key."""
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ class MessageRepository:
|
|||||||
JOIN channels c ON m.conversation_key = c.key
|
JOIN channels c ON m.conversation_key = c.key
|
||||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||||
|
AND COALESCE(c.muted, 0) = 0
|
||||||
{blocked_sql}
|
{blocked_sql}
|
||||||
GROUP BY m.conversation_key
|
GROUP BY m.conversation_key
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class AppSettingsRepository:
|
|||||||
advert_interval, last_advert_time, flood_scope,
|
advert_interval, last_advert_time, flood_scope,
|
||||||
blocked_keys, blocked_names, discovery_blocked_types,
|
blocked_keys, blocked_names, discovery_blocked_types,
|
||||||
tracked_telemetry_repeaters, auto_resend_channel,
|
tracked_telemetry_repeaters, auto_resend_channel,
|
||||||
telemetry_interval_hours
|
telemetry_interval_hours, telemetry_routed_hourly
|
||||||
FROM app_settings WHERE id = 1
|
FROM app_settings WHERE id = 1
|
||||||
"""
|
"""
|
||||||
) as cursor:
|
) as cursor:
|
||||||
@@ -113,6 +113,12 @@ class AppSettingsRepository:
|
|||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS
|
telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS
|
||||||
|
|
||||||
|
# Parse telemetry_routed_hourly boolean
|
||||||
|
try:
|
||||||
|
telemetry_routed_hourly = bool(row["telemetry_routed_hourly"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
telemetry_routed_hourly = False
|
||||||
|
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
max_radio_contacts=row["max_radio_contacts"],
|
max_radio_contacts=row["max_radio_contacts"],
|
||||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||||
@@ -126,6 +132,7 @@ class AppSettingsRepository:
|
|||||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||||
auto_resend_channel=auto_resend_channel,
|
auto_resend_channel=auto_resend_channel,
|
||||||
telemetry_interval_hours=telemetry_interval_hours,
|
telemetry_interval_hours=telemetry_interval_hours,
|
||||||
|
telemetry_routed_hourly=telemetry_routed_hourly,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -144,6 +151,7 @@ class AppSettingsRepository:
|
|||||||
tracked_telemetry_repeaters: list[str] | None = None,
|
tracked_telemetry_repeaters: list[str] | None = None,
|
||||||
auto_resend_channel: bool | None = None,
|
auto_resend_channel: bool | None = None,
|
||||||
telemetry_interval_hours: int | None = None,
|
telemetry_interval_hours: int | None = None,
|
||||||
|
telemetry_routed_hourly: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply field updates using an already-acquired connection.
|
"""Apply field updates using an already-acquired connection.
|
||||||
|
|
||||||
@@ -201,6 +209,10 @@ class AppSettingsRepository:
|
|||||||
updates.append("telemetry_interval_hours = ?")
|
updates.append("telemetry_interval_hours = ?")
|
||||||
params.append(telemetry_interval_hours)
|
params.append(telemetry_interval_hours)
|
||||||
|
|
||||||
|
if telemetry_routed_hourly is not None:
|
||||||
|
updates.append("telemetry_routed_hourly = ?")
|
||||||
|
params.append(1 if telemetry_routed_hourly else 0)
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||||
async with conn.execute(query, params):
|
async with conn.execute(query, params):
|
||||||
@@ -229,6 +241,7 @@ class AppSettingsRepository:
|
|||||||
tracked_telemetry_repeaters: list[str] | None = None,
|
tracked_telemetry_repeaters: list[str] | None = None,
|
||||||
auto_resend_channel: bool | None = None,
|
auto_resend_channel: bool | None = None,
|
||||||
telemetry_interval_hours: int | None = None,
|
telemetry_interval_hours: int | None = None,
|
||||||
|
telemetry_routed_hourly: bool | None = None,
|
||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
"""Update app settings. Only provided fields are updated."""
|
"""Update app settings. Only provided fields are updated."""
|
||||||
async with db.tx() as conn:
|
async with db.tx() as conn:
|
||||||
@@ -246,6 +259,7 @@ class AppSettingsRepository:
|
|||||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||||
auto_resend_channel=auto_resend_channel,
|
auto_resend_channel=auto_resend_channel,
|
||||||
telemetry_interval_hours=telemetry_interval_hours,
|
telemetry_interval_hours=telemetry_interval_hours,
|
||||||
|
telemetry_routed_hourly=telemetry_routed_hourly,
|
||||||
)
|
)
|
||||||
return await AppSettingsRepository._get_in_conn(conn)
|
return await AppSettingsRepository._get_in_conn(conn)
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ async def _resolve_contact_or_404(
|
|||||||
|
|
||||||
|
|
||||||
async def _ensure_on_radio(mc, contact: Contact) -> None:
|
async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||||
"""Add a contact to the radio for routing, raising 500 on failure."""
|
"""Add a contact to the radio for routing, raising 422 on failure."""
|
||||||
add_result = await mc.commands.add_contact(contact.to_radio_dict())
|
add_result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||||
if add_result is not None and add_result.type == EventType.ERROR:
|
if add_result is not None and add_result.type == EventType.ERROR:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}"
|
status_code=422, detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -315,6 +315,7 @@ async def create_contact(
|
|||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
|
type=request.type,
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
@@ -451,7 +452,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
raise HTTPException(status_code=422, detail=f"Failed to send trace: {result.payload}")
|
||||||
|
|
||||||
# Wait for the matching TRACE_DATA event
|
# Wait for the matching TRACE_DATA event
|
||||||
event = await mc.wait_for_event(
|
event = await mc.wait_for_event(
|
||||||
@@ -461,7 +462,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
raise HTTPException(status_code=408, detail="No trace response heard")
|
||||||
|
|
||||||
trace = event.payload
|
trace = event.payload
|
||||||
path = trace.get("path", [])
|
path = trace.get("path", [])
|
||||||
@@ -505,7 +506,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
|||||||
result = await mc.commands.send_path_discovery(contact.public_key)
|
result = await mc.commands.send_path_discovery(contact.public_key)
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail=f"Failed to send path discovery: {result.payload}",
|
detail=f"Failed to send path discovery: {result.payload}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -517,7 +518,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
|||||||
await response_task
|
await response_task
|
||||||
|
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=504, detail="No path discovery response heard")
|
raise HTTPException(status_code=408, detail="No path discovery response heard")
|
||||||
|
|
||||||
payload = event.payload
|
payload = event.payload
|
||||||
forward_path = str(payload.get("out_path") or "")
|
forward_path = str(payload.get("out_path") or "")
|
||||||
|
|||||||
+34
-5
@@ -64,7 +64,6 @@ class DebugRuntimeInfo(BaseModel):
|
|||||||
path_hash_mode_supported: bool
|
path_hash_mode_supported: bool
|
||||||
channel_slot_reuse_enabled: bool
|
channel_slot_reuse_enabled: bool
|
||||||
channel_send_cache_capacity: int
|
channel_send_cache_capacity: int
|
||||||
remediation_flags: dict[str, bool]
|
|
||||||
|
|
||||||
|
|
||||||
class DebugContactAudit(BaseModel):
|
class DebugContactAudit(BaseModel):
|
||||||
@@ -110,6 +109,21 @@ class DebugHealthSummary(BaseModel):
|
|||||||
basic_auth_enabled: bool = False
|
basic_auth_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DebugEnvironment(BaseModel):
|
||||||
|
connection_type: str
|
||||||
|
serial_port: str
|
||||||
|
serial_baudrate: int
|
||||||
|
tcp_host: str
|
||||||
|
tcp_port: int
|
||||||
|
ble_address: str
|
||||||
|
log_level: str
|
||||||
|
database_path: str
|
||||||
|
disable_bots: bool
|
||||||
|
enable_message_poll_fallback: bool
|
||||||
|
force_channel_slot_reconfigure: bool
|
||||||
|
load_with_autoevict: bool
|
||||||
|
|
||||||
|
|
||||||
class DebugAppSettings(BaseModel):
|
class DebugAppSettings(BaseModel):
|
||||||
max_radio_contacts: int
|
max_radio_contacts: int
|
||||||
auto_decrypt_dm_on_advert: bool
|
auto_decrypt_dm_on_advert: bool
|
||||||
@@ -123,6 +137,7 @@ class DebugSnapshotResponse(BaseModel):
|
|||||||
captured_at: str
|
captured_at: str
|
||||||
system: DebugSystemInfo
|
system: DebugSystemInfo
|
||||||
application: DebugApplicationInfo
|
application: DebugApplicationInfo
|
||||||
|
environment: DebugEnvironment
|
||||||
health: DebugHealthSummary
|
health: DebugHealthSummary
|
||||||
settings: DebugAppSettings
|
settings: DebugAppSettings
|
||||||
runtime: DebugRuntimeInfo
|
runtime: DebugRuntimeInfo
|
||||||
@@ -203,6 +218,23 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_environment() -> DebugEnvironment:
|
||||||
|
return DebugEnvironment(
|
||||||
|
connection_type=settings.connection_type,
|
||||||
|
serial_port=settings.serial_port,
|
||||||
|
serial_baudrate=settings.serial_baudrate,
|
||||||
|
tcp_host=settings.tcp_host,
|
||||||
|
tcp_port=settings.tcp_port,
|
||||||
|
ble_address=settings.ble_address,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
database_path=settings.database_path,
|
||||||
|
disable_bots=settings.disable_bots,
|
||||||
|
enable_message_poll_fallback=settings.enable_message_poll_fallback,
|
||||||
|
force_channel_slot_reconfigure=settings.force_channel_slot_reconfigure,
|
||||||
|
load_with_autoevict=settings.load_with_autoevict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||||
return DebugAppSettings(
|
return DebugAppSettings(
|
||||||
max_radio_contacts=app_settings.max_radio_contacts,
|
max_radio_contacts=app_settings.max_radio_contacts,
|
||||||
@@ -393,6 +425,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
captured_at=datetime.now(UTC).isoformat(),
|
captured_at=datetime.now(UTC).isoformat(),
|
||||||
system=_build_system_info(),
|
system=_build_system_info(),
|
||||||
application=_build_application_info(),
|
application=_build_application_info(),
|
||||||
|
environment=_build_environment(),
|
||||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||||
settings=_build_debug_app_settings(app_settings),
|
settings=_build_debug_app_settings(app_settings),
|
||||||
runtime=DebugRuntimeInfo(
|
runtime=DebugRuntimeInfo(
|
||||||
@@ -404,10 +437,6 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||||
remediation_flags={
|
|
||||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
|
||||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
database=DebugDatabaseInfo(
|
database=DebugDatabaseInfo(
|
||||||
total_dms=message_totals["total_dms"],
|
total_dms=message_totals["total_dms"],
|
||||||
|
|||||||
@@ -259,6 +259,25 @@ def _validate_apprise_config(config: dict) -> None:
|
|||||||
if not urls or not urls.strip():
|
if not urls or not urls.strip():
|
||||||
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
|
||||||
|
|
||||||
|
from app.fanout.apprise_mod import FORMAT_VARIABLES, _apply_format
|
||||||
|
|
||||||
|
dummy_vars: dict[str, str] = dict.fromkeys(FORMAT_VARIABLES, "test")
|
||||||
|
for field in ("body_format_dm", "body_format_channel"):
|
||||||
|
value = config.get(field)
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{field} must be a string")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
_apply_format(value, dummy_vars)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid format string in {field}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
markdown_format = config.get("markdown_format")
|
||||||
|
if markdown_format is not None:
|
||||||
|
config["markdown_format"] = bool(markdown_format)
|
||||||
|
|
||||||
|
|
||||||
def _validate_webhook_config(config: dict) -> None:
|
def _validate_webhook_config(config: dict) -> None:
|
||||||
"""Validate webhook config blob."""
|
"""Validate webhook config blob."""
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class RadioStatsSnapshot(BaseModel):
|
|||||||
# Core stats
|
# Core stats
|
||||||
battery_mv: int | None = None
|
battery_mv: int | None = None
|
||||||
uptime_secs: int | None = None
|
uptime_secs: int | None = None
|
||||||
|
queue_len: int | None = None
|
||||||
|
errors: int | None = None
|
||||||
# Radio stats
|
# Radio stats
|
||||||
noise_floor: int | None = None
|
noise_floor: int | None = None
|
||||||
last_rssi: int | None = None
|
last_rssi: int | None = None
|
||||||
@@ -155,6 +157,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
|||||||
"timestamp": raw_stats.get("timestamp"),
|
"timestamp": raw_stats.get("timestamp"),
|
||||||
"battery_mv": raw_stats.get("battery_mv"),
|
"battery_mv": raw_stats.get("battery_mv"),
|
||||||
"uptime_secs": raw_stats.get("uptime_secs"),
|
"uptime_secs": raw_stats.get("uptime_secs"),
|
||||||
|
"queue_len": raw_stats.get("queue_len"),
|
||||||
|
"errors": raw_stats.get("errors"),
|
||||||
"noise_floor": raw_stats.get("noise_floor"),
|
"noise_floor": raw_stats.get("noise_floor"),
|
||||||
"last_rssi": raw_stats.get("last_rssi"),
|
"last_rssi": raw_stats.get("last_rssi"),
|
||||||
"last_snr": raw_stats.get("last_snr"),
|
"last_snr": raw_stats.get("last_snr"),
|
||||||
|
|||||||
@@ -128,11 +128,15 @@ async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
|||||||
sender=message.sender_name,
|
sender=message.sender_name,
|
||||||
channel_key=message.conversation_key,
|
channel_key=message.conversation_key,
|
||||||
contact_key=message.sender_key,
|
contact_key=message.sender_key,
|
||||||
|
sender_timestamp=message.sender_timestamp,
|
||||||
|
message=message.text,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
decrypted_info = RawPacketDecryptedInfo(
|
decrypted_info = RawPacketDecryptedInfo(
|
||||||
sender=message.sender_name,
|
sender=message.sender_name,
|
||||||
contact_key=message.conversation_key,
|
contact_key=message.conversation_key,
|
||||||
|
sender_timestamp=message.sender_timestamp,
|
||||||
|
message=message.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
return RawPacketDetail(
|
return RawPacketDetail(
|
||||||
|
|||||||
+5
-5
@@ -48,7 +48,7 @@ async def vapid_public_key() -> VapidPublicKeyResponse:
|
|||||||
"""Return the VAPID public key for browser PushManager.subscribe()."""
|
"""Return the VAPID public key for browser PushManager.subscribe()."""
|
||||||
key = get_vapid_public_key()
|
key = get_vapid_public_key()
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
raise HTTPException(status_code=423, detail="VAPID keys not initialized")
|
||||||
return VapidPublicKeyResponse(public_key=key)
|
return VapidPublicKeyResponse(public_key=key)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ async def test_push(subscription_id: str) -> dict:
|
|||||||
|
|
||||||
vapid_key = get_vapid_private_key()
|
vapid_key = get_vapid_private_key()
|
||||||
if not vapid_key:
|
if not vapid_key:
|
||||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
raise HTTPException(status_code=423, detail="VAPID keys not initialized")
|
||||||
|
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{
|
{
|
||||||
@@ -127,7 +127,7 @@ async def test_push(subscription_id: str) -> dict:
|
|||||||
)
|
)
|
||||||
return {"status": "sent"}
|
return {"status": "sent"}
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise HTTPException(status_code=504, detail="Push delivery timed out") from None
|
raise HTTPException(status_code=408, detail="Push delivery timed out") from None
|
||||||
except WebPushException as e:
|
except WebPushException as e:
|
||||||
status_code = getattr(getattr(e, "response", None), "status_code", 0)
|
status_code = getattr(getattr(e, "response", None), "status_code", 0)
|
||||||
if status_code in (403, 404, 410):
|
if status_code in (403, 404, 410):
|
||||||
@@ -143,10 +143,10 @@ async def test_push(subscription_id: str) -> dict:
|
|||||||
"Re-enable push from a conversation header.",
|
"Re-enable push from a conversation header.",
|
||||||
) from None
|
) from None
|
||||||
logger.warning("Test push failed: %s", e)
|
logger.warning("Test push failed: %s", e)
|
||||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Test push failed: %s", e)
|
logger.warning("Test push failed: %s", e)
|
||||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
|
||||||
|
|
||||||
|
|
||||||
# ── Global push conversation management ──────────────────────────────────
|
# ── Global push conversation management ──────────────────────────────────
|
||||||
|
|||||||
+39
-15
@@ -338,7 +338,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
|||||||
|
|
||||||
info = mc.self_info
|
info = mc.self_info
|
||||||
if not info:
|
if not info:
|
||||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
raise HTTPException(status_code=423, detail="Radio info not available")
|
||||||
|
|
||||||
adv_loc_policy = info.get("adv_loc_policy", 1)
|
adv_loc_policy = info.get("adv_loc_policy", 1)
|
||||||
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
||||||
@@ -380,11 +380,35 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
|||||||
except PathHashModeUnsupportedError as exc:
|
except PathHashModeUnsupportedError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except RadioCommandRejectedError as exc:
|
except RadioCommandRejectedError as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
return await get_radio_config()
|
return await get_radio_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/private-key")
|
||||||
|
async def get_private_key() -> dict:
|
||||||
|
"""Return the in-memory private key (exported from radio on startup).
|
||||||
|
|
||||||
|
Gated behind MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
from app.keystore import get_private_key as ks_get
|
||||||
|
|
||||||
|
if not settings.enable_local_private_key_export:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Private key export is disabled (set MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true)",
|
||||||
|
)
|
||||||
|
|
||||||
|
key = ks_get()
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Private key not available (not exported from radio)",
|
||||||
|
)
|
||||||
|
return {"private_key": key.hex()}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/private-key")
|
@router.put("/private-key")
|
||||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||||
"""Set the radio's private key. This is write-only."""
|
"""Set the radio's private key. This is write-only."""
|
||||||
@@ -406,7 +430,7 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
|||||||
export_and_store_private_key_fn=export_and_store_private_key,
|
export_and_store_private_key_fn=export_and_store_private_key,
|
||||||
)
|
)
|
||||||
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
|
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -430,7 +454,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
|||||||
success = await do_send_advertisement(mc, force=True, mode=mode)
|
success = await do_send_advertisement(mc, force=True, mode=mode)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
|
raise HTTPException(status_code=422, detail=f"Failed to send {mode} advertisement")
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -462,7 +486,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
tag=tag,
|
tag=tag,
|
||||||
)
|
)
|
||||||
if send_result is None or send_result.type == EventType.ERROR:
|
if send_result is None or send_result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
|
raise HTTPException(status_code=422, detail="Failed to start mesh discovery")
|
||||||
|
|
||||||
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
|
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
|
||||||
results_by_key: dict[str, RadioDiscoveryResult] = {}
|
results_by_key: dict[str, RadioDiscoveryResult] = {}
|
||||||
@@ -514,7 +538,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
|||||||
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
||||||
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
||||||
if len(local_public_key) != 64:
|
if len(local_public_key) != 64:
|
||||||
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
|
raise HTTPException(status_code=423, detail="Local radio public key is unavailable")
|
||||||
local_name = (mc.self_info or {}).get("name")
|
local_name = (mc.self_info or {}).get("name")
|
||||||
|
|
||||||
response_task = asyncio.create_task(
|
response_task = asyncio.create_task(
|
||||||
@@ -531,13 +555,13 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
|||||||
flags=trace_flags,
|
flags=trace_flags,
|
||||||
)
|
)
|
||||||
if send_result is None or send_result.type == EventType.ERROR:
|
if send_result is None or send_result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail="Failed to send trace")
|
raise HTTPException(status_code=422, detail="Failed to send trace")
|
||||||
|
|
||||||
timeout_seconds = _trace_timeout_seconds(send_result)
|
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||||
try:
|
try:
|
||||||
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
raise HTTPException(status_code=408, detail="No trace response heard") from exc
|
||||||
finally:
|
finally:
|
||||||
if not response_task.done():
|
if not response_task.done():
|
||||||
response_task.cancel()
|
response_task.cancel()
|
||||||
@@ -545,12 +569,12 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
|||||||
await response_task
|
await response_task
|
||||||
|
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
raise HTTPException(status_code=408, detail="No trace response heard")
|
||||||
|
|
||||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||||
path_len = payload.get("path_len")
|
path_len = payload.get("path_len")
|
||||||
if not isinstance(path_len, int):
|
if not isinstance(path_len, int):
|
||||||
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
raise HTTPException(status_code=422, detail="Trace response was malformed")
|
||||||
|
|
||||||
raw_path = payload.get("path")
|
raw_path = payload.get("path")
|
||||||
path_nodes = raw_path if isinstance(raw_path, list) else []
|
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||||
@@ -564,7 +588,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
|||||||
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||||
|
|
||||||
if len(hashed_nodes) < len(trace_nodes):
|
if len(hashed_nodes) < len(trace_nodes):
|
||||||
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
raise HTTPException(status_code=422, detail="Trace response was incomplete")
|
||||||
|
|
||||||
nodes: list[RadioTraceNode] = []
|
nodes: list[RadioTraceNode] = []
|
||||||
for index, trace_node in enumerate(trace_nodes):
|
for index, trace_node in enumerate(trace_nodes):
|
||||||
@@ -617,13 +641,13 @@ async def _attempt_reconnect() -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Post-connect setup failed after reconnect")
|
logger.exception("Post-connect setup failed after reconnect")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=423,
|
||||||
detail=f"Radio connected but setup failed: {e}",
|
detail=f"Radio connected but setup failed: {e}",
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
status_code=423, detail="Failed to reconnect. Check radio connection and power."
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||||
@@ -678,14 +702,14 @@ async def reconnect_radio() -> dict:
|
|||||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||||
try:
|
try:
|
||||||
if not await _prepare_connected(broadcast_on_success=True):
|
if not await _prepare_connected(broadcast_on_success=True):
|
||||||
raise HTTPException(status_code=503, detail="Radio connection is paused")
|
raise HTTPException(status_code=423, detail="Radio connection is paused")
|
||||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Post-connect setup failed")
|
logger.exception("Post-connect setup failed")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=423,
|
||||||
detail=f"Radio connected but setup failed: {e}",
|
detail=f"Radio connected but setup failed: {e}",
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e)
|
logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e)
|
||||||
|
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
raise HTTPException(status_code=408, detail="No status response from repeater")
|
||||||
|
|
||||||
response = RepeaterStatusResponse(
|
response = RepeaterStatusResponse(
|
||||||
battery_volts=status.get("bat", 0) / 1000.0,
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
@@ -133,6 +133,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
flood_dups=status.get("flood_dups", 0),
|
flood_dups=status.get("flood_dups", 0),
|
||||||
direct_dups=status.get("direct_dups", 0),
|
direct_dups=status.get("direct_dups", 0),
|
||||||
full_events=status.get("full_evts", 0),
|
full_events=status.get("full_evts", 0),
|
||||||
|
recv_errors=status.get("recv_errors"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record to telemetry history as a JSON blob (best-effort)
|
# Record to telemetry history as a JSON blob (best-effort)
|
||||||
@@ -221,7 +222,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
|||||||
)
|
)
|
||||||
|
|
||||||
if telemetry is None:
|
if telemetry is None:
|
||||||
raise HTTPException(status_code=504, detail="No telemetry response from repeater")
|
raise HTTPException(status_code=408, detail="No telemetry response from repeater")
|
||||||
|
|
||||||
sensors: list[LppSensor] = []
|
sensors: list[LppSensor] = []
|
||||||
for entry in telemetry:
|
for entry in telemetry:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||||
|
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from room server")
|
raise HTTPException(status_code=408, detail="No status response from room server")
|
||||||
|
|
||||||
return RepeaterStatusResponse(
|
return RepeaterStatusResponse(
|
||||||
battery_volts=status.get("bat", 0) / 1000.0,
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
@@ -78,6 +78,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
flood_dups=status.get("flood_dups", 0),
|
flood_dups=status.get("flood_dups", 0),
|
||||||
direct_dups=status.get("direct_dups", 0),
|
direct_dups=status.get("direct_dups", 0),
|
||||||
full_events=status.get("full_evts", 0),
|
full_events=status.get("full_evts", 0),
|
||||||
|
recv_errors=status.get("recv_errors"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if telemetry is None:
|
if telemetry is None:
|
||||||
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
raise HTTPException(status_code=408, detail="No telemetry response from room server")
|
||||||
|
|
||||||
sensors = [
|
sensors = [
|
||||||
LppSensor(
|
LppSensor(
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ async def send_contact_cli_command(
|
|||||||
|
|
||||||
if send_result.type == EventType.ERROR:
|
if send_result.type == EventType.ERROR:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
status_code=422, detail=f"Failed to send command: {send_result.payload}"
|
||||||
)
|
)
|
||||||
|
|
||||||
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||||
|
|||||||
+72
-5
@@ -73,6 +73,13 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
"based on the current tracked-repeater count."
|
"based on the current tracked-repeater count."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
telemetry_routed_hourly: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"When enabled, tracked repeaters with a direct or routed (non-flood) "
|
||||||
|
"path are polled every hour instead of on the normal scheduled interval."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockKeyRequest(BaseModel):
|
class BlockKeyRequest(BaseModel):
|
||||||
@@ -94,6 +101,15 @@ class FavoriteToggleResponse(BaseModel):
|
|||||||
favorite: bool
|
favorite: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MuteChannelRequest(BaseModel):
|
||||||
|
key: str = Field(description="Channel key to toggle mute status")
|
||||||
|
|
||||||
|
|
||||||
|
class MuteChannelToggleResponse(BaseModel):
|
||||||
|
key: str
|
||||||
|
muted: bool
|
||||||
|
|
||||||
|
|
||||||
class TrackedTelemetryRequest(BaseModel):
|
class TrackedTelemetryRequest(BaseModel):
|
||||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||||
|
|
||||||
@@ -117,7 +133,18 @@ class TelemetrySchedule(BaseModel):
|
|||||||
max_tracked: int = Field(description="Maximum number of repeaters that can be tracked")
|
max_tracked: int = Field(description="Maximum number of repeaters that can be tracked")
|
||||||
next_run_at: int | None = Field(
|
next_run_at: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Unix timestamp (UTC seconds) of the next scheduled cycle",
|
description="Unix timestamp (UTC seconds) of the next scheduled flood cycle",
|
||||||
|
)
|
||||||
|
routed_hourly: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether hourly routed/direct-path telemetry is enabled",
|
||||||
|
)
|
||||||
|
next_routed_run_at: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Unix timestamp (UTC seconds) of the next hourly routed/direct check, "
|
||||||
|
"or None when routed_hourly is off or no repeaters are tracked"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -131,20 +158,27 @@ class TrackedTelemetryResponse(BaseModel):
|
|||||||
schedule: TelemetrySchedule = Field(description="Current scheduling state")
|
schedule: TelemetrySchedule = Field(description="Current scheduling state")
|
||||||
|
|
||||||
|
|
||||||
def _build_schedule(tracked_count: int, preferred_hours: int | None) -> TelemetrySchedule:
|
def _build_schedule(
|
||||||
|
tracked_count: int,
|
||||||
|
preferred_hours: int | None,
|
||||||
|
routed_hourly: bool = False,
|
||||||
|
) -> TelemetrySchedule:
|
||||||
pref = (
|
pref = (
|
||||||
preferred_hours
|
preferred_hours
|
||||||
if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS
|
if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS
|
||||||
else DEFAULT_TELEMETRY_INTERVAL_HOURS
|
else DEFAULT_TELEMETRY_INTERVAL_HOURS
|
||||||
)
|
)
|
||||||
effective = clamp_telemetry_interval(pref, tracked_count)
|
effective = clamp_telemetry_interval(pref, tracked_count)
|
||||||
|
has_tracked = tracked_count > 0
|
||||||
return TelemetrySchedule(
|
return TelemetrySchedule(
|
||||||
preferred_hours=pref,
|
preferred_hours=pref,
|
||||||
effective_hours=effective,
|
effective_hours=effective,
|
||||||
options=legal_interval_options(tracked_count),
|
options=legal_interval_options(tracked_count),
|
||||||
tracked_count=tracked_count,
|
tracked_count=tracked_count,
|
||||||
max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS,
|
max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS,
|
||||||
next_run_at=next_run_timestamp_utc(effective) if tracked_count > 0 else None,
|
next_run_at=next_run_timestamp_utc(effective) if has_tracked else None,
|
||||||
|
routed_hourly=routed_hourly,
|
||||||
|
next_routed_run_at=(next_run_timestamp_utc(1) if has_tracked and routed_hourly else None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +241,11 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
|||||||
logger.info("Updating telemetry_interval_hours to %d", raw_interval)
|
logger.info("Updating telemetry_interval_hours to %d", raw_interval)
|
||||||
kwargs["telemetry_interval_hours"] = raw_interval
|
kwargs["telemetry_interval_hours"] = raw_interval
|
||||||
|
|
||||||
|
# Telemetry routed hourly
|
||||||
|
if update.telemetry_routed_hourly is not None:
|
||||||
|
logger.info("Updating telemetry_routed_hourly to %s", update.telemetry_routed_hourly)
|
||||||
|
kwargs["telemetry_routed_hourly"] = update.telemetry_routed_hourly
|
||||||
|
|
||||||
# Flood scope
|
# Flood scope
|
||||||
flood_scope_changed = False
|
flood_scope_changed = False
|
||||||
if update.flood_scope is not None:
|
if update.flood_scope is not None:
|
||||||
@@ -260,6 +299,25 @@ async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
|
|||||||
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/muted-channels/toggle", response_model=MuteChannelToggleResponse)
|
||||||
|
async def toggle_muted_channel(request: MuteChannelRequest) -> MuteChannelToggleResponse:
|
||||||
|
"""Toggle a channel's muted status."""
|
||||||
|
channel = await ChannelRepository.get_by_key(request.key)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
new_value = not channel.muted
|
||||||
|
await ChannelRepository.set_muted(request.key, new_value)
|
||||||
|
logger.info("%s channel mute: %s", "Muted" if new_value else "Unmuted", request.key[:12])
|
||||||
|
|
||||||
|
refreshed = await ChannelRepository.get_by_key(request.key)
|
||||||
|
if refreshed:
|
||||||
|
from app.websocket import broadcast_event
|
||||||
|
|
||||||
|
broadcast_event("channel", refreshed.model_dump())
|
||||||
|
|
||||||
|
return MuteChannelToggleResponse(key=request.key, muted=new_value)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||||
"""Toggle a public key's blocked status."""
|
"""Toggle a public key's blocked status."""
|
||||||
@@ -300,7 +358,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
|||||||
return TrackedTelemetryResponse(
|
return TrackedTelemetryResponse(
|
||||||
tracked_telemetry_repeaters=new_list,
|
tracked_telemetry_repeaters=new_list,
|
||||||
names=await _resolve_names(new_list),
|
names=await _resolve_names(new_list),
|
||||||
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours),
|
schedule=_build_schedule(
|
||||||
|
len(new_list),
|
||||||
|
settings.telemetry_interval_hours,
|
||||||
|
settings.telemetry_routed_hourly,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate it's a repeater
|
# Validate it's a repeater
|
||||||
@@ -327,7 +389,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
|
|||||||
return TrackedTelemetryResponse(
|
return TrackedTelemetryResponse(
|
||||||
tracked_telemetry_repeaters=new_list,
|
tracked_telemetry_repeaters=new_list,
|
||||||
names=await _resolve_names(new_list),
|
names=await _resolve_names(new_list),
|
||||||
schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours),
|
schedule=_build_schedule(
|
||||||
|
len(new_list),
|
||||||
|
settings.telemetry_interval_hours,
|
||||||
|
settings.telemetry_routed_hourly,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,4 +409,5 @@ async def get_telemetry_schedule() -> TelemetrySchedule:
|
|||||||
return _build_schedule(
|
return _build_schedule(
|
||||||
len(app_settings.tracked_telemetry_repeaters),
|
len(app_settings.tracked_telemetry_repeaters),
|
||||||
app_settings.telemetry_interval_hours,
|
app_settings.telemetry_interval_hours,
|
||||||
|
app_settings.telemetry_routed_hourly,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async def send_channel_message_with_effective_scope(
|
|||||||
override_result.payload,
|
override_result.payload,
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail=(
|
detail=(
|
||||||
f"Failed to apply regional override {override_scope!r} before {action_label}: "
|
f"Failed to apply regional override {override_scope!r} before {action_label}: "
|
||||||
f"{override_result.payload}"
|
f"{override_result.payload}"
|
||||||
@@ -189,7 +189,7 @@ async def send_channel_message_with_effective_scope(
|
|||||||
phm_result.payload,
|
phm_result.payload,
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail=(
|
detail=(
|
||||||
f"Failed to apply path hash mode override before {action_label}: "
|
f"Failed to apply path hash mode override before {action_label}: "
|
||||||
f"{phm_result.payload}"
|
f"{phm_result.payload}"
|
||||||
@@ -233,7 +233,7 @@ async def send_channel_message_with_effective_scope(
|
|||||||
set_result.payload,
|
set_result.payload,
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail=f"Failed to configure channel on radio before {action_label}",
|
detail=f"Failed to configure channel on radio before {action_label}",
|
||||||
)
|
)
|
||||||
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
|
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
|
||||||
@@ -256,8 +256,14 @@ async def send_channel_message_with_effective_scope(
|
|||||||
action_label,
|
action_label,
|
||||||
channel.name,
|
channel.name,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||||
if send_result.type == EventType.ERROR:
|
if send_result.type == EventType.ERROR:
|
||||||
|
logger.error(
|
||||||
|
"Radio returned error during %s for channel %s: %s",
|
||||||
|
action_label,
|
||||||
|
channel.name,
|
||||||
|
send_result.payload,
|
||||||
|
)
|
||||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||||
else:
|
else:
|
||||||
radio_manager.note_channel_slot_used(channel_key)
|
radio_manager.note_channel_slot_used(channel_key)
|
||||||
@@ -592,10 +598,10 @@ async def send_direct_message_to_contact(
|
|||||||
"No response from radio after direct send to %s; send outcome is unknown",
|
"No response from radio after direct send to %s; send outcome is unknown",
|
||||||
contact.public_key[:12],
|
contact.public_key[:12],
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
raise HTTPException(status_code=422, detail=f"Failed to send message: {result.payload}")
|
||||||
|
|
||||||
message = await create_outgoing_direct_message(
|
message = await create_outgoing_direct_message(
|
||||||
conversation_key=contact.public_key.lower(),
|
conversation_key=contact.public_key.lower(),
|
||||||
@@ -607,7 +613,7 @@ async def send_direct_message_to_contact(
|
|||||||
)
|
)
|
||||||
if message is None:
|
if message is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail="Failed to store outgoing message - unexpected duplicate",
|
detail="Failed to store outgoing message - unexpected duplicate",
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -620,7 +626,7 @@ async def send_direct_message_to_contact(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if sent_at is None or sender_timestamp is None or message is None or result is None:
|
if sent_at is None or sender_timestamp is None or message is None or result is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
raise HTTPException(status_code=422, detail="Failed to store outgoing message")
|
||||||
|
|
||||||
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
|
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
|
||||||
|
|
||||||
@@ -785,7 +791,7 @@ async def send_channel_message_to_channel(
|
|||||||
)
|
)
|
||||||
if outgoing_message is None:
|
if outgoing_message is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail="Failed to store outgoing message - unexpected duplicate",
|
detail="Failed to store outgoing message - unexpected duplicate",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -807,11 +813,11 @@ async def send_channel_message_to_channel(
|
|||||||
"No response from radio after channel send to %s; send outcome is unknown",
|
"No response from radio after channel send to %s; send outcome is unknown",
|
||||||
channel.name,
|
channel.name,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to send message: {result.payload}"
|
status_code=422, detail=f"Failed to send message: {result.payload}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
if outgoing_message is not None:
|
if outgoing_message is not None:
|
||||||
@@ -828,7 +834,7 @@ async def send_channel_message_to_channel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
raise HTTPException(status_code=422, detail="Failed to store outgoing message")
|
||||||
|
|
||||||
outgoing_message = await build_stored_outgoing_channel_message(
|
outgoing_message = await build_stored_outgoing_channel_message(
|
||||||
message_id=outgoing_message.id,
|
message_id=outgoing_message.id,
|
||||||
@@ -856,7 +862,7 @@ async def send_channel_message_to_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never let watchdog setup failure break the send
|
logger.error("Echo watchdog setup failed", exc_info=True)
|
||||||
|
|
||||||
return outgoing_message
|
return outgoing_message
|
||||||
|
|
||||||
@@ -922,7 +928,7 @@ async def resend_channel_message_record(
|
|||||||
)
|
)
|
||||||
if new_message is None:
|
if new_message is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail="Failed to store resent message - unexpected duplicate",
|
detail="Failed to store resent message - unexpected duplicate",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -943,10 +949,10 @@ async def resend_channel_message_record(
|
|||||||
"No response from radio after channel resend to %s; send outcome is unknown",
|
"No response from radio after channel resend to %s; send outcome is unknown",
|
||||||
channel.name,
|
channel.name,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=422,
|
||||||
detail=f"Failed to resend message: {result.payload}",
|
detail=f"Failed to resend message: {result.payload}",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -965,7 +971,7 @@ async def resend_channel_message_record(
|
|||||||
|
|
||||||
if new_timestamp:
|
if new_timestamp:
|
||||||
if sent_at is None or new_message is None:
|
if sent_at is None or new_message is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
raise HTTPException(status_code=422, detail="Failed to assign resend timestamp")
|
||||||
|
|
||||||
new_message = await build_stored_outgoing_channel_message(
|
new_message = await build_stored_outgoing_channel_message(
|
||||||
message_id=new_message.id,
|
message_id=new_message.id,
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ class RadioRuntime:
|
|||||||
def require_connected(self):
|
def require_connected(self):
|
||||||
"""Return MeshCore when available, mirroring existing HTTP semantics."""
|
"""Return MeshCore when available, mirroring existing HTTP semantics."""
|
||||||
if self.is_setup_in_progress:
|
if self.is_setup_in_progress:
|
||||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
raise HTTPException(status_code=423, detail="Radio is initializing")
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
raise HTTPException(status_code=423, detail="Radio not connected")
|
||||||
mc = self.meshcore
|
mc = self.meshcore
|
||||||
if mc is None:
|
if mc is None:
|
||||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
raise HTTPException(status_code=423, detail="Radio not connected")
|
||||||
return mc
|
return mc
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
# MESHCORE_DISABLE_BOTS: "true"
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
# MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT: "false"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# MESHCORE_LOG_LEVEL: INFO
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
|||||||
+3
-6
@@ -75,7 +75,6 @@ frontend/src/
|
|||||||
├── utils/
|
├── utils/
|
||||||
│ ├── urlHash.ts # Hash parsing and encoding
|
│ ├── urlHash.ts # Hash parsing and encoding
|
||||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
|
||||||
│ ├── messageParser.ts # Message text → rendered segments
|
│ ├── messageParser.ts # Message text → rendered segments
|
||||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
@@ -132,6 +131,9 @@ frontend/src/
|
|||||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
|
│ ├── ChannelPathHashModeOverrideModal.tsx # Per-channel path hash mode override editor
|
||||||
|
│ ├── BulkAddChannelResultModal.tsx # Results dialog for bulk channel creation
|
||||||
|
│ ├── CommandPalette.tsx # Command palette overlay
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
@@ -178,7 +180,6 @@ frontend/src/
|
|||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
├── rawPacketDetailModal.test.tsx
|
├── rawPacketDetailModal.test.tsx
|
||||||
├── rawPacketFeedView.test.tsx
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
@@ -350,10 +351,6 @@ It falls back to a 12-char prefix when `name` is missing.
|
|||||||
|
|
||||||
Distance/validation helpers used by path + map UI.
|
Distance/validation helpers used by path + map UI.
|
||||||
|
|
||||||
### `utils/favorites.ts`
|
|
||||||
|
|
||||||
LocalStorage migration helpers for favorites; canonical favorites are server-side.
|
|
||||||
|
|
||||||
## Types and Contracts (`types.ts`)
|
## Types and Contracts (`types.ts`)
|
||||||
|
|
||||||
`AppSettings` currently includes:
|
`AppSettings` currently includes:
|
||||||
|
|||||||
Generated
+190
-1198
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.0",
|
"version": "3.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -61,12 +61,12 @@
|
|||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.10",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.19.0",
|
"typescript-eslint": "^8.19.0",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.4.2",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -25,7 +25,13 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
|||||||
import { usePush } from './contexts/PushSubscriptionContext';
|
import { usePush } from './contexts/PushSubscriptionContext';
|
||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
import { getStateKey } from './utils/conversationState';
|
||||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
import type {
|
||||||
|
BulkCreateHashtagChannelsResult,
|
||||||
|
Channel,
|
||||||
|
Conversation,
|
||||||
|
Message,
|
||||||
|
RawPacket,
|
||||||
|
} from './types';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||||
|
|
||||||
@@ -207,6 +213,12 @@ export function App() {
|
|||||||
removeConversationMessagesRef.current(conversationId),
|
removeConversationMessagesRef.current(conversationId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep channels in a ref for WS callback mute filtering
|
||||||
|
const channelsRef = useRef<Channel[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
channelsRef.current = channels;
|
||||||
|
}, [channels]);
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
async (type: 'channel' | 'contact', id: string) => {
|
async (type: 'channel' | 'contact', id: string) => {
|
||||||
// Optimistically toggle the favorite flag
|
// Optimistically toggle the favorite flag
|
||||||
@@ -343,6 +355,20 @@ export function App() {
|
|||||||
useFaviconBadge(unreadCounts, mentions, channels);
|
useFaviconBadge(unreadCounts, mentions, channels);
|
||||||
useUnreadTitle(unreadCounts, contacts, channels);
|
useUnreadTitle(unreadCounts, contacts, channels);
|
||||||
|
|
||||||
|
const handleToggleMute = useCallback(
|
||||||
|
async (key: string) => {
|
||||||
|
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
|
||||||
|
try {
|
||||||
|
await api.toggleChannelMute(key);
|
||||||
|
await refreshUnreads();
|
||||||
|
} catch {
|
||||||
|
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
|
||||||
|
toast.error('Failed to update mute');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setChannels, refreshUnreads]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeConversation?.type !== 'channel') {
|
if (activeConversation?.type !== 'channel') {
|
||||||
setChannelUnreadMarker(null);
|
setChannelUnreadMarker(null);
|
||||||
@@ -408,6 +434,7 @@ export function App() {
|
|||||||
setContacts,
|
setContacts,
|
||||||
blockedKeysRef,
|
blockedKeysRef,
|
||||||
blockedNamesRef,
|
blockedNamesRef,
|
||||||
|
channelsRef,
|
||||||
activeConversationRef,
|
activeConversationRef,
|
||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
@@ -586,6 +613,7 @@ export function App() {
|
|||||||
onRunTracePath: api.requestRadioTrace,
|
onRunTracePath: api.requestRadioTrace,
|
||||||
onPathDiscovery: handlePathDiscovery,
|
onPathDiscovery: handlePathDiscovery,
|
||||||
onToggleFavorite: handleToggleFavorite,
|
onToggleFavorite: handleToggleFavorite,
|
||||||
|
onToggleMute: handleToggleMute,
|
||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
onDeleteChannel: handleDeleteChannel,
|
onDeleteChannel: handleDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||||
|
|||||||
+9
-2
@@ -96,6 +96,7 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
getPrivateKey: () => fetchJson<{ private_key: string }>('/radio/private-key'),
|
||||||
setPrivateKey: (privateKey: string) =>
|
setPrivateKey: (privateKey: string) =>
|
||||||
fetchJson<{ status: string }>('/radio/private-key', {
|
fetchJson<{ status: string }>('/radio/private-key', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -157,10 +158,10 @@ export const api = {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ public_keys: publicKeys }),
|
body: JSON.stringify({ public_keys: publicKeys }),
|
||||||
}),
|
}),
|
||||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean, type?: number) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
|
body: JSON.stringify({ public_key: publicKey, name, type, try_historical: tryHistorical }),
|
||||||
}),
|
}),
|
||||||
markContactRead: (publicKey: string) =>
|
markContactRead: (publicKey: string) =>
|
||||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||||
@@ -343,6 +344,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ type, id }),
|
body: JSON.stringify({ type, id }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
toggleChannelMute: (key: string) =>
|
||||||
|
fetchJson<{ key: string; muted: boolean }>('/settings/muted-channels/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Fanout
|
// Fanout
|
||||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||||
createFanoutConfig: (config: {
|
createFanoutConfig: (config: {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
} from 'react';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
import { useSwipeable } from 'react-swipeable';
|
||||||
|
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
@@ -140,6 +148,26 @@ export function AppShell({
|
|||||||
crackerMounted.current = true;
|
crackerMounted.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position toasts below the conversation header when in chat, otherwise below the status bar
|
||||||
|
const TOAST_TOP_PADDING = 10;
|
||||||
|
const [toastTopOffset, setToastTopOffset] = useState<number | undefined>(undefined);
|
||||||
|
const hasLocalLabel = !!localLabel.text;
|
||||||
|
const activeType = conversationPaneProps.activeConversation?.type;
|
||||||
|
const activeId = conversationPaneProps.activeConversation?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
const measure = () => {
|
||||||
|
const anchor =
|
||||||
|
document.querySelector('[data-toast-anchor="conversation"]') ??
|
||||||
|
document.querySelector('[data-toast-anchor="statusbar"]');
|
||||||
|
setToastTopOffset(
|
||||||
|
anchor ? anchor.getBoundingClientRect().top + TOAST_TOP_PADDING : undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => window.removeEventListener('resize', measure);
|
||||||
|
}, [hasLocalLabel, activeType, activeId, showSettings]);
|
||||||
|
|
||||||
const settingsSidebarContent = (
|
const settingsSidebarContent = (
|
||||||
<nav
|
<nav
|
||||||
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
||||||
@@ -220,6 +248,7 @@ export function AppShell({
|
|||||||
onSettingsClick={onToggleSettingsView}
|
onSettingsClick={onToggleSettingsView}
|
||||||
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
||||||
/>
|
/>
|
||||||
|
<div data-toast-anchor="statusbar" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||||
@@ -344,7 +373,11 @@ export function AppShell({
|
|||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||||
<Toaster position="top-right" />
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
offset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
mobileOffset={toastTopOffset !== undefined ? { top: toastTopOffset } : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, BellOff, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
@@ -32,6 +32,7 @@ interface ChatHeaderProps {
|
|||||||
onTogglePush?: () => void;
|
onTogglePush?: () => void;
|
||||||
onOpenPushSettings?: () => void;
|
onOpenPushSettings?: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
|
onToggleMute?: (key: string) => void;
|
||||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||||
onDeleteChannel: (key: string) => void;
|
onDeleteChannel: (key: string) => void;
|
||||||
@@ -57,6 +58,7 @@ export function ChatHeader({
|
|||||||
onTogglePush,
|
onTogglePush,
|
||||||
onOpenPushSettings,
|
onOpenPushSettings,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
|
onToggleMute,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
onSetChannelPathHashModeOverride,
|
onSetChannelPathHashModeOverride,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
@@ -313,95 +315,125 @@ export function ChatHeader({
|
|||||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
|
{(notificationsSupported ||
|
||||||
<div className="relative" ref={notifDropdownRef}>
|
pushSupported ||
|
||||||
<button
|
(conversation.type === 'channel' && onToggleMute)) &&
|
||||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
!activeContactIsRoomServer && (
|
||||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
<div className="relative" ref={notifDropdownRef}>
|
||||||
title="Notification settings"
|
<button
|
||||||
aria-label="Notification settings"
|
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
aria-expanded={notifDropdownOpen}
|
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||||
>
|
title="Notification settings"
|
||||||
<Bell
|
aria-label="Notification settings"
|
||||||
className={cn(
|
aria-expanded={notifDropdownOpen}
|
||||||
'h-4 w-4',
|
>
|
||||||
notificationsEnabled || pushEnabledForConversation
|
{activeChannel?.muted ? (
|
||||||
? 'text-primary'
|
<BellOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
: 'text-muted-foreground'
|
) : (
|
||||||
|
<Bell
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
notificationsEnabled || pushEnabledForConversation
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
fill={
|
||||||
|
notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
|
</button>
|
||||||
aria-hidden="true"
|
{notifDropdownOpen && (
|
||||||
/>
|
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
||||||
</button>
|
{notificationsSupported && (
|
||||||
{notifDropdownOpen && (
|
|
||||||
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
|
||||||
{notificationsSupported && (
|
|
||||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
|
||||||
checked={notificationsEnabled}
|
|
||||||
disabled={notificationsPermission === 'denied'}
|
|
||||||
onChange={onToggleNotifications}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
|
||||||
Desktop notifications (legacy)
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
|
||||||
{notificationsPermission === 'denied'
|
|
||||||
? 'Blocked by browser — check site permissions'
|
|
||||||
: 'Alerts while this tab is open'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{pushSupported && onTogglePush && (
|
|
||||||
<>
|
|
||||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||||
checked={!!pushEnabledForConversation}
|
checked={notificationsEnabled}
|
||||||
onChange={onTogglePush}
|
disabled={notificationsPermission === 'denied'}
|
||||||
|
onChange={onToggleNotifications}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||||
Web Push (beta testing)
|
Desktop notifications (legacy)
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
{pushSubscribed
|
{notificationsPermission === 'denied'
|
||||||
? 'Alerts even when the browser is closed'
|
? 'Blocked by browser — check site permissions'
|
||||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
: 'Alerts while this tab is open'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
)}
|
||||||
All notification types require a trusted HTTPS context. Depending on your
|
{pushSupported && onTogglePush && (
|
||||||
browser, a snakeoil certificate may not be sufficient.
|
<>
|
||||||
</span>
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
{onOpenPushSettings && (
|
<input
|
||||||
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
type="checkbox"
|
||||||
Manage Web Push enabled devices in{' '}
|
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||||
<button
|
checked={!!pushEnabledForConversation}
|
||||||
type="button"
|
onChange={onTogglePush}
|
||||||
onClick={() => {
|
/>
|
||||||
setNotifDropdownOpen(false);
|
<div className="min-w-0">
|
||||||
onOpenPushSettings();
|
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||||
}}
|
Web Push (beta testing)
|
||||||
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
</span>
|
||||||
>
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
Settings → Local
|
{pushSubscribed
|
||||||
</button>
|
? 'Alerts even when the browser is closed'
|
||||||
.
|
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||||
</p>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</>
|
</label>
|
||||||
)}
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
</div>
|
All notification types require a trusted HTTPS context. Depending on your
|
||||||
)}
|
browser, a snakeoil certificate may not be sufficient.
|
||||||
</div>
|
</span>
|
||||||
)}
|
{onOpenPushSettings && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
||||||
|
Manage Web Push enabled devices in{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNotifDropdownOpen(false);
|
||||||
|
onOpenPushSettings();
|
||||||
|
}}
|
||||||
|
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
Settings → Local
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{conversation.type === 'channel' && onToggleMute && (
|
||||||
|
<>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||||
|
checked={!!activeChannel?.muted}
|
||||||
|
onChange={() => onToggleMute(conversation.id)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||||
|
Mute channel
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||||
|
Hide unread counts and suppress all notifications
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||||
<button
|
<button
|
||||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface ConversationPaneProps {
|
|||||||
) => Promise<RadioTraceResponse>;
|
) => Promise<RadioTraceResponse>;
|
||||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||||
|
onToggleMute: (key: string) => Promise<void>;
|
||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
onDeleteChannel: (key: string) => Promise<void>;
|
onDeleteChannel: (key: string) => Promise<void>;
|
||||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||||
@@ -143,6 +144,7 @@ export function ConversationPane({
|
|||||||
onRunTracePath,
|
onRunTracePath,
|
||||||
onPathDiscovery,
|
onPathDiscovery,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
|
onToggleMute,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
@@ -307,6 +309,7 @@ export function ConversationPane({
|
|||||||
onPathDiscovery={onPathDiscovery}
|
onPathDiscovery={onPathDiscovery}
|
||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onToggleMute={onToggleMute}
|
||||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||||
onDeleteChannel={onDeleteChannel}
|
onDeleteChannel={onDeleteChannel}
|
||||||
@@ -323,6 +326,7 @@ export function ConversationPane({
|
|||||||
{activeContactIsRoom && activeContact && (
|
{activeContactIsRoom && activeContact && (
|
||||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||||
)}
|
)}
|
||||||
|
{showRoomChat && <div data-toast-anchor="conversation" aria-hidden="true" />}
|
||||||
{showRoomChat && (
|
{showRoomChat && (
|
||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useRef,
|
useRef,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Input } from './ui/input';
|
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
getTextReplaceEnabled,
|
||||||
|
getTextReplaceMapJson,
|
||||||
|
applyTextReplacements,
|
||||||
|
} from '../utils/textReplace';
|
||||||
|
|
||||||
// MeshCore message size limits (empirically determined from LoRa packet constraints)
|
// MeshCore message size limits (empirically determined from LoRa packet constraints)
|
||||||
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
|
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
|
||||||
@@ -53,19 +59,32 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
) {
|
) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
/** Resize textarea to fit content, clamped between 1 row and ~6 rows. */
|
||||||
|
const autoResize = useCallback(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
// Clamp: min 40px (≈1 row), max 160px (≈6 rows)
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
appendText: (appendedText: string) => {
|
appendText: (appendedText: string) => {
|
||||||
setText((prev) => prev + appendedText);
|
setText((prev) => prev + appendedText);
|
||||||
// Focus the input after appending
|
textareaRef.current?.focus();
|
||||||
inputRef.current?.focus();
|
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
inputRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Re-measure height whenever text changes (covers programmatic updates like appendText)
|
||||||
|
useEffect(() => {
|
||||||
|
autoResize();
|
||||||
|
}, [text, autoResize]);
|
||||||
|
|
||||||
// Calculate character limits based on conversation type
|
// Calculate character limits based on conversation type
|
||||||
const limits = useMemo(() => {
|
const limits = useMemo(() => {
|
||||||
if (conversationType === 'contact') {
|
if (conversationType === 'contact') {
|
||||||
@@ -133,18 +152,44 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
// Refocus after React re-enables the input
|
// Refocus after React re-enables the textarea
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||||
},
|
},
|
||||||
[text, sending, disabled, onSend]
|
[text, sending, disabled, onSend]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const input = e.target;
|
||||||
|
const raw = input.value;
|
||||||
|
// Skip replacement during IME / dead-key composition to avoid garbling interim input
|
||||||
|
if (!e.nativeEvent || (e.nativeEvent as InputEvent).isComposing) {
|
||||||
|
setText(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getTextReplaceEnabled()) {
|
||||||
|
const result = applyTextReplacements(
|
||||||
|
raw,
|
||||||
|
input.selectionStart ?? raw.length,
|
||||||
|
getTextReplaceMapJson()
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
setText(result.text);
|
||||||
|
// Schedule cursor restore after React flushes the new value
|
||||||
|
const pos = result.cursor;
|
||||||
|
requestAnimationFrame(() => input.setSelectionRange(pos, pos));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setText(raw);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit(e as unknown as FormEvent);
|
handleSubmit(e as unknown as FormEvent);
|
||||||
}
|
}
|
||||||
|
// Shift+Enter falls through naturally and inserts a newline
|
||||||
},
|
},
|
||||||
[handleSubmit]
|
[handleSubmit]
|
||||||
);
|
);
|
||||||
@@ -162,22 +207,28 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-end">
|
||||||
<Input
|
<textarea
|
||||||
ref={inputRef}
|
ref={textareaRef}
|
||||||
type="text"
|
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
name="chat-message-input"
|
name="chat-message-input"
|
||||||
aria-label={placeholder || 'Type a message'}
|
aria-label={placeholder || 'Type a message'}
|
||||||
data-lpignore="true"
|
data-lpignore="true"
|
||||||
data-1p-ignore="true"
|
data-1p-ignore="true"
|
||||||
data-bwignore="true"
|
data-bwignore="true"
|
||||||
|
rows={1}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder || 'Type a message...'}
|
placeholder={placeholder || 'Type a message...'}
|
||||||
disabled={disabled || sending}
|
disabled={disabled || sending}
|
||||||
className="flex-1 min-w-0"
|
className={cn(
|
||||||
|
'flex-1 min-w-0 resize-none overflow-y-auto',
|
||||||
|
'rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background',
|
||||||
|
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50 md:text-sm'
|
||||||
|
)}
|
||||||
|
style={{ minHeight: '40px', maxHeight: '160px' }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ interface NewMessageModalProps {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
} | null;
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (
|
||||||
|
name: string,
|
||||||
|
publicKey: string,
|
||||||
|
tryHistorical: boolean,
|
||||||
|
type?: number
|
||||||
|
) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -91,6 +96,7 @@ export function NewMessageModal({
|
|||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [contactType, setContactType] = useState(1);
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
@@ -103,6 +109,7 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
|
setContactType(1);
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
setBulkChannelText('');
|
setBulkChannelText('');
|
||||||
@@ -161,7 +168,7 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical, contactType);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Channel name and key are required');
|
setError('Channel name and key are required');
|
||||||
@@ -293,6 +300,19 @@ export function NewMessageModal({
|
|||||||
placeholder="64-character hex public key"
|
placeholder="64-character hex public key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="contact-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="contact-type"
|
||||||
|
value={contactType}
|
||||||
|
onChange={(e) => setContactType(Number(e.target.value))}
|
||||||
|
className="block h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value={1}>Client</option>
|
||||||
|
<option value={2}>Repeater</option>
|
||||||
|
<option value={3}>Room Server</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<div data-toast-anchor="conversation" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
BellOff,
|
||||||
Cable,
|
Cable,
|
||||||
ChartNetwork,
|
ChartNetwork,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
@@ -49,6 +50,7 @@ type ConversationRow = {
|
|||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
isMention: boolean;
|
isMention: boolean;
|
||||||
notificationsEnabled: boolean;
|
notificationsEnabled: boolean;
|
||||||
|
muted?: boolean;
|
||||||
contact?: Contact;
|
contact?: Contact;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,6 +252,10 @@ export function Sidebar({
|
|||||||
if (isPublicChannelKey(a.key)) return -1;
|
if (isPublicChannelKey(a.key)) return -1;
|
||||||
if (isPublicChannelKey(b.key)) return 1;
|
if (isPublicChannelKey(b.key)) return 1;
|
||||||
|
|
||||||
|
// Muted channels always sort to the bottom
|
||||||
|
if (a.muted && !b.muted) return 1;
|
||||||
|
if (!a.muted && b.muted) return -1;
|
||||||
|
|
||||||
if (sectionSortOrders.channels === 'recent') {
|
if (sectionSortOrders.channels === 'recent') {
|
||||||
const timeA = getLastMessageTime('channel', a.key);
|
const timeA = getLastMessageTime('channel', a.key);
|
||||||
const timeB = getLastMessageTime('channel', b.key);
|
const timeB = getLastMessageTime('channel', b.key);
|
||||||
@@ -530,9 +536,10 @@ export function Sidebar({
|
|||||||
type: 'channel',
|
type: 'channel',
|
||||||
id: channel.key,
|
id: channel.key,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
unreadCount: getUnreadCount('channel', channel.key),
|
unreadCount: channel.muted ? 0 : getUnreadCount('channel', channel.key),
|
||||||
isMention: hasMention('channel', channel.key),
|
isMention: channel.muted ? false : hasMention('channel', channel.key),
|
||||||
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
||||||
|
muted: channel.muted,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
||||||
@@ -584,23 +591,31 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
||||||
<span className="ml-auto flex items-center gap-1">
|
<span className="ml-auto flex items-center gap-1">
|
||||||
{row.notificationsEnabled && (
|
{row.muted ? (
|
||||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
<span aria-label="Channel muted" title="Channel muted">
|
||||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : (
|
||||||
{row.unreadCount > 0 && (
|
<>
|
||||||
<span
|
{row.notificationsEnabled && (
|
||||||
className={cn(
|
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||||
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
highlightUnread
|
</span>
|
||||||
? '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 > 0 && (
|
||||||
>
|
<span
|
||||||
{row.unreadCount}
|
className={cn(
|
||||||
</span>
|
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||||
|
highlightUnread
|
||||||
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
|
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||||
|
)}
|
||||||
|
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
{row.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
|
import { useMemo } from 'react';
|
||||||
|
import { RepeaterPane, NotFetched, LppSensorRow, formatLppLabel } from './repeaterPaneShared';
|
||||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||||
|
|
||||||
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { distanceUnit } = useDistanceUnit();
|
const { distanceUnit } = useDistanceUnit();
|
||||||
|
|
||||||
|
// Build disambiguated labels matching the telemetry history chart names
|
||||||
|
const labels = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return data.sensors.map((s) => {
|
||||||
|
const base = `${s.type_name}_${s.channel}`;
|
||||||
|
const n = (counts.get(base) ?? 0) + 1;
|
||||||
|
counts.set(base, n);
|
||||||
|
return formatLppLabel(s.type_name) + ` Ch${s.channel}` + (n > 1 ? ` (${n})` : '');
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{data.sensors.map((sensor, i) => (
|
{data.sensors.map((sensor, i) => (
|
||||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
|
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../t
|
|||||||
|
|
||||||
const MAX_TRACKED = 8;
|
const MAX_TRACKED = 8;
|
||||||
|
|
||||||
type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
type BuiltinMetric =
|
||||||
|
| 'battery_volts'
|
||||||
|
| 'noise_floor_dbm'
|
||||||
|
| 'packets'
|
||||||
|
| 'recv_errors'
|
||||||
|
| 'uptime_seconds';
|
||||||
|
|
||||||
interface MetricConfig {
|
interface MetricConfig {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -29,6 +34,7 @@ const BUILTIN_METRIC_CONFIG: Record<BuiltinMetric, MetricConfig> = {
|
|||||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||||
|
recv_errors: { label: 'RX Errors', unit: '', color: '#ef4444' },
|
||||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,9 +43,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
|
|||||||
// Stable color rotation for dynamic LPP sensors
|
// Stable color rotation for dynamic LPP sensors
|
||||||
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48'];
|
||||||
|
|
||||||
/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */
|
/** Assign disambiguated flat keys to an array of LPP sensors.
|
||||||
function lppKey(s: TelemetryLppSensor): string {
|
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
|
||||||
return `lpp_${s.type_name}_ch${s.channel}`;
|
function assignLppKeys(
|
||||||
|
sensors: TelemetryLppSensor[]
|
||||||
|
): { sensor: TelemetryLppSensor; key: string; occurrence: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return sensors.map((s) => {
|
||||||
|
const base = `lpp_${s.type_name}_ch${s.channel}`;
|
||||||
|
const n = (counts.get(base) ?? 0) + 1;
|
||||||
|
counts.set(base, n);
|
||||||
|
return { sensor: s, key: n === 1 ? base : `${base}_${n}`, occurrence: n };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLTIP_STYLE = {
|
const TOOLTIP_STYLE = {
|
||||||
@@ -93,11 +108,10 @@ export function TelemetryHistoryPane({
|
|||||||
|
|
||||||
// Discover unique LPP sensors across all history entries
|
// Discover unique LPP sensors across all history entries
|
||||||
const lppMetrics = useMemo(() => {
|
const lppMetrics = useMemo(() => {
|
||||||
const seen = new Map<string, { type_name: string; channel: number }>();
|
const seen = new Map<string, { type_name: string; channel: number; occurrence: number }>();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
for (const s of e.data.lpp_sensors ?? []) {
|
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
|
||||||
const k = lppKey(s);
|
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
|
||||||
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
||||||
@@ -106,7 +120,8 @@ export function TelemetryHistoryPane({
|
|||||||
const label =
|
const label =
|
||||||
info.type_name.charAt(0).toUpperCase() +
|
info.type_name.charAt(0).toUpperCase() +
|
||||||
info.type_name.slice(1).replace(/_/g, ' ') +
|
info.type_name.slice(1).replace(/_/g, ' ') +
|
||||||
` Ch${info.channel}`;
|
` Ch${info.channel}` +
|
||||||
|
(info.occurrence > 1 ? ` (${info.occurrence})` : '');
|
||||||
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
||||||
result.push({
|
result.push({
|
||||||
key: k,
|
key: k,
|
||||||
@@ -139,18 +154,25 @@ export function TelemetryHistoryPane({
|
|||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return entries.map((e) => {
|
return entries.map((e) => {
|
||||||
const d = e.data;
|
const d = e.data;
|
||||||
|
const recvErrors = d.recv_errors ?? undefined;
|
||||||
|
const packetsReceived = d.packets_received;
|
||||||
const point: Record<string, number | undefined> = {
|
const point: Record<string, number | undefined> = {
|
||||||
timestamp: e.timestamp,
|
timestamp: e.timestamp,
|
||||||
battery_volts: d.battery_volts,
|
battery_volts: d.battery_volts,
|
||||||
noise_floor_dbm: d.noise_floor_dbm,
|
noise_floor_dbm: d.noise_floor_dbm,
|
||||||
packets_received: d.packets_received,
|
packets_received: packetsReceived,
|
||||||
packets_sent: d.packets_sent,
|
packets_sent: d.packets_sent,
|
||||||
|
recv_errors: recvErrors,
|
||||||
|
recv_error_pct:
|
||||||
|
recvErrors != null && packetsReceived != null && packetsReceived + recvErrors > 0
|
||||||
|
? +((recvErrors / (packetsReceived + recvErrors)) * 100).toFixed(2)
|
||||||
|
: undefined,
|
||||||
uptime_seconds: d.uptime_seconds,
|
uptime_seconds: d.uptime_seconds,
|
||||||
};
|
};
|
||||||
// Flatten LPP sensors into the point, converting units as needed
|
// Flatten LPP sensors into the point, converting units as needed
|
||||||
for (const s of d.lpp_sensors ?? []) {
|
for (const { sensor: s, key } of assignLppKeys(d.lpp_sensors ?? [])) {
|
||||||
if (typeof s.value === 'number') {
|
if (typeof s.value === 'number') {
|
||||||
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return point;
|
return point;
|
||||||
@@ -158,7 +180,11 @@ export function TelemetryHistoryPane({
|
|||||||
}, [entries, distanceUnit]);
|
}, [entries, distanceUnit]);
|
||||||
|
|
||||||
const dataKeys =
|
const dataKeys =
|
||||||
activeMetric === 'packets' ? ['packets_received', 'packets_sent'] : [activeMetric];
|
activeMetric === 'packets'
|
||||||
|
? ['packets_received', 'packets_sent']
|
||||||
|
: activeMetric === 'recv_errors'
|
||||||
|
? ['recv_errors', 'recv_error_pct']
|
||||||
|
: [activeMetric];
|
||||||
|
|
||||||
const yDomain = useMemo<[number, number] | undefined>(() => {
|
const yDomain = useMemo<[number, number] | undefined>(() => {
|
||||||
if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined;
|
if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined;
|
||||||
@@ -169,6 +195,20 @@ export function TelemetryHistoryPane({
|
|||||||
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
||||||
}, [activeMetric, chartData]);
|
}, [activeMetric, chartData]);
|
||||||
|
|
||||||
|
const yDomainPct = useMemo<[number, number]>(() => {
|
||||||
|
const MIN_SPAN = 5;
|
||||||
|
const values = chartData.map((d) => d.recv_error_pct).filter((v) => v != null) as number[];
|
||||||
|
if (values.length === 0) return [0, MIN_SPAN];
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
const span = hi - lo;
|
||||||
|
if (span >= MIN_SPAN)
|
||||||
|
return [Math.max(0, Math.floor(lo - span * 0.1)), Math.ceil(hi + span * 0.1)];
|
||||||
|
const pad = (MIN_SPAN - span) / 2;
|
||||||
|
const bottom = Math.max(0, Math.floor(lo - pad));
|
||||||
|
return [bottom, Math.ceil(bottom + MIN_SPAN)];
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
setToggling(true);
|
setToggling(true);
|
||||||
try {
|
try {
|
||||||
@@ -205,16 +245,16 @@ export function TelemetryHistoryPane({
|
|||||||
via the repeater pane, API calls to the endpoint (
|
via the repeater pane, API calls to the endpoint (
|
||||||
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
||||||
), or when the repeater is opted into interval telemetry polling, in which case the
|
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||||
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
|
repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
|
||||||
into this flow in the{' '}
|
|
||||||
<a
|
<a
|
||||||
href="#settings/database"
|
href="#settings/database"
|
||||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||||
>
|
>
|
||||||
Database & Messaging
|
Settings → Database & Messaging
|
||||||
</a>{' '}
|
</a>
|
||||||
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
, where you can also see which repeaters are currently opted in. A maximum of{' '}
|
||||||
of keeping mesh congestion reasonable.
|
{MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
|
||||||
|
reasonable.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isTracked ? (
|
{isTracked ? (
|
||||||
@@ -243,7 +283,7 @@ export function TelemetryHistoryPane({
|
|||||||
disabled={toggling}
|
disabled={toggling}
|
||||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||||
>
|
>
|
||||||
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
|
{toggling ? 'Updating...' : 'Opt Repeater into Interval Metrics Tracking'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +330,15 @@ export function TelemetryHistoryPane({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
<AreaChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{
|
||||||
|
top: 4,
|
||||||
|
right: activeMetric === 'recv_errors' ? 8 : 4,
|
||||||
|
bottom: 0,
|
||||||
|
left: -8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
@@ -302,6 +350,7 @@ export function TelemetryHistoryPane({
|
|||||||
tickFormatter={formatTime}
|
tickFormatter={formatTime}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
domain={yDomain}
|
domain={yDomain}
|
||||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -310,6 +359,17 @@ export function TelemetryHistoryPane({
|
|||||||
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
|
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{activeMetric === 'recv_errors' && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
domain={yDomainPct}
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
{...TOOLTIP_STYLE}
|
{...TOOLTIP_STYLE}
|
||||||
cursor={{
|
cursor={{
|
||||||
@@ -321,6 +381,10 @@ export function TelemetryHistoryPane({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
formatter={(value: any, name: any) => {
|
formatter={(value: any, name: any) => {
|
||||||
const numVal = typeof value === 'number' ? value : Number(value);
|
const numVal = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (activeMetric === 'recv_errors') {
|
||||||
|
if (name === 'recv_error_pct') return [`${numVal}%`, 'Error Rate'];
|
||||||
|
return [`${value}`, 'RX Errors'];
|
||||||
|
}
|
||||||
const display =
|
const display =
|
||||||
activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||||
const suffix =
|
const suffix =
|
||||||
@@ -338,51 +402,44 @@ export function TelemetryHistoryPane({
|
|||||||
return [`${display}${suffix}`, label];
|
return [`${display}${suffix}`, label];
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{dataKeys.map((key, i) => (
|
{dataKeys.map((key, i) => {
|
||||||
<Area
|
const color =
|
||||||
key={key}
|
activeMetric === 'packets'
|
||||||
type="linear"
|
? i === 0
|
||||||
dataKey={key}
|
? '#0ea5e9'
|
||||||
stroke={
|
: '#f43f5e'
|
||||||
activeMetric === 'packets'
|
: activeMetric === 'recv_errors'
|
||||||
? i === 0
|
? i === 0
|
||||||
? '#0ea5e9'
|
? '#ef4444'
|
||||||
: '#f43f5e'
|
: '#f59e0b'
|
||||||
: activeConfig.color
|
: activeConfig.color;
|
||||||
}
|
return (
|
||||||
fill={
|
<Area
|
||||||
activeMetric === 'packets'
|
key={key}
|
||||||
? i === 0
|
type="linear"
|
||||||
? '#0ea5e9'
|
dataKey={key}
|
||||||
: '#f43f5e'
|
yAxisId={
|
||||||
: activeConfig.color
|
activeMetric === 'recv_errors' && key === 'recv_error_pct' ? 'right' : 'left'
|
||||||
}
|
}
|
||||||
fillOpacity={0.15}
|
stroke={color}
|
||||||
strokeWidth={1.5}
|
fill={color}
|
||||||
dot={{
|
fillOpacity={0.15}
|
||||||
r: 4,
|
strokeWidth={1.5}
|
||||||
fill:
|
dot={{
|
||||||
activeMetric === 'packets'
|
r: 4,
|
||||||
? i === 0
|
fill: color,
|
||||||
? '#0ea5e9'
|
strokeWidth: 1.5,
|
||||||
: '#f43f5e'
|
stroke: 'hsl(var(--popover))',
|
||||||
: activeConfig.color,
|
}}
|
||||||
strokeWidth: 1.5,
|
activeDot={{
|
||||||
stroke: 'hsl(var(--popover))',
|
r: 6,
|
||||||
}}
|
fill: color,
|
||||||
activeDot={{
|
strokeWidth: 2,
|
||||||
r: 6,
|
stroke: 'hsl(var(--popover))',
|
||||||
fill:
|
}}
|
||||||
activeMetric === 'packets'
|
/>
|
||||||
? i === 0
|
);
|
||||||
? '#0ea5e9'
|
})}
|
||||||
: '#f43f5e'
|
|
||||||
: activeConfig.color,
|
|
||||||
strokeWidth: 2,
|
|
||||||
stroke: 'hsl(var(--popover))',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -91,6 +91,26 @@ export function TelemetryPane({
|
|||||||
label="Duplicates"
|
label="Duplicates"
|
||||||
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
|
||||||
/>
|
/>
|
||||||
|
{data.recv_errors != null && (
|
||||||
|
<KvRow
|
||||||
|
label="RX Errors"
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{data.recv_errors.toLocaleString()}
|
||||||
|
{data.packets_received > 0 && (
|
||||||
|
<Secondary>
|
||||||
|
(
|
||||||
|
{(
|
||||||
|
(data.recv_errors / (data.packets_received + data.recv_errors)) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}
|
||||||
|
%)
|
||||||
|
</Secondary>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Separator className="my-1" />
|
<Separator className="my-1" />
|
||||||
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
<KvRow label="TX Queue" value={data.tx_queue_len} />
|
||||||
<KvRow label="Debug Flags" value={data.full_events} />
|
<KvRow label="Debug Flags" value={data.full_events} />
|
||||||
|
|||||||
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
|
|||||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
|
export function LppSensorRow({
|
||||||
const label = formatLppLabel(sensor.type_name);
|
sensor,
|
||||||
|
unitPref,
|
||||||
|
label: labelOverride,
|
||||||
|
}: {
|
||||||
|
sensor: LppSensor;
|
||||||
|
unitPref?: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const label = labelOverride ?? formatLppLabel(sensor.type_name);
|
||||||
|
|
||||||
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
||||||
// Multi-value sensor (GPS, accelerometer, etc.)
|
// Multi-value sensor (GPS, accelerometer, etc.)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
|
|||||||
4: 'Sensor',
|
4: 'Sensor',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SortField = 'name' | 'type' | 'key' | 'first_seen' | 'last_seen';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleDateString([], {
|
return new Date(ts * 1000).toLocaleDateString([], {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -32,6 +35,32 @@ function datetimeToUnix(datetimeStr: string): number {
|
|||||||
return Math.floor(d.getTime() / 1000);
|
return Math.floor(d.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
sortField: SortField;
|
||||||
|
sortDir: SortDir;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sortField === field;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`px-3 py-1.5 cursor-pointer select-none hover:text-foreground transition-colors ${className ?? ''}`}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BulkDeleteContactsModalProps {
|
interface BulkDeleteContactsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -49,22 +78,42 @@ export function BulkDeleteContactsModal({
|
|||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [lastHeardAfter, setLastHeardAfter] = useState('');
|
||||||
|
const [lastHeardBefore, setLastHeardBefore] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('first_seen');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const lastClickedKeyRef = useRef<string | null>(null);
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(field === 'name' || field === 'key' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sortField]
|
||||||
|
);
|
||||||
|
|
||||||
const resetAndClose = useCallback(() => {
|
const resetAndClose = useCallback(() => {
|
||||||
setStep('select');
|
setStep('select');
|
||||||
setSelectedKeys(new Set());
|
setSelectedKeys(new Set());
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
setEndDate('');
|
setEndDate('');
|
||||||
|
setLastHeardAfter('');
|
||||||
|
setLastHeardBefore('');
|
||||||
setTypeFilter('all');
|
setTypeFilter('all');
|
||||||
|
setSortField('first_seen');
|
||||||
|
setSortDir('desc');
|
||||||
lastClickedKeyRef.current = null;
|
lastClickedKeyRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
let list = [...contacts];
|
||||||
if (typeFilter !== 'all') {
|
if (typeFilter !== 'all') {
|
||||||
list = list.filter((c) => c.type === typeFilter);
|
list = list.filter((c) => c.type === typeFilter);
|
||||||
}
|
}
|
||||||
@@ -76,8 +125,44 @@ export function BulkDeleteContactsModal({
|
|||||||
const end = datetimeToUnix(endDate);
|
const end = datetimeToUnix(endDate);
|
||||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||||
}
|
}
|
||||||
|
if (lastHeardAfter) {
|
||||||
|
const after = datetimeToUnix(lastHeardAfter);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) >= after);
|
||||||
|
}
|
||||||
|
if (lastHeardBefore) {
|
||||||
|
const before = datetimeToUnix(lastHeardBefore);
|
||||||
|
list = list.filter((c) => (c.last_seen ?? 0) <= before);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name': {
|
||||||
|
const an = getContactDisplayName(a.name, a.public_key, a.last_advert).toLowerCase();
|
||||||
|
const bn = getContactDisplayName(b.name, b.public_key, b.last_advert).toLowerCase();
|
||||||
|
return an < bn ? -dir : an > bn ? dir : 0;
|
||||||
|
}
|
||||||
|
case 'type':
|
||||||
|
return (a.type - b.type) * dir;
|
||||||
|
case 'key':
|
||||||
|
return a.public_key < b.public_key ? -dir : a.public_key > b.public_key ? dir : 0;
|
||||||
|
case 'first_seen':
|
||||||
|
return ((a.first_seen ?? 0) - (b.first_seen ?? 0)) * dir;
|
||||||
|
case 'last_seen':
|
||||||
|
return ((a.last_seen ?? 0) - (b.last_seen ?? 0)) * dir;
|
||||||
|
}
|
||||||
|
});
|
||||||
return list;
|
return list;
|
||||||
}, [contacts, typeFilter, startDate, endDate]);
|
}, [
|
||||||
|
contacts,
|
||||||
|
typeFilter,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
lastHeardAfter,
|
||||||
|
lastHeardBefore,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||||
@@ -148,6 +233,8 @@ export function BulkDeleteContactsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasFilters = startDate || endDate || lastHeardAfter || lastHeardBefore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||||
@@ -164,40 +251,64 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
{step === 'select' && (
|
{step === 'select' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Show</label>
|
<div className="space-y-1">
|
||||||
<select
|
<label className="text-xs text-muted-foreground">Show</label>
|
||||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
<select
|
||||||
onChange={(e) =>
|
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
onChange={(e) =>
|
||||||
}
|
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
}
|
||||||
>
|
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
<option value="all">All</option>
|
>
|
||||||
<option value="1">Clients</option>
|
<option value="all">All</option>
|
||||||
<option value="2">Repeaters</option>
|
<option value="1">Clients</option>
|
||||||
<option value="3">Room Servers</option>
|
<option value="2">Repeaters</option>
|
||||||
<option value="4">Sensors</option>
|
<option value="3">Room Servers</option>
|
||||||
</select>
|
<option value="4">Sensors</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created after</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Created after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={startDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
value={startDate}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Created before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-muted-foreground">Created before</label>
|
<div className="space-y-1">
|
||||||
<Input
|
<label className="text-xs text-muted-foreground">Last heard after</label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={endDate}
|
type="datetime-local"
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
value={lastHeardAfter}
|
||||||
className="w-48 h-8 text-sm"
|
onChange={(e) => setLastHeardAfter(e.target.value)}
|
||||||
/>
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Last heard before</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={lastHeardBefore}
|
||||||
|
onChange={(e) => setLastHeardBefore(e.target.value)}
|
||||||
|
className="w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||||
@@ -211,7 +322,7 @@ export function BulkDeleteContactsModal({
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||||
{(startDate || endDate) && ' (filtered)'}
|
{hasFilters && ' (filtered)'}
|
||||||
{' · '}
|
{' · '}
|
||||||
{selectedKeys.size} selected
|
{selectedKeys.size} selected
|
||||||
</div>
|
</div>
|
||||||
@@ -219,17 +330,51 @@ export function BulkDeleteContactsModal({
|
|||||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||||
{filteredContacts.length === 0 ? (
|
{filteredContacts.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
No contacts match the selected date range.
|
No contacts match the selected filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||||
<tr className="text-left text-xs text-muted-foreground">
|
<tr className="text-left text-xs text-muted-foreground">
|
||||||
<th className="px-3 py-1.5 w-8" />
|
<th className="px-3 py-1.5 w-8" />
|
||||||
<th className="px-3 py-1.5">Name</th>
|
<SortableHeader
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
label="Name"
|
||||||
<th className="px-3 py-1.5">Key</th>
|
field="name"
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Type"
|
||||||
|
field="type"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Key"
|
||||||
|
field="key"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Created"
|
||||||
|
field="first_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Last heard"
|
||||||
|
field="last_seen"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={handleSort}
|
||||||
|
className="hidden sm:table-cell"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -265,6 +410,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -298,6 +446,7 @@ export function BulkDeleteContactsModal({
|
|||||||
<th className="px-3 py-1.5">Type</th>
|
<th className="px-3 py-1.5">Type</th>
|
||||||
<th className="px-3 py-1.5">Key</th>
|
<th className="px-3 py-1.5">Key</th>
|
||||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||||
|
<th className="px-3 py-1.5 hidden sm:table-cell">Last heard</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -315,6 +464,9 @@ export function BulkDeleteContactsModal({
|
|||||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||||
|
{c.last_seen ? formatDate(c.last_seen) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ export function SettingsDatabaseSection({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [trackedTelemetryRepeaters.length, appSettings.telemetry_interval_hours]);
|
}, [
|
||||||
|
trackedTelemetryRepeaters.length,
|
||||||
|
appSettings.telemetry_interval_hours,
|
||||||
|
appSettings.telemetry_routed_hourly,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||||
@@ -346,13 +350,41 @@ export function SettingsDatabaseSection({
|
|||||||
restored if you drop back to a supported count.
|
restored if you drop back to a supported count.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{schedule?.next_run_at != null && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Next run at {formatTime(schedule.next_run_at)} (UTC top of hour).
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Routed hourly toggle */}
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={appSettings.telemetry_routed_hourly}
|
||||||
|
onChange={() => {
|
||||||
|
const next = !appSettings.telemetry_routed_hourly;
|
||||||
|
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
|
||||||
|
every hour instead of on the scheduled interval above. Flood-only repeaters still
|
||||||
|
follow the normal schedule.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{schedule?.next_run_at != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
|
||||||
|
{formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{schedule?.next_routed_run_at != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{trackedTelemetryRepeaters.length === 0 ? (
|
{trackedTelemetryRepeaters.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground italic">
|
<p className="text-sm text-muted-foreground italic">
|
||||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||||
@@ -362,6 +394,21 @@ export function SettingsDatabaseSection({
|
|||||||
{trackedTelemetryRepeaters.map((key) => {
|
{trackedTelemetryRepeaters.map((key) => {
|
||||||
const contact = contacts.find((c) => c.public_key === key);
|
const contact = contacts.find((c) => c.public_key === key);
|
||||||
const displayName = contact?.name ?? key.slice(0, 12);
|
const displayName = contact?.name ?? key.slice(0, 12);
|
||||||
|
const routeSource = contact?.effective_route_source ?? 'flood';
|
||||||
|
// A forced-flood override (path_len < 0) still reports source
|
||||||
|
// "override", but the actual route is flood. Check the real path.
|
||||||
|
const hasRealPath =
|
||||||
|
contact?.effective_route != null && contact.effective_route.path_len >= 0;
|
||||||
|
const routeLabel = !hasRealPath
|
||||||
|
? 'flood'
|
||||||
|
: routeSource === 'override'
|
||||||
|
? 'routed'
|
||||||
|
: routeSource === 'direct'
|
||||||
|
? 'direct'
|
||||||
|
: 'flood';
|
||||||
|
const routeColor = hasRealPath
|
||||||
|
? 'text-primary bg-primary/10'
|
||||||
|
: 'text-muted-foreground bg-muted';
|
||||||
const snap = latestTelemetry[key];
|
const snap = latestTelemetry[key];
|
||||||
const d = snap?.data;
|
const d = snap?.data;
|
||||||
return (
|
return (
|
||||||
@@ -369,9 +416,16 @@ export function SettingsDatabaseSection({
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm truncate block">{displayName}</span>
|
<span className="text-sm truncate block">{displayName}</span>
|
||||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
<div className="flex items-center gap-1.5">
|
||||||
{key.slice(0, 12)}
|
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||||
</span>
|
{key.slice(0, 12)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
|
||||||
|
>
|
||||||
|
{routeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{onToggleTrackedTelemetry && (
|
{onToggleTrackedTelemetry && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
import { ChevronDown, Info } from 'lucide-react';
|
import { ChevronDown, Info } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
@@ -278,7 +287,10 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
config: {
|
config: {
|
||||||
urls: '',
|
urls: '',
|
||||||
preserve_identity: true,
|
preserve_identity: true,
|
||||||
include_path: true,
|
markdown_format: true,
|
||||||
|
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
|
body_format_channel:
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||||
},
|
},
|
||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
@@ -1194,7 +1206,7 @@ function MqttHaConfigEditor({
|
|||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
Published Topic Summary
|
Published topic summary
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -2376,6 +2388,116 @@ function ScopeSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||||
|
const APPRISE_DEFAULT_CHANNEL =
|
||||||
|
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||||
|
const APPRISE_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]';
|
||||||
|
const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]';
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS: Record<string, string> = {
|
||||||
|
type: 'CHAN',
|
||||||
|
text: 'hello world',
|
||||||
|
sender_name: 'Alice',
|
||||||
|
sender_key: 'a1b2c3d4e5f6',
|
||||||
|
channel_name: '#general',
|
||||||
|
conversation_key: 'abcdef1234567890',
|
||||||
|
hops: '2a, 3b',
|
||||||
|
hops_backticked: '`2a`, `3b`',
|
||||||
|
hop_count: '2',
|
||||||
|
rssi: '-95',
|
||||||
|
snr: '6.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPRISE_SAMPLE_VARS_DM: Record<string, string> = {
|
||||||
|
...APPRISE_SAMPLE_VARS,
|
||||||
|
type: 'PRIV',
|
||||||
|
channel_name: '',
|
||||||
|
conversation_key: 'a1b2c3d4e5f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
|
||||||
|
let result = fmt;
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
result = result.split(`{${key}}`).join(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a markdown-ish string into inline React elements (bold, italic, code). */
|
||||||
|
function appriseRenderMarkdown(s: string): ReactNode[] {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
// Split on **bold**, __bold__, *italic*, _italic_, and `code` spans.
|
||||||
|
// Longer delimiters first so ** and __ match before * and _.
|
||||||
|
const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (
|
||||||
|
(part.startsWith('**') && part.endsWith('**')) ||
|
||||||
|
(part.startsWith('__') && part.endsWith('__'))
|
||||||
|
) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={key++} className="font-bold">
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
(part.startsWith('*') && part.endsWith('*')) ||
|
||||||
|
(part.startsWith('_') && part.endsWith('_'))
|
||||||
|
) {
|
||||||
|
nodes.push(
|
||||||
|
<em key={key++} className="italic">
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
nodes.push(
|
||||||
|
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else if (part) {
|
||||||
|
nodes.push(<span key={key++}>{part}</span>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppriseFormatPreview({
|
||||||
|
format,
|
||||||
|
vars,
|
||||||
|
markdown = true,
|
||||||
|
}: {
|
||||||
|
format: string;
|
||||||
|
vars: Record<string, string>;
|
||||||
|
markdown?: boolean;
|
||||||
|
}) {
|
||||||
|
const raw = appriseApplyFormat(format, vars);
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
|
||||||
|
{markdown && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Rendered (Discord, Slack, Telegram)
|
||||||
|
</span>
|
||||||
|
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
{markdown ? 'Raw (email, SMS)' : 'Preview'}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appriseIsDefault(value: unknown, defaultStr: string): boolean {
|
||||||
|
if (value == null) return true;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === '' || s === defaultStr;
|
||||||
|
}
|
||||||
|
|
||||||
function AppriseConfigEditor({
|
function AppriseConfigEditor({
|
||||||
config,
|
config,
|
||||||
scope,
|
scope,
|
||||||
@@ -2387,6 +2509,12 @@ function AppriseConfigEditor({
|
|||||||
onChange: (config: Record<string, unknown>) => void;
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const markdown = config.markdown_format !== false;
|
||||||
|
const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN;
|
||||||
|
const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN;
|
||||||
|
const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm;
|
||||||
|
const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
@@ -2445,16 +2573,145 @@ function AppriseConfigEditor({
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={config.include_path !== false}
|
checked={markdown}
|
||||||
onChange={(e) => onChange({ ...config, include_path: e.target.checked })}
|
onChange={(e) => {
|
||||||
|
const md = e.target.checked;
|
||||||
|
const updates: Record<string, unknown> = { ...config, markdown_format: md };
|
||||||
|
const curDm = ((config.body_format_dm as string) || '').trim();
|
||||||
|
const curChan = ((config.body_format_channel as string) || '').trim();
|
||||||
|
if (md) {
|
||||||
|
if (!curDm || curDm === APPRISE_DEFAULT_DM_PLAIN)
|
||||||
|
updates.body_format_dm = APPRISE_DEFAULT_DM;
|
||||||
|
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL_PLAIN)
|
||||||
|
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL;
|
||||||
|
} else {
|
||||||
|
if (!curDm || curDm === APPRISE_DEFAULT_DM)
|
||||||
|
updates.body_format_dm = APPRISE_DEFAULT_DM_PLAIN;
|
||||||
|
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL)
|
||||||
|
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL_PLAIN;
|
||||||
|
}
|
||||||
|
onChange(updates);
|
||||||
|
}}
|
||||||
className="h-4 w-4 rounded border-border"
|
className="h-4 w-4 rounded border-border"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Include routing path in notifications</span>
|
<div>
|
||||||
|
<span className="text-sm">Markdown formatting</span>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
If notifications fail on services like Telegram due to special characters in sender
|
||||||
|
names, disable this option.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<details className="group">
|
||||||
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
|
Available variables
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 rounded-md border border-border bg-muted/30 p-2 text-xs space-y-0.5">
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{text}'}</code>
|
||||||
|
<span className="text-muted-foreground">Message body</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender display name</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{sender_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Sender public key (hex)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{channel_name}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Channel name (channel messages only)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{conversation_key}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Contact pubkey (DM) or channel key (channel)
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{type}'}</code>
|
||||||
|
<span className="text-muted-foreground">PRIV or CHAN</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{hops}'}</code>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Comma-separated hop IDs, or "direct"
|
||||||
|
</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hops_backticked}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Hops wrapped in backticks for markdown</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">
|
||||||
|
{'{hop_count}'}
|
||||||
|
</code>
|
||||||
|
<span className="text-muted-foreground">Number of hops (0 for direct)</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{rssi}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop RSSI in dBm</span>
|
||||||
|
<code className="text-[0.6875rem] font-mono bg-muted px-1 rounded">{'{snr}'}</code>
|
||||||
|
<span className="text-muted-foreground">Last-hop SNR in dB</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Empty textareas use the default format. RSSI/SNR may be empty if unavailable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
|
||||||
|
{!appriseIsDefault(config.body_format_dm, defaultDm) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset DM format to default"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onChange({ ...config, body_format_dm: defaultDm })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="fanout-apprise-fmt-dm"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||||
|
placeholder={defaultDm}
|
||||||
|
value={(config.body_format_dm as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} markdown={markdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
|
||||||
|
{!appriseIsDefault(config.body_format_channel, defaultChan) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reset channel format to default"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => onChange({ ...config, body_format_channel: defaultChan })}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="fanout-apprise-fmt-chan"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||||
|
placeholder={defaultChan}
|
||||||
|
value={(config.body_format_channel as string) ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} markdown={markdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
<ScopeSelector scope={scope} onChange={onScopeChange} />
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ import {
|
|||||||
setSavedFontScale,
|
setSavedFontScale,
|
||||||
} from '../../utils/fontScale';
|
} from '../../utils/fontScale';
|
||||||
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
||||||
|
import {
|
||||||
|
getTextReplaceEnabled,
|
||||||
|
setTextReplaceEnabled as saveTextReplaceEnabled,
|
||||||
|
getTextReplaceMapJson,
|
||||||
|
setTextReplaceMapJson,
|
||||||
|
DEFAULT_MAP_JSON,
|
||||||
|
} from '../../utils/textReplace';
|
||||||
import {
|
import {
|
||||||
BATTERY_DISPLAY_CHANGE_EVENT,
|
BATTERY_DISPLAY_CHANGE_EVENT,
|
||||||
getShowBatteryPercent,
|
getShowBatteryPercent,
|
||||||
@@ -232,6 +239,9 @@ export function SettingsLocalSection({
|
|||||||
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
|
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
|
||||||
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
|
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
|
||||||
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
|
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
|
||||||
|
const [textReplaceEnabled, setTextReplaceEnabled] = useState(getTextReplaceEnabled);
|
||||||
|
const [textReplaceJson, setTextReplaceJson] = useState(getTextReplaceMapJson);
|
||||||
|
const [textReplaceError, setTextReplaceError] = useState<string | null>(null);
|
||||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||||
@@ -439,6 +449,63 @@ export function SettingsLocalSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border/60 p-3 space-y-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="text-replace"
|
||||||
|
checked={textReplaceEnabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const v = checked === true;
|
||||||
|
setTextReplaceEnabled(v);
|
||||||
|
saveTextReplaceEnabled(v);
|
||||||
|
}}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="text-replace">Replace as you Type</Label>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
Automatically replace characters as you type in the message input. Define
|
||||||
|
replacements as a JSON object mapping source strings to their replacements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{textReplaceEnabled && (
|
||||||
|
<div className="space-y-2 pl-7">
|
||||||
|
<textarea
|
||||||
|
value={textReplaceJson}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setTextReplaceJson(val);
|
||||||
|
setTextReplaceError(setTextReplaceMapJson(val));
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
rows={10}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border bg-background px-3 py-2 text-sm font-mono',
|
||||||
|
textReplaceError ? 'border-destructive' : 'border-input'
|
||||||
|
)}
|
||||||
|
aria-label="Text replacement map (JSON)"
|
||||||
|
/>
|
||||||
|
{textReplaceError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{textReplaceError} Changes are not saved until this is resolved.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTextReplaceJson(DEFAULT_MAP_JSON);
|
||||||
|
setTextReplaceMapJson(DEFAULT_MAP_JSON);
|
||||||
|
setTextReplaceError(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex h-8 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { MapPinned } from 'lucide-react';
|
import { ChevronDown, Download, MapPinned, Upload } from 'lucide-react';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
import { api } from '../../api';
|
||||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||||
import type {
|
import type {
|
||||||
@@ -17,8 +26,116 @@ import type {
|
|||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
RadioDiscoveryTarget,
|
RadioDiscoveryTarget,
|
||||||
|
RadioStatsSnapshot,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
|
function formatUptime(secs: number): string {
|
||||||
|
const days = Math.floor(secs / 86400);
|
||||||
|
const hours = Math.floor((secs % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((secs % 3600) / 60);
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAirtime(secs: number): string {
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const hours = Math.floor(secs / 3600);
|
||||||
|
const minutes = Math.floor((secs % 3600) / 60);
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 py-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono tabular-nums ${warn ? 'text-warning font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioDetailsCollapsible({ stats }: { stats: RadioStatsSnapshot }) {
|
||||||
|
const age = stats.timestamp ? Math.max(0, Math.floor(Date.now() / 1000) - stats.timestamp) : null;
|
||||||
|
const packets = {
|
||||||
|
recv: stats.packets_recv,
|
||||||
|
sent: stats.packets_sent,
|
||||||
|
flood_tx: stats.flood_tx,
|
||||||
|
direct_tx: stats.direct_tx,
|
||||||
|
flood_rx: stats.flood_rx,
|
||||||
|
direct_rx: stats.direct_rx,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
|
Radio Details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||||
|
{age !== null && (
|
||||||
|
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Updated {age < 5 ? 'just now' : `${age}s ago`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Core */}
|
||||||
|
{stats.uptime_secs != null && (
|
||||||
|
<StatRow label="Uptime" value={formatUptime(stats.uptime_secs)} />
|
||||||
|
)}
|
||||||
|
{stats.battery_mv != null && stats.battery_mv > 0 && (
|
||||||
|
<StatRow label="Battery" value={`${(stats.battery_mv / 1000).toFixed(2)}V`} />
|
||||||
|
)}
|
||||||
|
{stats.queue_len != null && (
|
||||||
|
<StatRow
|
||||||
|
label="TX Queue"
|
||||||
|
value={`${stats.queue_len} / 16`}
|
||||||
|
warn={stats.queue_len >= 14}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stats.errors != null && (
|
||||||
|
<StatRow label="Errors" value={String(stats.errors)} warn={stats.errors > 0} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RF */}
|
||||||
|
{stats.noise_floor != null && (
|
||||||
|
<StatRow label="Noise Floor" value={`${stats.noise_floor} dBm`} />
|
||||||
|
)}
|
||||||
|
{stats.last_rssi != null && <StatRow label="Last RSSI" value={`${stats.last_rssi} dBm`} />}
|
||||||
|
{stats.last_snr != null && <StatRow label="Last SNR" value={`${stats.last_snr} dB`} />}
|
||||||
|
|
||||||
|
{/* Airtime */}
|
||||||
|
{(stats.tx_air_secs != null || stats.rx_air_secs != null) && (
|
||||||
|
<>
|
||||||
|
{stats.tx_air_secs != null && (
|
||||||
|
<StatRow label="TX Airtime" value={formatAirtime(stats.tx_air_secs)} />
|
||||||
|
)}
|
||||||
|
{stats.rx_air_secs != null && (
|
||||||
|
<StatRow label="RX Airtime" value={formatAirtime(stats.rx_air_secs)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Packets */}
|
||||||
|
{packets.recv != null && <StatRow label="Packets Received" value={String(packets.recv)} />}
|
||||||
|
{packets.sent != null && <StatRow label="Packets Sent" value={String(packets.sent)} />}
|
||||||
|
{packets.flood_tx != null && <StatRow label="Flood TX" value={String(packets.flood_tx)} />}
|
||||||
|
{packets.flood_rx != null && <StatRow label="Flood RX" value={String(packets.flood_rx)} />}
|
||||||
|
{packets.direct_tx != null && (
|
||||||
|
<StatRow label="Direct TX" value={String(packets.direct_tx)} />
|
||||||
|
)}
|
||||||
|
{packets.direct_rx != null && (
|
||||||
|
<StatRow label="Direct RX" value={String(packets.direct_rx)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsRadioSection({
|
export function SettingsRadioSection({
|
||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
@@ -279,11 +396,6 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const update: AppSettingsUpdate = {};
|
const update: AppSettingsUpdate = {};
|
||||||
const hours = parseInt(advertIntervalHours, 10);
|
|
||||||
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
|
||||||
if (newAdvertInterval !== appSettings.advert_interval) {
|
|
||||||
update.advert_interval = newAdvertInterval;
|
|
||||||
}
|
|
||||||
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
||||||
update.flood_scope = floodScope;
|
update.flood_scope = floodScope;
|
||||||
}
|
}
|
||||||
@@ -302,6 +414,27 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [advertIntervalBusy, setAdvertIntervalBusy] = useState(false);
|
||||||
|
const [advertIntervalError, setAdvertIntervalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSaveAdvertInterval = async () => {
|
||||||
|
setAdvertIntervalError(null);
|
||||||
|
setAdvertIntervalBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hours = parseInt(advertIntervalHours, 10);
|
||||||
|
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
|
||||||
|
if (newAdvertInterval !== appSettings.advert_interval) {
|
||||||
|
await onSaveAppSettings({ advert_interval: newAdvertInterval });
|
||||||
|
}
|
||||||
|
toast.success('Advertising interval saved');
|
||||||
|
} catch (err) {
|
||||||
|
setAdvertIntervalError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setAdvertIntervalBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
const handleAdvertise = async (mode: RadioAdvertMode) => {
|
||||||
setAdvertisingMode(mode);
|
setAdvertisingMode(mode);
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +453,169 @@ export function SettingsRadioSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
|
||||||
|
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
const buildConfigProfile = () => ({
|
||||||
|
version: 1,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
name: config.name,
|
||||||
|
lat: config.lat,
|
||||||
|
lon: config.lon,
|
||||||
|
tx_power: config.tx_power,
|
||||||
|
radio: { ...config.radio },
|
||||||
|
path_hash_mode: config.path_hash_mode,
|
||||||
|
advert_location_source: config.advert_location_source ?? 'current',
|
||||||
|
multi_acks_enabled: config.multi_acks_enabled ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadJson = (profile: object, suffix: string) => {
|
||||||
|
const blob = new Blob([JSON.stringify(profile, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const safeName = (config.name || 'radio').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.replace(/[/:, ]+/g, '-');
|
||||||
|
a.download = `${safeName}-${suffix}-${timestamp}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportConfig = async () => {
|
||||||
|
const profile = buildConfigProfile();
|
||||||
|
try {
|
||||||
|
const { private_key } = await api.getPrivateKey();
|
||||||
|
downloadJson({ ...profile, private_key }, 'config');
|
||||||
|
toast.success('Export generated with private key');
|
||||||
|
} catch {
|
||||||
|
downloadJson(profile, 'config');
|
||||||
|
toast.info('Export generated without private key', {
|
||||||
|
description: 'See README_ADVANCED.md for private key export enable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateImportData = (
|
||||||
|
data: unknown
|
||||||
|
): data is {
|
||||||
|
name: string;
|
||||||
|
radio: { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
[k: string]: unknown;
|
||||||
|
} =>
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'name' in data &&
|
||||||
|
typeof (data as Record<string, unknown>).name === 'string' &&
|
||||||
|
'radio' in data &&
|
||||||
|
typeof (data as Record<string, unknown>).radio === 'object' &&
|
||||||
|
(data as Record<string, unknown>).radio !== null &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.freq === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.bw === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.sf === 'number' &&
|
||||||
|
typeof (data as Record<string, Record<string, unknown>>).radio.cr === 'number';
|
||||||
|
|
||||||
|
const populateFormFromImport = (data: Record<string, unknown>) => {
|
||||||
|
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
setName(data.name as string);
|
||||||
|
if (typeof data.lat === 'number') setLat(String(data.lat));
|
||||||
|
if (typeof data.lon === 'number') setLon(String(data.lon));
|
||||||
|
if (typeof data.tx_power === 'number') setTxPower(String(data.tx_power));
|
||||||
|
setFreq(String(radio.freq));
|
||||||
|
setBw(String(radio.bw));
|
||||||
|
setSf(String(radio.sf));
|
||||||
|
setCr(String(radio.cr));
|
||||||
|
if (typeof data.path_hash_mode === 'number') setPathHashMode(String(data.path_hash_mode));
|
||||||
|
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||||
|
setAdvertLocationSource(data.advert_location_source);
|
||||||
|
if (typeof data.multi_acks_enabled === 'boolean') setMultiAcksEnabled(data.multi_acks_enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUpdateFromImport = (data: Record<string, unknown>): RadioConfigUpdate => {
|
||||||
|
const radio = data.radio as { freq: number; bw: number; sf: number; cr: number };
|
||||||
|
const update: RadioConfigUpdate = {
|
||||||
|
name: data.name as string,
|
||||||
|
lat: typeof data.lat === 'number' ? data.lat : config.lat,
|
||||||
|
lon: typeof data.lon === 'number' ? data.lon : config.lon,
|
||||||
|
tx_power: typeof data.tx_power === 'number' ? (data.tx_power as number) : config.tx_power,
|
||||||
|
radio,
|
||||||
|
};
|
||||||
|
if (data.advert_location_source === 'off' || data.advert_location_source === 'current')
|
||||||
|
update.advert_location_source = data.advert_location_source;
|
||||||
|
if (typeof data.multi_acks_enabled === 'boolean')
|
||||||
|
update.multi_acks_enabled = data.multi_acks_enabled;
|
||||||
|
if (config.path_hash_mode_supported && typeof data.path_hash_mode === 'number')
|
||||||
|
update.path_hash_mode = data.path_hash_mode as number;
|
||||||
|
return update;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyImport = async (data: Record<string, unknown>) => {
|
||||||
|
populateFormFromImport(data);
|
||||||
|
const update = buildUpdateFromImport(data);
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setRebooting(true);
|
||||||
|
try {
|
||||||
|
if (typeof data.private_key === 'string' && data.private_key) {
|
||||||
|
await onSetPrivateKey(data.private_key);
|
||||||
|
toast.success('Config + private key imported, saving & rebooting...');
|
||||||
|
} else {
|
||||||
|
toast.success('Config imported, saving & rebooting...');
|
||||||
|
}
|
||||||
|
await onSave(update);
|
||||||
|
await onReboot();
|
||||||
|
if (!pageMode) onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||||
|
} finally {
|
||||||
|
setRebooting(false);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!validateImportData(data)) {
|
||||||
|
toast.error('Invalid config file', {
|
||||||
|
description: 'File must contain name and radio parameters (freq, bw, sf, cr)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.private_key === 'string' && data.private_key) {
|
||||||
|
// Private key present — show warning dialog before applying
|
||||||
|
pendingImportRef.current = data;
|
||||||
|
setKeyImportDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
await applyImport(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to import config');
|
||||||
|
} finally {
|
||||||
|
if (importInputRef.current) importInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmKeyImport = async () => {
|
||||||
|
setKeyImportDialogOpen(false);
|
||||||
|
const data = pendingImportRef.current;
|
||||||
|
pendingImportRef.current = null;
|
||||||
|
if (data) await applyImport(data);
|
||||||
|
};
|
||||||
|
|
||||||
const radioState =
|
const radioState =
|
||||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||||
const connectionActionLabel =
|
const connectionActionLabel =
|
||||||
@@ -414,6 +710,9 @@ export function SettingsRadioSection({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
|
||||||
|
|
||||||
|
{health?.radio_stats && <RadioDetailsCollapsible stats={health.radio_stats} />}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -678,6 +977,37 @@ export function SettingsRadioSection({
|
|||||||
Some settings may require a reboot to take effect on some radios.
|
Some settings may require a reboot to take effect on some radios.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportConfig} className="flex-1">
|
||||||
|
<Download className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
Export Config
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
disabled={busy || rebooting}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Upload className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||||
|
Import & Reboot
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImportConfig(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
|
Export saves the current server config to a JSON file. Import loads a config file, applies
|
||||||
|
it, and reboots the radio.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* ── Messaging ── */}
|
{/* ── Messaging ── */}
|
||||||
@@ -733,9 +1063,9 @@ export function SettingsRadioSection({
|
|||||||
placeholder="MyRegion"
|
placeholder="MyRegion"
|
||||||
/>
|
/>
|
||||||
<p className="text-[0.8125rem] text-muted-foreground">
|
<p className="text-[0.8125rem] text-muted-foreground">
|
||||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
|
||||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
region can forward the traffic, while repeaters configured to deny other regions may drop
|
||||||
drop it. Leave empty to disable.
|
it. Leave empty to disable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -795,6 +1125,18 @@ export function SettingsRadioSection({
|
|||||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||||
Recommended: 24 hours or higher.
|
Recommended: 24 hours or higher.
|
||||||
</p>
|
</p>
|
||||||
|
{advertIntervalError && (
|
||||||
|
<div className="text-sm text-destructive" role="alert">
|
||||||
|
{advertIntervalError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAdvertInterval}
|
||||||
|
disabled={advertIntervalBusy}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{advertIntervalBusy ? 'Saving...' : 'Save Advertising Interval'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -907,6 +1249,44 @@ export function SettingsRadioSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Private Key Import Warning ── */}
|
||||||
|
<Dialog
|
||||||
|
open={keyImportDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setKeyImportDialogOpen(open);
|
||||||
|
if (!open) pendingImportRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import includes Private Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This config file contains a private key. Importing it will change your radio's
|
||||||
|
identity — your radio will have a new public key and other nodes will see it as
|
||||||
|
a different device. This cannot be undone without the original key.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setKeyImportDialogOpen(false);
|
||||||
|
pendingImportRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmKeyImport}
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Import Config & Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function useContactsAndChannels({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateContact = useCallback(
|
const handleCreateContact = useCallback(
|
||||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
async (name: string, publicKey: string, tryHistorical: boolean, type?: number) => {
|
||||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
const created = await api.createContact(publicKey, name || undefined, tryHistorical, type);
|
||||||
const data = await fetchAllContacts();
|
const data = await fetchAllContacts();
|
||||||
setContacts(data);
|
setContacts(data);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,33 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Race a promise against a timeout; rejects with a descriptive error on expiry. */
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${label} timed out — the service worker may have failed to install. ` +
|
||||||
|
'Mobile browsers require a trusted TLS certificate for service workers, ' +
|
||||||
|
'even if the page itself loads with a self-signed cert.'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
promise.then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
|
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
|
||||||
if (!a || a.length !== b.length) return false;
|
if (!a || a.length !== b.length) return false;
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
@@ -109,8 +136,9 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
||||||
|
|
||||||
// Check if THIS browser has an active push subscription and match it
|
// Check if THIS browser has an active push subscription and match it
|
||||||
// to a backend record.
|
// to a backend record. Use a timeout so we don't hang forever when the
|
||||||
navigator.serviceWorker.ready
|
// service worker failed to install (e.g. mobile + self-signed cert).
|
||||||
|
withTimeout(navigator.serviceWorker.ready, 1_000, 'Service worker activation')
|
||||||
.then((reg) => reg.pushManager.getSubscription())
|
.then((reg) => reg.pushManager.getSubscription())
|
||||||
.then(async (sub) => {
|
.then(async (sub) => {
|
||||||
const existing = await subsPromise;
|
const existing = await subsPromise;
|
||||||
@@ -129,7 +157,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
const refreshSubscriptions = useCallback(async () => {
|
const refreshSubscriptions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const subs = await api.getPushSubscriptions();
|
const subs = await api.getPushSubscriptions();
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await withTimeout(
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
10_000,
|
||||||
|
'Service worker activation'
|
||||||
|
);
|
||||||
const sub = await reg.pushManager.getSubscription();
|
const sub = await reg.pushManager.getSubscription();
|
||||||
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
||||||
return subs;
|
return subs;
|
||||||
@@ -155,7 +187,11 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
vapidKeyRef.current = resp.public_key;
|
vapidKeyRef.current = resp.public_key;
|
||||||
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
|
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
|
||||||
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await withTimeout(
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
3_000,
|
||||||
|
'Service worker activation'
|
||||||
|
);
|
||||||
let pushSub = await reg.pushManager.getSubscription();
|
let pushSub = await reg.pushManager.getSubscription();
|
||||||
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
||||||
const requiresRecreate =
|
const requiresRecreate =
|
||||||
@@ -188,6 +224,7 @@ export function usePushSubscription(): PushSubscriptionState {
|
|||||||
console.error('Push subscribe failed:', err);
|
console.error('Push subscribe failed:', err);
|
||||||
toast.error('Failed to enable push notifications', {
|
toast.error('Failed to enable push notifications', {
|
||||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||||
|
duration: 8_000,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface UseRealtimeAppStateArgs {
|
|||||||
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
||||||
blockedKeysRef: MutableRefObject<string[]>;
|
blockedKeysRef: MutableRefObject<string[]>;
|
||||||
blockedNamesRef: MutableRefObject<string[]>;
|
blockedNamesRef: MutableRefObject<string[]>;
|
||||||
|
channelsRef: MutableRefObject<Channel[]>;
|
||||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||||
recordMessageEvent: (args: {
|
recordMessageEvent: (args: {
|
||||||
@@ -94,6 +95,7 @@ export function useRealtimeAppState({
|
|||||||
setContacts,
|
setContacts,
|
||||||
blockedKeysRef,
|
blockedKeysRef,
|
||||||
blockedNamesRef,
|
blockedNamesRef,
|
||||||
|
channelsRef,
|
||||||
activeConversationRef,
|
activeConversationRef,
|
||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
@@ -191,16 +193,24 @@ export function useRealtimeAppState({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMutedChannel =
|
||||||
|
msg.type === 'CHAN' &&
|
||||||
|
!!msg.conversation_key &&
|
||||||
|
channelsRef.current.some((c) => c.key === msg.conversation_key && c.muted);
|
||||||
|
|
||||||
const { added: isNewMessage, activeConversation: isForActiveConversation } =
|
const { added: isNewMessage, activeConversation: isForActiveConversation } =
|
||||||
observeMessage(msg);
|
observeMessage(msg);
|
||||||
recordMessageEvent({
|
|
||||||
msg,
|
|
||||||
activeConversation: isForActiveConversation,
|
|
||||||
isNewMessage,
|
|
||||||
hasMention: checkMention(msg.text),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!msg.outgoing && isNewMessage) {
|
if (!isMutedChannel) {
|
||||||
|
recordMessageEvent({
|
||||||
|
msg,
|
||||||
|
activeConversation: isForActiveConversation,
|
||||||
|
isNewMessage,
|
||||||
|
hasMention: checkMention(msg.text),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg.outgoing && isNewMessage && !isMutedChannel) {
|
||||||
notifyIncomingMessage?.(msg);
|
notifyIncomingMessage?.(msg);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
|
|
||||||
// Register service worker for Web Push (requires secure context)
|
// Register service worker for Web Push (requires secure context)
|
||||||
if ('serviceWorker' in navigator && window.isSecureContext) {
|
if ('serviceWorker' in navigator && window.isSecureContext) {
|
||||||
navigator.serviceWorker.register('./sw.js').catch(() => {});
|
navigator.serviceWorker.register('./sw.js').catch((err) => {
|
||||||
|
console.warn('Service worker registration failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ describe('fetchJson (via api methods)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function installMockFetch() {
|
function installMockFetch() {
|
||||||
|
mockFetch.mockReset();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('BulkAddChannelResultModal', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'BB'.repeat(16),
|
key: 'BB'.repeat(16),
|
||||||
@@ -26,6 +27,7 @@ describe('BulkAddChannelResultModal', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
existing_count: 3,
|
existing_count: 3,
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ import { api } from '../api';
|
|||||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||||
|
|
||||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
is_hashtag: isHashtag,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDetail(channel: Channel): ChannelDetail {
|
function makeDetail(channel: Channel): ChannelDetail {
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { CONTACT_TYPE_ROOM } from '../types';
|
|||||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||||
|
|
||||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
is_hashtag: isHashtag,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const channel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
@@ -142,6 +143,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
|||||||
throw new Error('unused');
|
throw new Error('unused');
|
||||||
}),
|
}),
|
||||||
onToggleFavorite: vi.fn(async () => {}),
|
onToggleFavorite: vi.fn(async () => {}),
|
||||||
|
onToggleMute: vi.fn(async () => {}),
|
||||||
onDeleteContact: vi.fn(async () => {}),
|
onDeleteContact: vi.fn(async () => {}),
|
||||||
onDeleteChannel: vi.fn(async () => {}),
|
onDeleteChannel: vi.fn(async () => {}),
|
||||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ beforeEach(() => {
|
|||||||
tracked_telemetry_repeaters: [],
|
tracked_telemetry_repeaters: [],
|
||||||
auto_resend_channel: false,
|
auto_resend_channel: false,
|
||||||
telemetry_interval_hours: 8,
|
telemetry_interval_hours: 8,
|
||||||
|
telemetry_routed_hourly: false,
|
||||||
});
|
});
|
||||||
mockedApi.getRadioConfig.mockResolvedValue({
|
mockedApi.getRadioConfig.mockResolvedValue({
|
||||||
public_key: 'aa'.repeat(32),
|
public_key: 'aa'.repeat(32),
|
||||||
@@ -1050,6 +1051,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
tracked_telemetry_repeaters: ['cc'.repeat(32)],
|
tracked_telemetry_repeaters: ['cc'.repeat(32)],
|
||||||
auto_resend_channel: false,
|
auto_resend_channel: false,
|
||||||
telemetry_interval_hours: 8,
|
telemetry_interval_hours: 8,
|
||||||
|
telemetry_routed_hourly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
renderSection();
|
renderSection();
|
||||||
@@ -1057,7 +1059,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
selectCreateIntegration('Home Assistant MQTT Discovery');
|
selectCreateIntegration('Home Assistant MQTT Discovery');
|
||||||
confirmCreateIntegration();
|
confirmCreateIntegration();
|
||||||
|
|
||||||
expect(await screen.findByText('Published Topic Summary')).toBeInTheDocument();
|
expect(await screen.findByText('Published topic summary')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByLabelText(/Alice/));
|
fireEvent.click(await screen.findByLabelText(/Alice/));
|
||||||
fireEvent.click(await screen.findByLabelText(/Repeater One/));
|
fireEvent.click(await screen.findByLabelText(/Repeater One/));
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('MessageInput', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getInput() {
|
function getInput() {
|
||||||
return screen.getByPlaceholderText('Type a message...') as HTMLInputElement;
|
return screen.getByPlaceholderText('Type a message...') as HTMLTextAreaElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSendButton() {
|
function getSendButton() {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
|
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false, 1);
|
||||||
});
|
});
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const BOT_CHANNEL: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOT_PACKET: RawPacket = {
|
const BOT_PACKET: RawPacket = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const TEST_CHANNEL: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ describe('buildRawPacketStatsSnapshot', () => {
|
|||||||
sender: 'Alpha',
|
sender: 'Alpha',
|
||||||
channel_key: null,
|
channel_key: null,
|
||||||
contact_key: '0a'.repeat(32),
|
contact_key: '0a'.repeat(32),
|
||||||
|
sender_timestamp: null,
|
||||||
|
message: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
recv_errors: 5,
|
||||||
telemetry_history: [],
|
telemetry_history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -707,6 +708,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
recv_errors: null,
|
||||||
telemetry_history: [liveEntry],
|
telemetry_history: [liveEntry],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -742,6 +744,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
recv_errors: null,
|
||||||
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const defaultProps = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onNavigateToMessage: vi.fn(),
|
onNavigateToMessage: vi.fn(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SettingsModal } from '../components/SettingsModal';
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
Contact,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
@@ -71,6 +72,7 @@ const baseSettings: AppSettings = {
|
|||||||
tracked_telemetry_repeaters: [],
|
tracked_telemetry_repeaters: [],
|
||||||
auto_resend_channel: false,
|
auto_resend_channel: false,
|
||||||
telemetry_interval_hours: 8,
|
telemetry_interval_hours: 8,
|
||||||
|
telemetry_routed_hourly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides?: {
|
function renderModal(overrides?: {
|
||||||
@@ -89,6 +91,8 @@ function renderModal(overrides?: {
|
|||||||
meshDiscovery?: RadioDiscoveryResponse | null;
|
meshDiscovery?: RadioDiscoveryResponse | null;
|
||||||
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
||||||
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||||
|
contacts?: Contact[];
|
||||||
|
trackedTelemetryRepeaters?: string[];
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
pageMode?: boolean;
|
pageMode?: boolean;
|
||||||
externalSidebarNav?: boolean;
|
externalSidebarNav?: boolean;
|
||||||
@@ -127,6 +131,8 @@ function renderModal(overrides?: {
|
|||||||
onDiscoverMesh,
|
onDiscoverMesh,
|
||||||
onHealthRefresh: vi.fn(async () => {}),
|
onHealthRefresh: vi.fn(async () => {}),
|
||||||
onRefreshAppSettings,
|
onRefreshAppSettings,
|
||||||
|
contacts: overrides?.contacts,
|
||||||
|
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = overrides?.externalSidebarNav
|
const view = overrides?.externalSidebarNav
|
||||||
@@ -794,4 +800,68 @@ describe('SettingsModal', () => {
|
|||||||
expect(screen.getByText('Network')).toBeInTheDocument();
|
expect(screen.getByText('Network')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders routed hourly checkbox and calls save on toggle', async () => {
|
||||||
|
const onSaveAppSettings = vi.fn(async () => {});
|
||||||
|
|
||||||
|
renderModal({
|
||||||
|
externalSidebarNav: true,
|
||||||
|
desktopSection: 'database',
|
||||||
|
onSaveAppSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox', {
|
||||||
|
name: /Poll direct\/routed-path repeaters hourly/i,
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(checkbox).toBeInTheDocument();
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSaveAppSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ telemetry_routed_hourly: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows route badge per tracked repeater', async () => {
|
||||||
|
const directKey = 'bb'.repeat(32);
|
||||||
|
|
||||||
|
renderModal({
|
||||||
|
externalSidebarNav: true,
|
||||||
|
desktopSection: 'database',
|
||||||
|
appSettings: {
|
||||||
|
...baseSettings,
|
||||||
|
tracked_telemetry_repeaters: [directKey],
|
||||||
|
},
|
||||||
|
trackedTelemetryRepeaters: [directKey],
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
public_key: directKey,
|
||||||
|
name: 'DirectRepeater',
|
||||||
|
type: 2,
|
||||||
|
flags: 0,
|
||||||
|
direct_path: 'aabb',
|
||||||
|
direct_path_len: 1,
|
||||||
|
direct_path_hash_mode: 1,
|
||||||
|
last_advert: null,
|
||||||
|
lat: null,
|
||||||
|
lon: null,
|
||||||
|
last_seen: null,
|
||||||
|
on_radio: false,
|
||||||
|
favorite: false,
|
||||||
|
last_contacted: null,
|
||||||
|
last_read_at: null,
|
||||||
|
first_seen: null,
|
||||||
|
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
|
||||||
|
effective_route_source: 'direct',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('direct')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function makeChannel(key: string, name: string): Channel {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
getTextReplaceEnabled,
|
||||||
|
setTextReplaceEnabled,
|
||||||
|
getTextReplaceMapJson,
|
||||||
|
setTextReplaceMapJson,
|
||||||
|
applyTextReplacements,
|
||||||
|
DEFAULT_MAP_JSON,
|
||||||
|
} from '../utils/textReplace';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enabled toggle', () => {
|
||||||
|
it('defaults to disabled', () => {
|
||||||
|
expect(getTextReplaceEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists enabled state', () => {
|
||||||
|
setTextReplaceEnabled(true);
|
||||||
|
expect(getTextReplaceEnabled()).toBe(true);
|
||||||
|
setTextReplaceEnabled(false);
|
||||||
|
expect(getTextReplaceEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('map JSON persistence', () => {
|
||||||
|
it('returns default map when nothing stored', () => {
|
||||||
|
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists valid JSON and returns null', () => {
|
||||||
|
const json = '{"a":"b"}';
|
||||||
|
expect(setTextReplaceMapJson(json)).toBeNull();
|
||||||
|
expect(getTextReplaceMapJson()).toBe(json);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid JSON with error string', () => {
|
||||||
|
const err = setTextReplaceMapJson('not json');
|
||||||
|
expect(err).toBeTypeOf('string');
|
||||||
|
// localStorage unchanged — still returns default
|
||||||
|
expect(getTextReplaceMapJson()).toBe(DEFAULT_MAP_JSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects arrays', () => {
|
||||||
|
expect(setTextReplaceMapJson('["a","b"]')).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string values', () => {
|
||||||
|
expect(setTextReplaceMapJson('{"a":123}')).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null', () => {
|
||||||
|
expect(setTextReplaceMapJson('null')).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
expect(setTextReplaceMapJson('{}')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('re-expansion validation', () => {
|
||||||
|
it('rejects when a key appears in its own replacement', () => {
|
||||||
|
const err = setTextReplaceMapJson(JSON.stringify({ a: 'aa' }));
|
||||||
|
expect(err).toBeTypeOf('string');
|
||||||
|
expect(err).toContain('"a"');
|
||||||
|
expect(err).toContain('"aa"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when a key appears in another replacement', () => {
|
||||||
|
const err = setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'ab' }));
|
||||||
|
expect(err).toBeTypeOf('string');
|
||||||
|
expect(err).toContain('"a"');
|
||||||
|
expect(err).toContain('"ab"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows replacements that do not contain any key', () => {
|
||||||
|
expect(setTextReplaceMapJson(JSON.stringify({ a: 'X', b: 'Y' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the default Cyrillic map', () => {
|
||||||
|
expect(setTextReplaceMapJson(DEFAULT_MAP_JSON)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not check empty keys for re-expansion', () => {
|
||||||
|
// Empty key is silently skipped by buildReplacements, so it should not
|
||||||
|
// cause a re-expansion rejection for other entries.
|
||||||
|
expect(setTextReplaceMapJson(JSON.stringify({ '': 'x', b: 'Y' }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTextReplacements', () => {
|
||||||
|
const simpleMap = JSON.stringify({ a: 'X', b: 'Y' });
|
||||||
|
|
||||||
|
it('returns null when no replacements match', () => {
|
||||||
|
expect(applyTextReplacements('hello', 5, simpleMap)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty map', () => {
|
||||||
|
expect(applyTextReplacements('abc', 3, '{}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid JSON', () => {
|
||||||
|
expect(applyTextReplacements('abc', 3, 'broken')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces a single character with cursor at end', () => {
|
||||||
|
const result = applyTextReplacements('a', 1, simpleMap);
|
||||||
|
expect(result).toEqual({ text: 'X', cursor: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces multiple characters in one pass', () => {
|
||||||
|
const result = applyTextReplacements('ab', 2, simpleMap);
|
||||||
|
expect(result).toEqual({ text: 'XY', cursor: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts cursor when replacement is longer than needle', () => {
|
||||||
|
const map = JSON.stringify({ ':)': 'smiley' });
|
||||||
|
// "hello :)" cursor at end (8)
|
||||||
|
const result = applyTextReplacements('hello :)', 8, map);
|
||||||
|
expect(result).toEqual({ text: 'hello smiley', cursor: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts cursor when replacement is shorter than needle', () => {
|
||||||
|
const map = JSON.stringify({ abc: 'Z' });
|
||||||
|
// "abcdef" cursor at end (6)
|
||||||
|
const result = applyTextReplacements('abcdef', 6, map);
|
||||||
|
expect(result).toEqual({ text: 'Zdef', cursor: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves cursor position when replacement is before cursor', () => {
|
||||||
|
const map = JSON.stringify({ a: 'XX' });
|
||||||
|
// "a_b" cursor at 2 (on 'b'), 'a' replaced with 'XX'
|
||||||
|
const result = applyTextReplacements('a_b', 2, map);
|
||||||
|
expect(result).toEqual({ text: 'XX_b', cursor: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not adjust cursor for replacements after cursor', () => {
|
||||||
|
const map = JSON.stringify({ b: 'YY' });
|
||||||
|
// "ab" cursor at 1 (after 'a'), 'b' is after cursor
|
||||||
|
const result = applyTextReplacements('ab', 1, map);
|
||||||
|
expect(result).toEqual({ text: 'aYY', cursor: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places cursor after replacement when cursor is inside a multi-char match', () => {
|
||||||
|
const map = JSON.stringify({ abc: 'Z' });
|
||||||
|
// "abc" cursor at 2 (inside the match)
|
||||||
|
const result = applyTextReplacements('abc', 2, map);
|
||||||
|
expect(result).toEqual({ text: 'Z', cursor: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple replacements with cursor tracking', () => {
|
||||||
|
const map = JSON.stringify({ ':)': 'S' });
|
||||||
|
// ":):)" cursor at end (4) — two replacements, each shrinks by 1
|
||||||
|
const result = applyTextReplacements(':):)', 4, map);
|
||||||
|
expect(result).toEqual({ text: 'SS', cursor: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cursor between two replacements stays correct', () => {
|
||||||
|
const map = JSON.stringify({ ':)': 'S' });
|
||||||
|
// ":):)" cursor at 2 (between the two smileys)
|
||||||
|
const result = applyTextReplacements(':):)', 2, map);
|
||||||
|
expect(result).toEqual({ text: 'SS', cursor: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses longest match first', () => {
|
||||||
|
const map = JSON.stringify({ ab: 'LONG', a: 'X' });
|
||||||
|
const result = applyTextReplacements('ab', 2, map);
|
||||||
|
expect(result).toEqual({ text: 'LONG', cursor: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty-string keys (no infinite loop)', () => {
|
||||||
|
const map = JSON.stringify({ '': 'oops', a: 'X' });
|
||||||
|
const result = applyTextReplacements('abc', 3, map);
|
||||||
|
expect(result).toEqual({ text: 'Xbc', cursor: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with the default Cyrillic map', () => {
|
||||||
|
// "Привет" — П has no mapping, р→p, и has no mapping, в has no mapping, е→e, т has no mapping
|
||||||
|
const result = applyTextReplacements('Привет', 6, DEFAULT_MAP_JSON);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.text).toBe('Пpивeт');
|
||||||
|
expect(result!.cursor).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles paste with many replacements', () => {
|
||||||
|
const map = JSON.stringify({ А: 'A', В: 'B', С: 'C' });
|
||||||
|
const result = applyTextReplacements('АВС', 3, map);
|
||||||
|
expect(result).toEqual({ text: 'ABC', cursor: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,6 +194,7 @@ describe('resolveChannelFromHashToken', () => {
|
|||||||
on_radio: true,
|
on_radio: true,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '11111111111111111111111111111111',
|
key: '11111111111111111111111111111111',
|
||||||
@@ -202,6 +203,7 @@ describe('resolveChannelFromHashToken', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '22222222222222222222222222222222',
|
key: '22222222222222222222222222222222',
|
||||||
@@ -210,6 +212,7 @@ describe('resolveChannelFromHashToken', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ describe('useContactsAndChannels', () => {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
existing_count: 1,
|
existing_count: 1,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const publicChannel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentMessage: Message = {
|
const sentMessage: Message = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const publicChannel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ import type { Channel, Contact } from '../types';
|
|||||||
import { getStateKey } from '../utils/conversationState';
|
import { getStateKey } from '../utils/conversationState';
|
||||||
|
|
||||||
function makeChannel(key: string, favorite = false): Channel {
|
function makeChannel(key: string, favorite = false): Channel {
|
||||||
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
|
return {
|
||||||
|
key,
|
||||||
|
name: key,
|
||||||
|
is_hashtag: false,
|
||||||
|
on_radio: false,
|
||||||
|
last_read_at: null,
|
||||||
|
favorite,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeContact(publicKey: string, favorite = false): Contact {
|
function makeContact(publicKey: string, favorite = false): Contact {
|
||||||
|
|||||||
@@ -150,6 +150,35 @@ describe('usePushSubscription', () => {
|
|||||||
expect(result.current.allSubscriptions).toEqual([]);
|
expect(result.current.allSubscriptions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('times out and shows a toast when service worker never activates', async () => {
|
||||||
|
// Replace serviceWorker.ready with a promise that never resolves
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
ready: new Promise(() => {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePushSubscription());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isSupported).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// subscribe() will hang on serviceWorker.ready, then the 1s timeout fires
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.subscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(mocks.toast.error).toHaveBeenCalledWith(
|
||||||
|
'Failed to enable push notifications',
|
||||||
|
expect.objectContaining({
|
||||||
|
description: expect.stringContaining('trusted TLS certificate for service workers'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
||||||
const oldSubscription = activeSubscription;
|
const oldSubscription = activeSubscription;
|
||||||
mocks.api.getPushSubscriptions
|
mocks.api.getPushSubscriptions
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const publicChannel: Channel = {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const incomingDm: Message = {
|
const incomingDm: Message = {
|
||||||
@@ -65,6 +66,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
|||||||
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
||||||
setContacts,
|
setContacts,
|
||||||
blockedKeysRef: { current: [] as string[] },
|
blockedKeysRef: { current: [] as string[] },
|
||||||
|
channelsRef: { current: [publicChannel] },
|
||||||
blockedNamesRef: { current: [] as string[] },
|
blockedNamesRef: { current: [] as string[] },
|
||||||
activeConversationRef: { current: null as Conversation | null },
|
activeConversationRef: { current: null as Conversation | null },
|
||||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function makeChannel(key: string, name: string): Channel {
|
|||||||
on_radio: false,
|
on_radio: false,
|
||||||
last_read_at: null,
|
last_read_at: null,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface RadioStatsSnapshot {
|
|||||||
timestamp: number | null;
|
timestamp: number | null;
|
||||||
battery_mv: number | null;
|
battery_mv: number | null;
|
||||||
uptime_secs: number | null;
|
uptime_secs: number | null;
|
||||||
|
queue_len: number | null;
|
||||||
|
errors: number | null;
|
||||||
noise_floor: number | null;
|
noise_floor: number | null;
|
||||||
last_rssi: number | null;
|
last_rssi: number | null;
|
||||||
last_snr: number | null;
|
last_snr: number | null;
|
||||||
@@ -223,6 +225,7 @@ export interface Channel {
|
|||||||
path_hash_mode_override?: number | null;
|
path_hash_mode_override?: number | null;
|
||||||
last_read_at: number | null;
|
last_read_at: number | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
|
muted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelMessageCounts {
|
export interface ChannelMessageCounts {
|
||||||
@@ -340,6 +343,8 @@ export interface RawPacket {
|
|||||||
sender: string | null;
|
sender: string | null;
|
||||||
channel_key: string | null;
|
channel_key: string | null;
|
||||||
contact_key: string | null;
|
contact_key: string | null;
|
||||||
|
sender_timestamp: number | null;
|
||||||
|
message: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +361,7 @@ export interface AppSettings {
|
|||||||
tracked_telemetry_repeaters: string[];
|
tracked_telemetry_repeaters: string[];
|
||||||
auto_resend_channel: boolean;
|
auto_resend_channel: boolean;
|
||||||
telemetry_interval_hours: number;
|
telemetry_interval_hours: number;
|
||||||
|
telemetry_routed_hourly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
@@ -368,6 +374,7 @@ export interface AppSettingsUpdate {
|
|||||||
blocked_names?: string[];
|
blocked_names?: string[];
|
||||||
discovery_blocked_types?: number[];
|
discovery_blocked_types?: number[];
|
||||||
telemetry_interval_hours?: number;
|
telemetry_interval_hours?: number;
|
||||||
|
telemetry_routed_hourly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelemetrySchedule {
|
export interface TelemetrySchedule {
|
||||||
@@ -377,6 +384,8 @@ export interface TelemetrySchedule {
|
|||||||
tracked_count: number;
|
tracked_count: number;
|
||||||
max_tracked: number;
|
max_tracked: number;
|
||||||
next_run_at: number | null;
|
next_run_at: number | null;
|
||||||
|
routed_hourly: boolean;
|
||||||
|
next_routed_run_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackedTelemetryResponse {
|
export interface TrackedTelemetryResponse {
|
||||||
@@ -435,6 +444,7 @@ export interface RepeaterStatusResponse {
|
|||||||
flood_dups: number;
|
flood_dups: number;
|
||||||
direct_dups: number;
|
direct_dups: number;
|
||||||
full_events: number;
|
full_events: number;
|
||||||
|
recv_errors: number | null;
|
||||||
telemetry_history: TelemetryHistoryEntry[];
|
telemetry_history: TelemetryHistoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,51 +324,56 @@ export function inspectRawPacketWithOptions(
|
|||||||
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
|
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
|
||||||
);
|
);
|
||||||
|
|
||||||
const enrichedPayloadFields =
|
const enrichedPayloadFields = payloadFields.map((field) => {
|
||||||
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
|
if (!decoded?.isValid || field.name !== 'Ciphertext') {
|
||||||
? payloadFields.map((field) => {
|
return field;
|
||||||
if (field.name !== 'Ciphertext') {
|
}
|
||||||
return field;
|
|
||||||
}
|
const withStructure = {
|
||||||
const payload = decoded.payload.decoded as {
|
...field,
|
||||||
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
|
description: describeCiphertextStructure(
|
||||||
};
|
decoded.payloadType,
|
||||||
if (!payload.decrypted?.message) {
|
field.endByte - field.startByte + 1,
|
||||||
return field;
|
field.description
|
||||||
}
|
),
|
||||||
const detailLines = [
|
};
|
||||||
payload.decrypted.timestamp != null
|
|
||||||
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
|
// GroupText: client-side decoder has the decrypted content
|
||||||
: null,
|
if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
|
||||||
payload.decrypted.flags != null
|
const payload = decoded.payload.decoded as {
|
||||||
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
|
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
|
||||||
: null,
|
};
|
||||||
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
|
if (!payload.decrypted?.message) {
|
||||||
`Message: ${payload.decrypted.message}`,
|
return withStructure;
|
||||||
].filter((line): line is string => line !== null);
|
}
|
||||||
return {
|
const detailLines = [
|
||||||
...field,
|
payload.decrypted.timestamp != null
|
||||||
description: describeCiphertextStructure(
|
? `Sent (packet): ${formatUnixTimestamp(payload.decrypted.timestamp)}`
|
||||||
decoded.payloadType,
|
: null,
|
||||||
field.endByte - field.startByte + 1,
|
payload.decrypted.flags != null
|
||||||
field.description
|
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
|
||||||
),
|
: null,
|
||||||
decryptedMessage: detailLines.join('\n'),
|
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
|
||||||
};
|
`Message: ${payload.decrypted.message}`,
|
||||||
})
|
].filter((line): line is string => line !== null);
|
||||||
: payloadFields.map((field) => {
|
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
|
||||||
if (!decoded?.isValid || field.name !== 'Ciphertext') {
|
}
|
||||||
return field;
|
|
||||||
}
|
// TextMessage (DM): server-side decryption via decrypted_info
|
||||||
return {
|
if (decoded.payloadType === PayloadType.TextMessage && packet.decrypted_info?.message) {
|
||||||
...field,
|
const info = packet.decrypted_info;
|
||||||
description: describeCiphertextStructure(
|
const detailLines = [
|
||||||
decoded.payloadType,
|
info.sender_timestamp != null
|
||||||
field.endByte - field.startByte + 1,
|
? `Sent (packet): ${formatUnixTimestamp(info.sender_timestamp)}`
|
||||||
field.description
|
: null,
|
||||||
),
|
info.sender ? `Sender: ${info.sender}` : null,
|
||||||
};
|
`Message: ${info.message}`,
|
||||||
});
|
].filter((line): line is string => line !== null);
|
||||||
|
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
return withStructure;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
decoded,
|
decoded,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const ENABLED_KEY = 'remoteterm-text-replace-enabled';
|
||||||
|
const MAP_KEY = 'remoteterm-text-replace-map';
|
||||||
|
|
||||||
|
const DEFAULT_MAP: Record<string, string> = {
|
||||||
|
А: 'A',
|
||||||
|
В: 'B',
|
||||||
|
Е: 'E',
|
||||||
|
Ё: 'E',
|
||||||
|
З: '3',
|
||||||
|
К: 'K',
|
||||||
|
М: 'M',
|
||||||
|
Н: 'H',
|
||||||
|
О: 'O',
|
||||||
|
Р: 'P',
|
||||||
|
С: 'C',
|
||||||
|
Т: 'T',
|
||||||
|
Х: 'X',
|
||||||
|
Ь: 'b',
|
||||||
|
а: 'a',
|
||||||
|
е: 'e',
|
||||||
|
ё: 'e',
|
||||||
|
о: 'o',
|
||||||
|
р: 'p',
|
||||||
|
с: 'c',
|
||||||
|
у: 'y',
|
||||||
|
х: 'x',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_JSON = JSON.stringify(DEFAULT_MAP, null, 2);
|
||||||
|
|
||||||
|
export function getTextReplaceEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ENABLED_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTextReplaceEnabled(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
localStorage.setItem(ENABLED_KEY, 'true');
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(ENABLED_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextReplaceMapJson(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(MAP_KEY);
|
||||||
|
if (raw !== null) return raw;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return DEFAULT_MAP_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the map JSON only if it's valid. Returns null on success or an error string. */
|
||||||
|
export function setTextReplaceMapJson(json: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
||||||
|
return 'Must be a JSON object.';
|
||||||
|
const rawEntries = Object.entries(parsed);
|
||||||
|
for (const [k, v] of rawEntries) {
|
||||||
|
if (typeof k !== 'string' || typeof v !== 'string')
|
||||||
|
return 'All keys and values must be strings.';
|
||||||
|
}
|
||||||
|
const entries = rawEntries as [string, string][];
|
||||||
|
// Check for re-expansion: no key may appear as a substring of any replacement value.
|
||||||
|
for (const [needle] of entries) {
|
||||||
|
if (needle.length === 0) continue;
|
||||||
|
for (const [, replacement] of entries) {
|
||||||
|
if (replacement.includes(needle)) {
|
||||||
|
return `Key "${needle}" appears inside replacement "${replacement}" and would re-expand on every keystroke.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem(MAP_KEY, json);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return 'Invalid JSON.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a sorted-by-length-desc array of [needle, replacement] for efficient matching. */
|
||||||
|
function buildReplacements(json: string): [string, string][] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as Record<string, string>;
|
||||||
|
return Object.entries(parsed)
|
||||||
|
.filter(([k]) => k.length > 0)
|
||||||
|
.sort((a, b) => b[0].length - a[0].length);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply text replacements and compute the adjusted cursor position.
|
||||||
|
* Returns null if nothing changed.
|
||||||
|
*/
|
||||||
|
export function applyTextReplacements(
|
||||||
|
text: string,
|
||||||
|
cursorPos: number,
|
||||||
|
mapJson: string
|
||||||
|
): { text: string; cursor: number } | null {
|
||||||
|
const replacements = buildReplacements(mapJson);
|
||||||
|
if (replacements.length === 0) return null;
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
let newCursor = cursorPos;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
let matched = false;
|
||||||
|
for (const [needle, replacement] of replacements) {
|
||||||
|
if (text.startsWith(needle, i)) {
|
||||||
|
result += replacement;
|
||||||
|
// Adjust cursor if this match is before or spans the cursor
|
||||||
|
if (i + needle.length <= cursorPos) {
|
||||||
|
newCursor += replacement.length - needle.length;
|
||||||
|
} else if (i < cursorPos) {
|
||||||
|
// Cursor is inside this match — place it after the replacement
|
||||||
|
newCursor = result.length;
|
||||||
|
}
|
||||||
|
i += needle.length;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
result += text[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === text) return null;
|
||||||
|
return { text: result, cursor: newCursor };
|
||||||
|
}
|
||||||
@@ -29,3 +29,7 @@ MESHCORE_DISABLE_BOTS=true
|
|||||||
# HTTP Basic Auth (recommended when bots are enabled)
|
# HTTP Basic Auth (recommended when bots are enabled)
|
||||||
#MESHCORE_BASIC_AUTH_USERNAME=
|
#MESHCORE_BASIC_AUTH_USERNAME=
|
||||||
#MESHCORE_BASIC_AUTH_PASSWORD=
|
#MESHCORE_BASIC_AUTH_PASSWORD=
|
||||||
|
|
||||||
|
# Enable GET /api/radio/private-key to return the in-memory private key as hex
|
||||||
|
# for backup or migration. Only enable on a trusted network.
|
||||||
|
#MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=false
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.12.0"
|
version = "3.13.0"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"pycryptodome>=3.20.0",
|
"pycryptodome>=3.20.0",
|
||||||
"pynacl>=1.5.0",
|
"pynacl>=1.5.0",
|
||||||
"meshcore==2.3.2",
|
"meshcore==2.3.7",
|
||||||
"aiomqtt>=2.0",
|
"aiomqtt>=2.0",
|
||||||
"apprise>=1.9.8",
|
"apprise>=1.9.8",
|
||||||
"boto3>=1.38.0",
|
"boto3>=1.38.0",
|
||||||
@@ -61,7 +61,7 @@ reportMissingTypeStubs = false
|
|||||||
dev = [
|
dev = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"pip-licenses>=5.0.0",
|
"pip-licenses>=5.0.0",
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-xdist>=3.0",
|
"pytest-xdist>=3.0",
|
||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user