Compare commits

..

10 Commits

Author SHA1 Message Date
Jack Kingsman d6e1218888 Updating changelog + build for 3.12.0 2026-04-17 12:21:35 -07:00
Jack Kingsman ad0e398704 Docs improvements 2026-04-17 10:24:45 -07:00
Jack Kingsman 39f5bb2b51 Don't stop on missing wire ack for dm send 2026-04-17 10:04:26 -07:00
Jack Kingsman 5257cb0b1b Go ham on radio clearing in manual mode 2026-04-17 09:38:05 -07:00
Jack Kingsman b1547773c5 Phrasing corrections 2026-04-17 08:56:16 -07:00
Jack Kingsman 71da6841c1 Documentation improvements 2026-04-17 00:38:50 -07:00
Jack Kingsman 6f00e857c2 Suck less at settings UI (help me I'm not a designer) 2026-04-16 23:49:52 -07:00
Jack Kingsman 303becf4b8 Merge pull request #183 from jkingsman/web-push
Add web push
2026-04-16 23:12:39 -07:00
Jack Kingsman b1020e6e34 Add some QOL improvements to HA integration 2026-04-16 23:03:56 -07:00
Jack Kingsman 87a892fc6e Don't chirp about the time set failures all the time 2026-04-16 21:58:53 -07:00
34 changed files with 1590 additions and 645 deletions
+8 -2
View File
@@ -179,7 +179,9 @@ Outgoing DMs send once immediately, then may retry up to 2 more times in the bac
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint `idx_messages_dedup_null_safe` on `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
**Incoming direct messages**: A separate unique index `idx_messages_incoming_priv_dedup` on `(type, conversation_key, text, COALESCE(sender_timestamp, 0), COALESCE(sender_key, ''))` where `type = 'PRIV' AND outgoing = 0` deduplicates incoming DMs. The additional `sender_key` term (added in migration 056) distinguishes room-server posts from different senders that arrive in the same second with identical text.
This message-layer echo/path handling is independent of raw-packet storage deduplication.
@@ -346,6 +348,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
@@ -375,6 +378,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| 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/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
@@ -387,6 +391,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| PATCH | `/api/push/subscriptions/{id}` | Update subscription label or filter preferences |
| DELETE | `/api/push/subscriptions/{id}` | Delete a push subscription |
| POST | `/api/push/subscriptions/{id}/test` | Send a test push notification |
| GET | `/api/push/conversations` | Global list of push-enabled conversation state keys |
| POST | `/api/push/conversations/toggle` | Add or remove a conversation from the global push list |
| WS | `/api/ws` | Real-time updates |
## Key Concepts
@@ -498,7 +504,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, and `auto_resend_channel`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
**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`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+22
View File
@@ -1,3 +1,25 @@
## [3.12.0] - 2026-04-17
* Feature: Web Push -- get your mesh notifications on a locked phone or when your browser is closed!
* Feature: Add link to node from map display
* Feature: Map layers
* Feature: Better contact/channel selection for fanout
* Feature: Add glittering status dot option
* Feature: Add airtime math and average packets/min for repeater info displays
* Feature: Offer multiple timing intervals for repeater telemetry aurofetch
* Feature: Add ability to follow OS light/dark mode
* Bugfix: Clear 100% of messages from radio in fallback mode; don't stop at 100
* Bugfix: Don't stop DM retry just because the radio did not provide a radio ack on the wire
* Bugfix: Don't strip outgoing colons on DMs or room servers
* Bugfix: Patch statusbar overlap on PWA
* Bugfix: Patch default map upload URL
* Bugfix: Show learned path in routing override
* Bugfix: Centralize on "only means RF heard" for first_seen/last_seen
* Misc: Reduce frequency of time set failure chirping
* Misc: QoL improvements for Home Assistant integration
* Misc: Overhaul settings styling
* Misc: Documentation + tests updates
## [3.11.3] - 2026-04-12
* Bugfix: Add icons and screenshots for webmanifest
+2 -2
View File
@@ -13,7 +13,7 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
FROM python:3.13-slim
ARG COMMIT_HASH=unknown
@@ -22,7 +22,7 @@ WORKDIR /app
ENV COMMIT_HASH=${COMMIT_HASH}
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /usr/local/bin/uv
# Copy dependency files first for layer caching
COPY pyproject.toml uv.lock ./
+383
View File
@@ -647,6 +647,389 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
</details>
### pywebpush (2.3.0) — MPL-2.0
<details>
<summary>Full license text</summary>
```
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
```
</details>
### uvicorn (0.40.0) — BSD-3-Clause
<details>
+95 -29
View File
@@ -19,6 +19,28 @@ RemoteTerm can publish mesh network data to Home Assistant via MQTT Discovery. D
Devices will appear in HA under **Settings > Devices & Services > MQTT** within a few seconds.
## How MeshCore IDs Map Into Home Assistant
RemoteTerm uses each node's public key to derive a stable short identifier:
- Full public key: `ae92577bae6c4f1d...`
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
- Example runtime topic: `meshcore/ae92577bae6c/gps`
When this README shows `<node_id>`, it always means that 12-character value.
The same node ID appears in:
- Home Assistant entity IDs
- Home Assistant discovery topics under `homeassistant/...`
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
You can also see these IDs in RemoteTerm's Home Assistant integration UI:
- `What gets created in Home Assistant`
- `Published Topic Summary`
## What Gets Created
### Local Radio Device
@@ -27,24 +49,26 @@ Always created. Updates every 60 seconds.
| Entity | Type | Description |
|--------|------|-------------|
| `binary_sensor.meshcore_*_connected` | Connectivity | Radio online/offline |
| `sensor.meshcore_*_noise_floor` | Signal strength | Radio noise floor (dBm) |
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
| `sensor.meshcore_<radio_node_id>_noise_floor` | Signal strength | Radio noise floor (dBm) |
### Repeater Devices
One device per tracked repeater (must have repeater opted). Updates when telemetry is collected (auto-collect cycle (~8 hours), or when you manually fetch from the repeater dashboard).
One device per tracked repeater selected in the HA integration. Updates when telemetry is collected (auto-collect cycle (~8 hours or variable in settings), or when you manually fetch from the repeater dashboard).
Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm's Radio settings section. Only auto-tracked repeaters appear in the HA integration's repeater picker.
| Entity | Type | Unit | Description |
|--------|------|------|-------------|
| `sensor.meshcore_*_battery_voltage` | Voltage | V | Battery level |
| `sensor.meshcore_*_noise_floor` | Signal strength | dBm | Local noise floor |
| `sensor.meshcore_*_last_rssi` | Signal strength | dBm | Last received signal strength |
| `sensor.meshcore_*_last_snr` | -- | dB | Last signal-to-noise ratio |
| `sensor.meshcore_*_packets_received` | -- | count | Total packets received |
| `sensor.meshcore_*_packets_sent` | -- | count | Total packets sent |
| `sensor.meshcore_*_uptime` | Duration | s | Uptime since last reboot |
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
| `sensor.meshcore_<repeater_node_id>_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.
### Contact Device Trackers
@@ -52,11 +76,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
| Entity | Description |
|--------|-------------|
| `device_tracker.meshcore_*` | GPS position (latitude/longitude) |
| `device_tracker.meshcore_<contact_node_id>` | GPS position (latitude/longitude) |
### Message Event Entity
A single `event.meshcore_messages` entity that fires for each message matching your configured scope. Each event carries these attributes:
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:
| Attribute | Example | Description |
|-----------|---------|-------------|
@@ -73,13 +97,27 @@ A single `event.meshcore_messages` entity that fires for each message matching y
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.
That same 12-character node ID is also used in the MQTT topic paths. For example:
- Local radio health: `meshcore/<radio_node_id>/health`
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
- Contact GPS: `meshcore/<contact_node_id>/gps`
- Message events: `meshcore/<radio_node_id>/events/message`
## What Appears When
- Always created: the local radio device and its entities
- Created when selected in the HA integration: tracked repeater devices and tracked contact device trackers
- Populated only after data exists: contact GPS trackers need an advert with GPS; repeater sensors need telemetry, although cached repeater telemetry is replayed on startup when available
- Message event entity: always created once the HA integration is enabled for a connected radio
## Common Automations
### Low repeater battery alert
Notify when a tracked repeater's battery drops below a threshold.
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_*_battery_voltage`, below `3.8`, action: notification.
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_<repeater_node_id>_battery_voltage`, below `3.8`, action: notification.
**YAML:**
```yaml
@@ -102,7 +140,7 @@ automation:
Notify if the radio has been disconnected for more than 5 minutes.
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_*_connected`, to `off`, for `00:05:00`, action: notification.
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_<radio_node_id>_connected`, to `off`, for `00:05:00`, action: notification.
**YAML:**
```yaml
@@ -128,7 +166,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.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_messages`, action: notification.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages`, action: notification.
**YAML:**
```yaml
@@ -136,7 +174,7 @@ automation:
- alias: "Emergency channel alert"
trigger:
- platform: state
entity_id: event.meshcore_messages
entity_id: event.meshcore_aabbccddeeff_messages
action:
- service: notify.mobile_app_your_phone
data:
@@ -150,7 +188,7 @@ automation:
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_messages` > Add condition > Template > enter the template below.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages` > Add condition > Template > enter the template below.
**YAML:**
```yaml
@@ -158,7 +196,7 @@ automation:
- alias: "Emergency channel alert"
trigger:
- platform: state
entity_id: event.meshcore_messages
entity_id: event.meshcore_aabbccddeeff_messages
condition:
- condition: template
value_template: >-
@@ -180,7 +218,7 @@ automation:
- alias: "DM from Alice"
trigger:
- platform: state
entity_id: event.meshcore_messages
entity_id: event.meshcore_aabbccddeeff_messages
condition:
- condition: template
value_template: >-
@@ -201,7 +239,7 @@ automation:
- alias: "Keyword alert"
trigger:
- platform: state
entity_id: event.meshcore_messages
entity_id: event.meshcore_aabbccddeeff_messages
condition:
- condition: template
value_template: >-
@@ -266,7 +304,9 @@ mosquitto_pub -h <broker> -t 'homeassistant/sensor/meshcore_unknown/noise_floor/
### Repeater sensors show "Unknown" or "Unavailable"
Repeater telemetry only updates when collected. Trigger a manual fetch by opening the repeater's dashboard in RemoteTerm and clicking "Status", or wait for the next auto-collect cycle (~8 hours). Sensors show "Unknown" until the first telemetry reading arrives.
Repeater telemetry only updates when collected. Trigger a manual fetch by opening the repeater's dashboard in RemoteTerm and clicking "Status", or wait for the next auto-collect cycle (~8 hours).
If RemoteTerm already has cached telemetry for that repeater, it republishes the last known values on startup. If the sensors are still unknown or unavailable, it usually means no telemetry has ever been collected for that repeater yet.
### Contact device tracker shows "Unknown"
@@ -280,26 +320,52 @@ Radio health entities have a 120-second expiry. If RemoteTerm stops sending heal
Disabling or deleting the HA integration in RemoteTerm's settings publishes empty retained messages to all discovery topics, which removes the devices and entities from HA automatically.
## Local Test Environment
For local development, RemoteTerm includes a helper that starts Mosquitto and Home Assistant with MQTT preconfigured:
```bash
./scripts/setup/start_ha_test_env.sh
```
That gives you:
- Home Assistant at `http://localhost:8123`
- Mosquitto at `localhost:1883`
- A pre-created HA MQTT integration using that broker
To watch all MQTT traffic during testing:
```bash
docker exec ha-test-mosquitto mosquitto_sub -h 127.0.0.1 -t '#' -v
```
To stop and clean up:
```bash
./scripts/setup/stop_ha_test_env.sh --clean
```
## MQTT Topics Reference
State topics (where data is published):
Runtime/state topics (where data is published):
| Topic | Content | Update frequency |
|-------|---------|-----------------|
| `meshcore/{node_id}/health` | `{"connected": bool, "noise_floor_dbm": int}` | Every 60s |
| `meshcore/{node_id}/telemetry` | `{"battery_volts": float, ...}` | ~8h or manual |
| `meshcore/{node_id}/gps` | `{"latitude": float, "longitude": float, ...}` | On advert |
| `meshcore/events/message` | `{"event_type": "message_received", ...}` | On message |
| `meshcore/{node_id}/events/message` | `{"event_type": "message_received", ...}` | On message |
Discovery topics (entity registration, under `homeassistant/`):
| Pattern | Entity type |
|---------|------------|
| `homeassistant/binary_sensor/meshcore_*/connected/config` | Radio connectivity |
| `homeassistant/sensor/meshcore_*/noise_floor/config` | Noise floor sensor |
| `homeassistant/sensor/meshcore_*/battery_voltage/config` | Repeater battery |
| `homeassistant/sensor/meshcore_*/*/config` | Other repeater sensors |
| `homeassistant/device_tracker/meshcore_*/config` | Contact GPS tracker |
| `homeassistant/event/meshcore_messages/config` | Message event entity |
| `homeassistant/binary_sensor/meshcore_<node_id>/connected/config` | Radio connectivity |
| `homeassistant/sensor/meshcore_<node_id>/noise_floor/config` | Noise floor sensor |
| `homeassistant/sensor/meshcore_<node_id>/battery_voltage/config` | Repeater battery |
| `homeassistant/sensor/meshcore_<node_id>/*/config` | Other repeater sensors |
| `homeassistant/device_tracker/meshcore_<node_id>/config` | Contact GPS tracker |
| `homeassistant/event/meshcore_<node_id>/messages/config` | Message event entity |
The `{node_id}` is always the first 12 characters of the node's public key, lowercased.
+12 -6
View File
@@ -27,10 +27,10 @@ app/
├── config.py # Env-driven runtime settings
├── channel_constants.py # Public/default channel constants shared across sync/send logic
├── database.py # SQLite connection + base schema + migration runner
├── migrations.py # Schema migrations (SQLite user_version)
├── migrations/ # Schema migrations (SQLite user_version, per-version modules)
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout, push_subscriptions, repeater_telemetry)
├── services/ # Shared orchestration/domain services
│ ├── messages.py # Shared message creation, dedup, ACK application
│ ├── message_send.py # Direct send, channel send, resend workflows
@@ -55,7 +55,7 @@ app/
│ ├── send.py # pywebpush wrapper (async via thread executor)
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler
├── path_utils.py # Path hex rendering and hop-width helpers
├── region_scope.py # Normalize/validate regional flood-scope values
├── keystore.py # Ephemeral private/public key storage for DM decryption
@@ -70,7 +70,7 @@ app/
├── packets.py
├── read_state.py
├── rooms.py
├── server_control.py
├── server_control.py # Shared helpers for repeater/room CLI flows (not an APIRouter)
├── settings.py
├── fanout.py
├── repeaters.py
@@ -140,8 +140,9 @@ app/
### Echo/repeat dedup
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same conversation/text/sender timestamp also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
- Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`.
- Incoming PRIV message uniqueness (`idx_messages_incoming_priv_dedup`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0), COALESCE(sender_key, ''))` where `type = 'PRIV' AND outgoing = 0``sender_key` was added in migration 056 to distinguish room-server posts from different senders in the same second.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same dedup identity also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
### Raw packet dedup policy
@@ -224,6 +225,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /contacts/{public_key}/repeater/radio-settings`
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info`
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
- `POST /contacts/{public_key}/room/login`
- `POST /contacts/{public_key}/room/status`
- `POST /contacts/{public_key}/room/lpp-telemetry`
@@ -263,6 +265,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /settings/blocked-keys/toggle`
- `POST /settings/blocked-names/toggle`
- `POST /settings/tracked-telemetry/toggle`
- `GET /settings/tracked-telemetry/schedule` — current telemetry scheduling derivation, interval options, and next-run-at timestamp
### Fanout
- `GET /fanout` — list all fanout configs
@@ -281,6 +284,8 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `PATCH /push/subscriptions/{id}` — update label or filter preferences
- `DELETE /push/subscriptions/{id}` — delete subscription
- `POST /push/subscriptions/{id}/test` — send test notification
- `GET /push/conversations` — global list of push-enabled conversation state keys
- `POST /push/conversations/toggle` — add or remove a conversation from the global push list
### WebSocket
- `WS /ws`
@@ -338,6 +343,7 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `auto_resend_channel`
- `telemetry_interval_hours`
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
+7 -3
View File
@@ -237,9 +237,13 @@ async def on_new_contact(event: "Event") -> None:
logger.debug("New contact: %s", public_key[:12])
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
# Intentionally do not set last_seen here: NEW_CONTACT fires from the
# radio's stored contact DB, not an RF observation. last_seen means
# "last time we heard this pubkey on RF".
# Intentionally do not set first_seen or last_seen here: NEW_CONTACT
# fires from the radio's stored contact DB, not an RF observation.
# Both first_seen and last_seen are RF-only timestamps — they track
# the first and most recent time we actually heard this pubkey over
# the air (adverts, messages, path updates). Contacts synced from the
# radio's internal DB without any RF activity stay NULL until a real
# RF observation fills them in.
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
+41 -18
View File
@@ -115,6 +115,21 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
return f"lpp_{type_name}_ch{channel}"
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Build the flat HA state payload for a repeater telemetry snapshot."""
payload: dict[str, Any] = {}
for sensor in _REPEATER_SENSORS:
field = sensor["field"]
if field is not None:
payload[field] = data.get(field)
for sensor in data.get("lpp_sensors", []) or []:
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
payload[key] = sensor.get("value")
return payload
def _lpp_discovery_configs(
prefix: str,
pub_key: str,
@@ -497,13 +512,14 @@ class MqttHaModule(FanoutModule):
# ── Discovery publishing ──────────────────────────────────────────
async def _publish_discovery(self) -> None:
"""Publish all HA discovery configs with retain=True."""
"""Publish HA discovery configs and one-shot cached repeater state."""
if not self._radio_key:
# Don't publish discovery until we know the radio identity —
# the first health heartbeat will provide it and trigger this.
return
configs: list[tuple[str, dict]] = []
cached_repeater_states: list[tuple[str, dict[str, Any]]] = []
radio_name = self._radio_name or "MeshCore Radio"
configs.extend(_radio_discovery_configs(self._prefix, self._radio_key, radio_name))
@@ -514,8 +530,10 @@ class MqttHaModule(FanoutModule):
configs.extend(
_repeater_discovery_configs(self._prefix, pub_key, rname, self._radio_key)
)
latest = await self._resolve_latest_telemetry(pub_key)
latest_data = latest.get("data", {}) if latest else {}
# Dynamic LPP sensor entities from last known telemetry snapshot
lpp_sensors = await self._resolve_lpp_sensors(pub_key)
lpp_sensors = latest_data.get("lpp_sensors", [])
if lpp_sensors:
nid = _node_id(pub_key)
device = _device_payload(pub_key, rname, "Repeater", via_device_key=self._radio_key)
@@ -523,6 +541,13 @@ class MqttHaModule(FanoutModule):
configs.extend(
_lpp_discovery_configs(self._prefix, pub_key, device, lpp_sensors, state_topic)
)
if latest_data:
cached_repeater_states.append(
(
f"{self._prefix}/{_node_id(pub_key)}/telemetry",
_repeater_telemetry_payload(latest_data),
)
)
# Tracked contacts — resolve names from DB best-effort
for pub_key in self._tracked_contacts:
@@ -539,11 +564,18 @@ class MqttHaModule(FanoutModule):
for topic, payload in configs:
await self._publisher.publish(topic, payload, retain=True)
for topic, payload in cached_repeater_states:
# Replay cached state after discovery so newly created HA entities
# populate immediately, but do not retain it or HA will treat a
# broker reconnect as fresh telemetry and reset expire_after.
await self._publisher.publish(topic, payload)
logger.info(
"HA MQTT: published %d discovery configs (%d repeaters, %d contacts)",
"HA MQTT: published %d discovery configs (%d repeaters, %d contacts, %d cached telemetry states)",
len(configs),
len(self._tracked_repeaters),
len(self._tracked_contacts),
len(cached_repeater_states),
)
async def _clear_retained_topics(self, topics: list[str]) -> None:
@@ -575,17 +607,15 @@ class MqttHaModule(FanoutModule):
return pub_key[:12]
@staticmethod
async def _resolve_lpp_sensors(pub_key: str) -> list[dict]:
"""Return the LPP sensor list from the most recent telemetry snapshot, or []."""
async def _resolve_latest_telemetry(pub_key: str) -> dict | None:
"""Return the most recent telemetry row for a repeater, or None."""
try:
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
latest = await RepeaterTelemetryRepository.get_latest(pub_key)
if latest:
return latest.get("data", {}).get("lpp_sensors", [])
return await RepeaterTelemetryRepository.get_latest(pub_key)
except Exception:
pass
return []
return None
def _seed_radio_identity_from_runtime(self) -> None:
"""Best-effort bootstrap from the currently connected radio session."""
@@ -698,19 +728,12 @@ class MqttHaModule(FanoutModule):
nid = _node_id(pub_key)
# Publish the full telemetry dict — HA sensors use value_template
# to extract individual fields
payload: dict[str, Any] = {}
for s in _REPEATER_SENSORS:
field = s["field"]
if field is not None:
payload[field] = data.get(field)
# Flatten LPP sensors into the same payload so HA value_templates work
payload = _repeater_telemetry_payload(data)
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
rediscover = False
for sensor in lpp_sensors:
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
payload[key] = sensor.get("value")
# 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"
if expected_topic not in self._discovery_topics:
rediscover = True
+14 -9
View File
@@ -461,9 +461,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
Returns the count of messages retrieved.
"""
count = 0
max_iterations = 100 # Safety limit
for _ in range(max_iterations):
while True:
try:
result = await mc.commands.get_msg(timeout=2.0)
@@ -855,7 +854,7 @@ async def _attempt_clock_wraparound(mc: MeshCore, *, now: int, observed_radio_ti
return False
async def sync_radio_time(mc: MeshCore) -> bool:
async def sync_radio_time(mc: MeshCore, *, warn_on_failure: bool = True) -> bool:
"""Sync the radio's clock with the system time.
The firmware only accepts forward time adjustments (new >= current).
@@ -870,9 +869,15 @@ async def sync_radio_time(mc: MeshCore) -> bool:
only once; if it doesn't help (hardware RTC persists the wrong time),
the skew is logged as a warning on subsequent syncs.
``warn_on_failure`` controls log severity for rejected/failed sync attempts.
Startup and reconnect setup should leave this enabled so operators see the
initial skew problem. Periodic maintenance syncs pass ``False`` to avoid
repeating the same warning every few minutes after startup.
Returns True if the radio accepted the new time, False otherwise.
"""
global _clock_reboot_attempted # noqa: PLW0603
log_failure = logger.warning if warn_on_failure else logger.debug
try:
now = int(time.time())
preflight_radio_time: int | None = None
@@ -901,7 +906,7 @@ async def sync_radio_time(mc: MeshCore) -> bool:
if radio_time is not None:
delta = radio_time - now
logger.warning(
log_failure(
"Radio rejected time sync: radio clock is %+d seconds "
"(%+.1f hours) from system time (radio=%d, system=%d).",
delta,
@@ -911,7 +916,7 @@ async def sync_radio_time(mc: MeshCore) -> bool:
)
else:
delta = None
logger.warning(
log_failure(
"Radio rejected time sync (set_time returned %s) "
"and get_time query failed; cannot determine clock skew.",
result.type,
@@ -936,14 +941,14 @@ async def sync_radio_time(mc: MeshCore) -> bool:
# reboot, allowing the next post-connect sync to succeed.
if not _clock_reboot_attempted and (delta is None or delta > 30):
_clock_reboot_attempted = True
logger.warning(
log_failure(
"Rebooting radio to reset clock skew. Boards with a "
"volatile RTC will accept the correct time after restart."
)
try:
await mc.commands.reboot()
except Exception:
logger.warning("Reboot command failed", exc_info=True)
log_failure("Reboot command failed", exc_info=True)
elif _clock_reboot_attempted:
logger.debug(
"Clock skew persists after reboot (hardware RTC); ignoring until next session."
@@ -951,7 +956,7 @@ async def sync_radio_time(mc: MeshCore) -> bool:
return False
except Exception as e:
logger.warning("Failed to sync radio time: %s", e, exc_info=True)
log_failure("Failed to sync radio time: %s", e, exc_info=True)
return False
@@ -971,7 +976,7 @@ async def _periodic_sync_loop():
) as mc:
if await should_run_full_periodic_sync(mc):
await sync_and_offload_all(mc)
await sync_radio_time(mc)
await sync_radio_time(mc, warn_on_failure=False)
except RadioOperationBusyError:
logger.debug("Skipping periodic sync: radio busy")
except asyncio.CancelledError:
+4 -3
View File
@@ -513,14 +513,15 @@ async def _retry_direct_message_until_acked(
ack_code = _extract_expected_ack_code(result)
if not ack_code:
logger.warning(
logger.debug(
"Background DM retry attempt %d/%d for %s returned no expected_ack; "
"stopping retries to avoid duplicate sends",
"continuing with previous timeout",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
return
attempt += 1
continue
next_wait_timeout_ms = _get_direct_message_retry_timeout_ms(result)
+16 -5
View File
@@ -40,7 +40,8 @@ frontend/src/
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── contexts/
── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
│ └── PushSubscriptionContext.tsx # Push subscription state context/provider
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
@@ -92,7 +93,13 @@ frontend/src/
│ ├── radioPresets.ts # LoRa radio preset configurations
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
── theme.ts # Theme switching helpers
── theme.ts # Theme switching helpers
│ ├── autoFocusInput.ts # Auto-focus input helper
│ ├── batteryDisplay.ts # Battery level display helpers
│ ├── messageIdentity.ts # Message identity/dedup helpers
│ ├── rawPacketInspector.ts # Raw packet inspection helpers
│ ├── serverLoginState.ts # Server login state helpers
│ └── statusDotPulse.ts # Status dot pulse animation helpers
├── components/
│ ├── StatusBar.tsx
│ ├── Sidebar.tsx
@@ -135,7 +142,8 @@ frontend/src/
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ ── ThemeSelector.tsx # Color theme picker
│ │ ── ThemeSelector.tsx # Color theme picker
│ │ └── BulkDeleteContactsModal.tsx # Bulk contact deletion dialog
│ ├── repeater/
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
@@ -145,6 +153,7 @@ frontend/src/
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
│ │ ├── RepeaterTelemetryHistoryPane.tsx # Historical telemetry chart/table
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
@@ -357,7 +366,7 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`
- `auto_resend_channel`
- `telemetry_interval_hours`
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
@@ -453,7 +462,9 @@ Do not rely on old class-only layout assumptions.
Key conventions documented in the reference:
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
- **Group titles** (sub-section headings within settings tabs) use `<h3 className="text-base font-semibold tracking-tight">`. These separate major groups like "Connection", "Identity", "MQTT Broker". When a group contains named sub-items (e.g. "Contact Management" → "Blocked Contacts", "Bulk Delete"), use `<h4 className="text-sm font-semibold">` for the children and nest them inside the parent group's `div` instead of separating with `<Separator />`.
- **Helper / description text** uses `text-[0.8125rem] text-muted-foreground` (13px). This is for explanatory paragraphs under inputs or sections — not for metadata, timestamps, or alert text which stay at `text-xs`.
- **Metadata labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium` for compact category tags like "Push-enabled conversations" or "Registered Devices".
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.11.3",
"version": "3.12.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -84,12 +84,12 @@ export function BulkAddChannelResultModal({
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
<p className="text-sm text-muted-foreground">No new channels were added.</p>
)}
{result && result.invalid_names.length > 0 && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
Ignored invalid room names: {result.invalid_names.join(', ')}
Ignored invalid channel names: {result.invalid_names.join(', ')}
</div>
)}
</div>
+5 -6
View File
@@ -298,17 +298,16 @@ export function ContactInfoPane({
{isPrefixOnlyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM
arrives before we hear an advertisement. This contact stays read-only until the full
key resolves from a later advertisement.
We&apos;ve received a message from this sender but don&apos;t have their full
identity yet. This contact stays read-only until their identity is confirmed &mdash;
this usually happens automatically when they next advertise.
</div>
)}
{isUnknownFullKeyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
We know this sender&apos;s full key, but we have not yet heard an advertisement that
fills in their identity details. Those details will appear automatically when an
advertisement arrives.
This sender&apos;s profile details (name, location) haven&apos;t arrived yet. They
will fill in automatically when the sender&apos;s next advertisement is heard.
</div>
)}
+5 -5
View File
@@ -103,17 +103,17 @@ function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'p
if (variant === 'prefix-only') {
return (
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM arrives
before we learn their full identity. This conversation is read-only until we hear an
advertisement that resolves the full key.
We&apos;ve received a message from this sender but don&apos;t have their full identity yet.
Sending is disabled until their identity is confirmed &mdash; this usually happens
automatically when they next advertise.
</div>
);
}
return (
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
A full identity profile is not yet available because we have not heard an advertisement from
this sender. The contact will fill in automatically when an advertisement arrives.
This sender&apos;s profile details (name, location) haven&apos;t arrived yet. They will fill
in automatically when the sender&apos;s next advert is heard.
</div>
);
}
+5 -5
View File
@@ -183,11 +183,11 @@ export function NewMessageModal({
permitCapitals
);
if (channelNames.length === 0) {
setError('Enter at least one valid room name');
setError('Enter at least one valid channel name');
return;
}
if (invalidNames.length > 0) {
setError(`Invalid room names: ${invalidNames.join(', ')}`);
setError(`Invalid channel names: ${invalidNames.join(', ')}`);
return;
}
await onBulkAddHashtagChannels(channelNames, tryHistorical);
@@ -249,7 +249,7 @@ export function NewMessageModal({
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag channels to add them in one batch'}
</DialogDescription>
</DialogHeader>
@@ -377,11 +377,11 @@ export function NewMessageModal({
aria-label="Bulk channel names"
value={bulkChannelText}
onChange={(e) => setBulkChannelText(e.target.value)}
placeholder={'#ops\nmesh-room\nanother-room'}
placeholder={'#ops\nmesh-chat\nanother-channel'}
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Paste room names separated by lines, spaces, or commas. Leading # marks are
Paste channel names separated by lines, spaces, or commas. Leading # marks are
stripped automatically.
</p>
</div>
@@ -185,7 +185,7 @@ export function SettingsDatabaseSection({
<div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3">
<Label className="text-base">Database Overview</Label>
<h3 className="text-base font-semibold tracking-tight">Database Overview</h3>
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">Database size</span>
@@ -212,11 +212,11 @@ export function SettingsDatabaseSection({
{/* ── Storage Cleanup ── */}
<div className="space-y-4">
<Label className="text-base">Storage Cleanup</Label>
<h3 className="text-base font-semibold tracking-tight">Storage Cleanup</h3>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-sm font-semibold">Delete Undecrypted Packets</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Permanently deletes stored raw packets that have not yet been decrypted. These are
retained in case you later obtain the correct key once deleted, these messages can
never be recovered.
@@ -248,8 +248,8 @@ export function SettingsDatabaseSection({
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-sm font-semibold">Purge Archival Raw Packets</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
@@ -269,7 +269,7 @@ export function SettingsDatabaseSection({
{/* ── DM Decryption ── */}
<div className="space-y-3">
<Label className="text-base">DM Decryption</Label>
<h3 className="text-base font-semibold tracking-tight">DM Decryption</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
@@ -286,7 +286,7 @@ export function SettingsDatabaseSection({
/>
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
</label>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, the server will automatically try to decrypt stored DM packets when a new
contact sends an advertisement. This may cause brief delays on large packet backlogs.
</p>
@@ -296,8 +296,8 @@ export function SettingsDatabaseSection({
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters so fewer tracked repeaters allows shorter intervals, and more tracked
@@ -427,145 +427,143 @@ export function SettingsDatabaseSection({
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
{/* Block discovery of new node types */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked
? prev.filter((t) => t !== typeCode)
: [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
)}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{/* Bulk delete */}
<div className="space-y-3">
<Label>Bulk Delete Contacts</Label>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Bulk delete */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
</div>
);
@@ -729,7 +729,7 @@ function MqttPrivateConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
</p>
@@ -843,6 +843,7 @@ function MqttHaConfigEditor({
const [contacts, setContacts] = useState<Contact[]>([]);
const [trackedRepeaters, setTrackedRepeaters] = useState<string[]>([]);
const [contactSearch, setContactSearch] = useState('');
const [radioConfig, setRadioConfig] = useState<{ public_key: string; name: string } | null>(null);
useEffect(() => {
(async () => {
@@ -858,6 +859,11 @@ function MqttHaConfigEditor({
setContacts(all);
})().catch(console.error);
api
.getRadioConfig()
.then((radio) => setRadioConfig({ public_key: radio.public_key, name: radio.name }))
.catch(console.error);
api
.getSettings()
.then((s) => setTrackedRepeaters(s.tracked_telemetry_repeaters ?? []))
@@ -897,6 +903,82 @@ function MqttHaConfigEditor({
const selectedContactDetails = contactOptions.filter((c) =>
selectedContacts.includes(c.public_key)
);
const selectedRepeaterDetails = repeaterOptions.filter((c) =>
selectedRepeaters.includes(c.public_key)
);
const prefix = ((config.topic_prefix as string) || 'meshcore').trim() || 'meshcore';
const nodeIdForKey = useCallback((publicKey: string) => publicKey.slice(0, 12).toLowerCase(), []);
const topicSummary = useMemo(() => {
const items: Array<{
kind: 'radio' | 'event' | 'repeater' | 'contact';
label: string;
publicKey: string;
nodeId: string;
topics: string[];
}> = [];
if (radioConfig?.public_key) {
const nodeId = nodeIdForKey(radioConfig.public_key);
items.push({
kind: 'radio',
label: radioConfig.name || radioConfig.public_key.slice(0, 12),
publicKey: radioConfig.public_key,
nodeId,
topics: [`${prefix}/${nodeId}/health`],
});
items.push({
kind: 'event',
label: radioConfig.name || radioConfig.public_key.slice(0, 12),
publicKey: radioConfig.public_key,
nodeId,
topics: [`${prefix}/${nodeId}/events/message`],
});
}
for (const repeater of selectedRepeaterDetails) {
const nodeId = nodeIdForKey(repeater.public_key);
items.push({
kind: 'repeater',
label: repeater.name || repeater.public_key.slice(0, 12),
publicKey: repeater.public_key,
nodeId,
topics: [`${prefix}/${nodeId}/telemetry`],
});
}
for (const contact of selectedContactDetails) {
const nodeId = nodeIdForKey(contact.public_key);
items.push({
kind: 'contact',
label: contact.name || contact.public_key.slice(0, 12),
publicKey: contact.public_key,
nodeId,
topics: [`${prefix}/${nodeId}/gps`],
});
}
return items;
}, [nodeIdForKey, prefix, radioConfig, selectedContactDetails, selectedRepeaterDetails]);
const kindLabel: Record<(typeof topicSummary)[number]['kind'], string> = {
radio: 'Local radio state',
event: 'Message events',
repeater: 'Repeater telemetry',
contact: 'Contact GPS',
};
const localRadioNodeId = radioConfig?.public_key
? nodeIdForKey(radioConfig.public_key)
: '<radio_node_id>';
const exampleRepeaterNodeId =
selectedRepeaterDetails.length > 0
? nodeIdForKey(selectedRepeaterDetails[0].public_key)
: '<repeater_node_id>';
const exampleContactNodeId =
selectedContactDetails.length > 0
? nodeIdForKey(selectedContactDetails[0].public_key)
: '<contact_node_id>';
const toggleTrackedContact = (key: string) => {
const current = [...selectedContacts];
@@ -914,111 +996,175 @@ function MqttHaConfigEditor({
onChange({ ...config, tracked_repeaters: current });
};
const prefix = ((config.topic_prefix as string) || 'meshcore').trim() || 'meshcore';
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Uses{' '}
<span
role="link"
tabIndex={0}
className="underline cursor-pointer hover:text-primary transition-colors"
onClick={() =>
window.open('https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery', '_blank')
}
onKeyDown={(e) => {
if (e.key === 'Enter')
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="space-y-1">
<h3 className="text-base font-semibold tracking-tight">Home Assistant MQTT Discovery</h3>
<p className="text-sm text-muted-foreground">
Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant
creates native devices, sensors, GPS trackers, and message events automatically.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
<div className="rounded-md border border-border/70 bg-background/80 p-3">
<div className="text-sm font-medium text-foreground">1. Same broker</div>
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Home Assistant&apos;s built-in MQTT integration must point at the same broker
configured below.
</p>
</div>
<div className="rounded-md border border-border/70 bg-background/80 p-3">
<div className="text-sm font-medium text-foreground">2. Pick what to expose</div>
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Choose repeaters for telemetry sensors and contacts for GPS tracker entities.
</p>
</div>
<div className="rounded-md border border-border/70 bg-background/80 p-3">
<div className="text-sm font-medium text-foreground">3. Automate in HA</div>
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
Radio health and message events publish continuously; repeater and contact data update
when new data is heard or collected.
</p>
</div>
</div>
<p className="text-[0.8125rem] text-muted-foreground">
Uses{' '}
<span
role="link"
tabIndex={0}
className="underline cursor-pointer hover:text-primary transition-colors"
onClick={() =>
window.open(
'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery',
'_blank'
);
}}
>
MQTT Discovery
</span>{' '}
to automatically create devices and entities in Home Assistant. Your HA instance must have
the MQTT integration configured and connected to the same broker. See{' '}
<span
role="link"
tabIndex={0}
className="underline cursor-pointer hover:text-primary transition-colors"
onClick={() =>
window.open(
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
'_blank'
)
}
onKeyDown={(e) => {
if (e.key === 'Enter')
)
}
onKeyDown={(e) => {
if (e.key === 'Enter')
window.open(
'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery',
'_blank'
);
}}
>
MQTT Discovery
</span>{' '}
and the topic conventions documented in{' '}
<span
role="link"
tabIndex={0}
className="underline cursor-pointer hover:text-primary transition-colors"
onClick={() =>
window.open(
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
'_blank'
);
}}
>
README_HA.md
</span>{' '}
for automation examples and setup details. Note that entities like repeaters and contact GPS
won't update until new data is available; there is no caching layer (so devices/entities
might take hours to days to appear).
</p>
)
}
onKeyDown={(e) => {
if (e.key === 'Enter')
window.open(
'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md',
'_blank'
);
}}
>
README_HA.md
</span>
.
</p>
</div>
<details className="group">
<summary className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium 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" />
What gets created in Home Assistant
</summary>
<div className="mt-2 space-y-2 text-xs text-muted-foreground rounded-md border border-border bg-muted/20 p-3">
<div className="mt-2 space-y-2 text-sm text-muted-foreground rounded-md border border-border bg-muted/20 p-3">
<div>
<span className="font-medium text-foreground">Local radio device</span> (always)
<span className="ml-1">&mdash; updates every 60s</span>
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
<li>
<code className="text-[0.6875rem]">binary_sensor.meshcore_*_connected</code> &mdash;
radio online/offline
<code className="text-[0.6875rem]">
{`binary_sensor.meshcore_${localRadioNodeId}_connected`}
</code>{' '}
&mdash; radio online/offline
</li>
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code> &mdash;
radio noise floor (dBm)
<code className="text-[0.6875rem]">
{`sensor.meshcore_${localRadioNodeId}_noise_floor`}
</code>{' '}
&mdash; radio noise floor (dBm)
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Per tracked repeater</span> &mdash;
updates on telemetry collect cycle (~8h) or manual dashboard fetch
updates on telemetry collect cycle (~8h) or manual dashboard fetch. Entity IDs shown use
one repeater for example; these sensors are created for each selected repeater.
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_battery_voltage</code> (V)
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_battery_voltage`}
</code>{' '}
(V)
</li>
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code>,{' '}
<code className="text-[0.6875rem]">*_last_rssi</code>,{' '}
<code className="text-[0.6875rem]">*_last_snr</code> (dBm/dB)
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_noise_floor`}
</code>
,{' '}
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_last_rssi`}
</code>
,{' '}
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_last_snr`}
</code>{' '}
(dBm/dB)
</li>
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_packets_received</code>,{' '}
<code className="text-[0.6875rem]">*_packets_sent</code>
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_packets_received`}
</code>
,{' '}
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_packets_sent`}
</code>
</li>
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_uptime</code> (seconds)
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_uptime`}
</code>{' '}
(seconds)
</li>
<li>
<code className="text-[0.6875rem]">sensor.meshcore_*_lpp_temperature_ch*</code>,{' '}
<code className="text-[0.6875rem]">*_lpp_humidity_ch*</code>, etc. &mdash;
CayenneLPP sensors (auto-detected from repeater)
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_lpp_temperature_ch1`}
</code>
,{' '}
<code className="text-[0.6875rem]">
{`sensor.meshcore_${exampleRepeaterNodeId}_lpp_humidity_ch1`}
</code>
, etc. &mdash; CayenneLPP sensors (auto-detected from repeater)
</li>
</ul>
</div>
<div>
<span className="font-medium text-foreground">Per tracked contact</span> &mdash; updates
passively when advertisements with GPS are heard
passively when advertisements with GPS are heard. Shown for one contact; a tracker is
created for each selected contact.
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
<li>
<code className="text-[0.6875rem]">device_tracker.meshcore_*</code> &mdash;
latitude/longitude
<code className="text-[0.6875rem]">
{`device_tracker.meshcore_${exampleContactNodeId}`}
</code>{' '}
&mdash; latitude/longitude
</li>
</ul>
</div>
@@ -1028,8 +1174,10 @@ function MqttHaConfigEditor({
each message matching the scope below
<ul className="mt-0.5 ml-4 list-disc space-y-0.5">
<li>
<code className="text-[0.6875rem]">event.meshcore_messages</code> &mdash; trigger
automations on sender, channel, or message content
<code className="text-[0.6875rem]">
{`event.meshcore_${localRadioNodeId}_messages`}
</code>{' '}
&mdash; trigger automations on sender, channel, or message content
</li>
</ul>
</div>
@@ -1043,11 +1191,62 @@ function MqttHaConfigEditor({
</div>
</details>
<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" />
Published Topic Summary
</summary>
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
Home Assistant device and entity IDs are keyed off the first 12 characters of each
node&apos;s public key, not the display name. Those same 12 characters are used in the
MQTT state topics below.
</p>
{topicSummary.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No topic previews available yet. Connect to a radio to resolve the local radio key,
and select contacts or repeaters above to preview their published topics.
</p>
) : (
<div className="space-y-2">
{topicSummary.map((item) => (
<div
key={`${item.kind}-${item.publicKey}`}
className="rounded border border-border/70 bg-background/70 p-2"
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="font-medium text-foreground">{kindLabel[item.kind]}</span>
<span className="text-foreground">{item.label}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground">
node id {item.nodeId}
</span>
</div>
<div className="mt-1 text-[0.6875rem] text-muted-foreground font-mono break-all">
key {item.publicKey}
</div>
{item.topics.map((topic) => (
<div
key={topic}
className="mt-1 rounded bg-muted px-2 py-1 text-[0.6875rem] font-mono text-foreground break-all"
>
{topic}
</div>
))}
</div>
))}
</div>
)}
<p className="text-[0.6875rem] text-muted-foreground">
Discovery config topics are also published under{' '}
<code className="text-[0.6875rem]">homeassistant/.../config</code>, but the topics above
are the primary runtime state and event topics.
</p>
</div>
</details>
<Separator />
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
MQTT Broker
</p>
<h3 className="text-base font-semibold tracking-tight">MQTT Broker</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
@@ -1138,10 +1337,8 @@ function MqttHaConfigEditor({
<Separator />
<div className="space-y-2">
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
GPS Tracked Contacts
</p>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">GPS Tracked Contacts</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Each selected contact becomes a <code className="text-[0.6875rem]">device_tracker</code>{' '}
in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for
tracking mobile nodes on an HA map dashboard.
@@ -1169,7 +1366,7 @@ function MqttHaConfigEditor({
)}
{contactOptions.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No contacts available.</p>
<p className="text-[0.8125rem] text-muted-foreground italic">No contacts available.</p>
) : (
<>
<Input
@@ -1181,7 +1378,7 @@ function MqttHaConfigEditor({
/>
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
{filteredContacts.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-1">
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
No contacts match &ldquo;{contactSearch}&rdquo;
</p>
) : (
@@ -1211,10 +1408,8 @@ function MqttHaConfigEditor({
<Separator />
<div className="space-y-2">
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Telemetry Tracked Repeaters
</p>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Telemetry Tracked Repeaters</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Each selected repeater becomes an HA device with sensors for battery voltage, RSSI, SNR,
noise floor, packet counts, and uptime. Data updates whenever telemetry is collected
(auto-collect runs every ~8 hours, or on manual dashboard fetch). Only repeaters already
@@ -1222,13 +1417,13 @@ function MqttHaConfigEditor({
repeater and opting in at the bottom of the page).
</p>
{trackedRepeaters.length === 0 ? (
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-[0.8125rem] text-muted-foreground">
No repeaters are being auto-tracked for telemetry. Add repeaters to the auto-telemetry
tracking list in the Radio section first, then return here to select which ones to
expose to HA.
</div>
) : repeaterOptions.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
<p className="text-[0.8125rem] text-muted-foreground italic">
Auto-tracked repeaters not found in contact list.
</p>
) : (
@@ -1254,14 +1449,12 @@ function MqttHaConfigEditor({
<Separator />
<div className="space-y-2">
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Message Events
</p>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Message Events</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Matching messages fire an{' '}
<code className="text-[0.6875rem]">event.meshcore_messages</code> entity in HA with
sender, text, channel, and direction attributes. Use HA automations to trigger actions on
specific messages, channels, or contacts.
<code className="text-[0.6875rem]">{`event.meshcore_${localRadioNodeId}_messages`}</code>{' '}
entity in HA with sender, text, channel, and direction attributes. Use HA automations to
trigger actions on specific messages, channels, or contacts.
</p>
</div>
<ScopeSelector scope={scope} onChange={onScopeChange} />
@@ -1280,7 +1473,7 @@ function MqttCommunityConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for
modifying a saved preset after creation. Only raw RF packets are shared &mdash; never
decrypted messages.
@@ -1343,7 +1536,7 @@ function MqttCommunityConfigEditor({
</div>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
@@ -1358,7 +1551,9 @@ function MqttCommunityConfigEditor({
value={(config.token_audience as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, token_audience: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Defaults to the broker host when blank</p>
<p className="text-[0.8125rem] text-muted-foreground">
Defaults to the broker host when blank
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-comm-email">Owner Email (optional)</Label>
@@ -1369,7 +1564,7 @@ function MqttCommunityConfigEditor({
value={(config.email as string) || ''}
onChange={(e) => onChange({ ...config, email: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Used to claim your node on the community aggregator
</p>
</div>
@@ -1433,7 +1628,7 @@ function MqttCommunityConfigEditor({
onChange={(e) => onChange({ ...config, iata: e.target.value.toUpperCase() })}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Your nearest airport&apos;s IATA code (required)
</p>
</div>
@@ -1447,7 +1642,7 @@ function MqttCommunityConfigEditor({
value={(config.topic_template as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Use <code>{'{IATA}'}</code> and <code>{'{PUBLIC_KEY}'}</code>. Default:{' '}
<code>{DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}</code>
</p>
@@ -1465,7 +1660,7 @@ function MeshRankConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created,
but only asks for the MeshRank packet topic you were given.
</p>
@@ -1492,7 +1687,7 @@ function MeshRankConfigEditor({
})
}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Paste the full topic template from your MeshRank config, for example{' '}
<code>meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets</code>.
</p>
@@ -1512,7 +1707,7 @@ function LetsMeshConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created,
but only asks for the values LetsMesh expects from you.
</p>
@@ -1593,7 +1788,7 @@ function BotConfigEditor({
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Define a <code className="bg-muted px-1 rounded">bot()</code> function that receives
message data and optionally returns a reply.
</p>
@@ -1617,7 +1812,7 @@ function BotConfigEditor({
<BotCodeEditor value={code} onChange={(c) => onChange({ ...config, code: c })} />
</Suspense>
<div className="text-xs text-muted-foreground space-y-1">
<div className="text-[0.8125rem] text-muted-foreground space-y-1">
<p>
<strong>Available:</strong> Standard Python libraries and any modules installed in the
server environment.
@@ -1665,7 +1860,7 @@ function MapUploadConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Automatically upload heard repeater and room server advertisements to{' '}
<a
href="https://map.meshcore.io"
@@ -1696,7 +1891,7 @@ function MapUploadConfigEditor({
/>
<div>
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
have confirmed the logged output looks correct.
</p>
@@ -1714,7 +1909,7 @@ function MapUploadConfigEditor({
value={(config.api_url as string) || ''}
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Leave blank to use the default <code>map.meshcore.io</code> endpoint.
</p>
</div>
@@ -1730,7 +1925,7 @@ function MapUploadConfigEditor({
/>
<div>
<span className="text-sm font-medium">Enable Geofence</span>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Only upload nodes whose location falls within the configured radius of your radio&apos;s
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
latitude/longitude set in Radio Settings.
@@ -1748,7 +1943,7 @@ function MapUploadConfigEditor({
</div>
)}
{radioLatLonConfigured && (
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Using radio position{' '}
<code>
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
@@ -1772,7 +1967,7 @@ function MapUploadConfigEditor({
})
}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Nodes further than this distance from your radio&apos;s position will not be uploaded.
</p>
</div>
@@ -1927,7 +2122,7 @@ function PillsSearchList({
)}
{items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">{emptyItemsMessage}</p>
<p className="text-[0.8125rem] text-muted-foreground italic">{emptyItemsMessage}</p>
) : (
<>
<Input
@@ -1939,7 +2134,7 @@ function PillsSearchList({
/>
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
{filtered.length === 0 ? (
<p className="text-xs text-muted-foreground italic py-1">
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
No {label.toLowerCase()} match &ldquo;{search}&rdquo;
</p>
) : (
@@ -2089,7 +2284,7 @@ function ScopeSelector({
return (
<div className="space-y-3">
<Label>Message Scope</Label>
<h3 className="text-base font-semibold tracking-tight">Message Scope</h3>
{showRawPackets && (
<label className="flex items-center gap-3 cursor-pointer">
@@ -2126,7 +2321,7 @@ function ScopeSelector({
{isListMode && (
<>
<p className="text-xs text-muted-foreground">{listHint}</p>
<p className="text-[0.8125rem] text-muted-foreground">{listHint}</p>
{channels.length > 0 && (
<PillsSearchList
@@ -2194,7 +2389,7 @@ function AppriseConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Send push notifications via{' '}
<a
href="https://github.com/caronc/apprise"
@@ -2228,7 +2423,7 @@ function AppriseConfigEditor({
onChange={(e) => onChange({ ...config, urls: e.target.value })}
rows={4}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
One URL per line. All URLs receive every matched notification. For Matrix room version 12
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
</p>
@@ -2243,7 +2438,7 @@ function AppriseConfigEditor({
/>
<div>
<span className="text-sm">Preserve identity on Discord</span>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, Discord webhooks will use their configured name/avatar instead of
overriding with MeshCore sender info.
</p>
@@ -2299,7 +2494,7 @@ function WebhookConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Send message data as JSON to an HTTP endpoint when messages are received.
</p>
@@ -2333,8 +2528,8 @@ function WebhookConfigEditor({
<Separator />
<div className="space-y-3">
<Label>HMAC Signing</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">HMAC Signing</h3>
<p className="text-[0.8125rem] text-muted-foreground">
When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in
the specified header (e.g. <code className="bg-muted px-1 rounded">sha256=ab12cd...</code>
).
@@ -2397,7 +2592,7 @@ function SqsConfigEditor({
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
or downstream automation.
</p>
@@ -2438,15 +2633,17 @@ function SqsConfigEditor({
value={(config.endpoint_url as string) || ''}
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Useful for LocalStack or custom endpoints</p>
<p className="text-[0.8125rem] text-muted-foreground">
Useful for LocalStack or custom endpoints
</p>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Static Credentials (optional)</Label>
<p className="text-xs text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Static Credentials (optional)</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Leave blank to use the server&apos;s normal AWS credential chain.
</p>
</div>
@@ -5,6 +5,7 @@ import { usePush } from '../../contexts/PushSubscriptionContext';
import type { Channel, Contact } from '../../types';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Checkbox } from '../ui/checkbox';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
@@ -92,8 +93,8 @@ function PushDeviceManagement({
if (!isSupported) {
return (
<div className="space-y-3">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Web Push Notifications</h3>
<p className="text-[0.8125rem] text-muted-foreground">
{window.isSecureContext
? 'Push notifications are not supported by this browser.'
: 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}
@@ -105,13 +106,13 @@ function PushDeviceManagement({
return (
<div className="space-y-4">
<div className="space-y-1">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
<h3 className="text-base font-semibold tracking-tight">Web Push Notifications</h3>
<p className="text-[0.8125rem] text-muted-foreground">
Receive notifications even when the browser is closed. Use the bell icon in any
conversation header to enable push for that contact or channel, or subscribe this browser
to receive notifications for all push-enabled conversations.
</p>
<p className="text-sm text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
The set of channels or DMs that trigger push notifications are global per-install (i.e.
all devices that register for Web Push will have the same set of channels/DMs that trigger
notifications). Subscribing or unsubscribing a particular browser only controls whether
@@ -265,12 +266,12 @@ export function SettingsLocalSection({
return (
<div className={className}>
<p className="text-sm text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
These settings apply only to this device/browser.
</p>
<div className="space-y-1">
<Label>Color Scheme</Label>
<h3 className="text-base font-semibold tracking-tight">Color Scheme</h3>
<ThemeSelector />
<ThemePreview className="mt-6" />
</div>
@@ -278,7 +279,7 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<h3 className="text-base font-semibold tracking-tight">Local Label</h3>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
@@ -305,7 +306,7 @@ export function SettingsLocalSection({
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Display a colored banner at the top of the page to identify this instance.
</p>
</div>
@@ -330,7 +331,7 @@ export function SettingsLocalSection({
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Controls how distances are shown throughout the app.
</p>
</div>
@@ -338,86 +339,107 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label>UI Tweaks</Label>
<h3 className="text-base font-semibold tracking-tight">UI Tweaks</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="reopen-last"
checked={reopenLastConversation}
onCheckedChange={(checked) => handleToggleReopenLastConversation(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="reopen-last">Reopen Last Conversation</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Automatically reopen to the last-open channel or contact when the app loads to the
bare URL.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="auto-focus-input"
checked={autoFocusInput}
onCheckedChange={(checked) => {
const v = checked === true;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="auto-focus-input">Auto-Focus Message Input</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Place the cursor in the message input when switching conversations. Desktop only.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryPercent}
onChange={(e) => {
const v = e.target.checked;
setBatteryPercent(v);
saveBatteryPercent(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery percentage in status bar</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="battery-percent"
checked={batteryPercent}
onCheckedChange={(checked) => {
const v = checked === true;
setBatteryPercent(v);
saveBatteryPercent(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="battery-percent">Show Battery Percentage</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Display the radio&apos;s battery percentage in the status bar. Data updates every 60
seconds and may take up to a minute to appear after connecting.
</p>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryVoltage}
onChange={(e) => {
const v = e.target.checked;
setBatteryVoltage(v);
saveBatteryVoltage(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery voltage in status bar</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="battery-voltage"
checked={batteryVoltage}
onCheckedChange={(checked) => {
const v = checked === true;
setBatteryVoltage(v);
saveBatteryVoltage(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="battery-voltage">Show Battery Voltage</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Display the radio&apos;s battery voltage in the status bar (in mV). Data updates
every 60 seconds and may take up to a minute to appear after connecting.
</p>
</div>
</div>
{(batteryPercent || batteryVoltage) && (
<p className="text-xs text-muted-foreground ml-7">
Battery data updates every 60 seconds and may take up to a minute to appear after
connecting.
</p>
)}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={statusDotPulse}
onChange={(e) => {
const v = e.target.checked;
setStatusDotPulse(v);
saveStatusDotPulse(v);
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">
Glitter status dot as packets arrive (blue = channel, purple = DM, cyan = advert, dark
green = other)
</span>
</label>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="status-dot-pulse"
checked={statusDotPulse}
onCheckedChange={(checked) => {
const v = checked === true;
setStatusDotPulse(v);
saveStatusDotPulse(v);
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="status-dot-pulse">Status Dot Glitters</Label>
<p className="text-[0.8125rem] text-muted-foreground">
Flash the connection status dot in color as packets arrive: blue for channel, purple
for DM, cyan for advert, dark green for other.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
@@ -490,7 +512,7 @@ export function SettingsLocalSection({
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
@@ -603,15 +625,15 @@ function ThemePreview({ className }: { className?: string }) {
desc="Sheet / dialog title"
/>
<PreviewTextRow
classes="text-base font-semibold"
label="text-base font-semibold"
desc="Section title"
classes="text-base font-semibold tracking-tight"
label="text-base font-semibold tracking-tight"
desc="Section / group title"
/>
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
<PreviewTextRow
classes="text-xs text-muted-foreground"
label="text-xs text-muted-foreground"
desc="Helper text"
classes="text-[0.8125rem] text-muted-foreground"
label="text-[0.8125rem] text-muted-foreground"
desc="Helper / description text"
/>
<PreviewTextRow
classes="text-[0.6875rem] text-muted-foreground"
@@ -620,7 +642,7 @@ function ThemePreview({ className }: { className?: string }) {
/>
<div>
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Section Label
Metadata Label
</p>
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
@@ -392,7 +392,7 @@ export function SettingsRadioSection({
<div className={className}>
{/* ── Connection ── */}
<div className="space-y-3">
<Label className="text-base">Connection</Label>
<h3 className="text-base font-semibold tracking-tight">Connection</h3>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
@@ -423,7 +423,7 @@ export function SettingsRadioSection({
>
{connectionBusy ? `${connectionActionLabel}...` : connectionActionLabel}
</Button>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Disconnect pauses automatic reconnect attempts so another device can use the radio.
</p>
</div>
@@ -432,7 +432,7 @@ export function SettingsRadioSection({
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
<h3 className="text-base font-semibold tracking-tight">Identity</h3>
</div>
<div className="space-y-2">
@@ -477,7 +477,7 @@ export function SettingsRadioSection({
{/* ── Radio Parameters ── */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
<h3 className="text-base font-semibold tracking-tight">Radio Parameters</h3>
</div>
<div className="space-y-2">
@@ -590,7 +590,7 @@ export function SettingsRadioSection({
{/* ── Location ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base">Location</Label>
<h3 className="text-base font-semibold tracking-tight">Location</h3>
<Button
type="button"
variant="outline"
@@ -645,7 +645,7 @@ export function SettingsRadioSection({
<option value="off">Off</option>
<option value="current">Include Node Location</option>
</select>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Companion-radio firmware does not distinguish between saved coordinates and live GPS
here. When enabled, adverts include the node&apos;s current location state. That may be
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
@@ -668,13 +668,13 @@ export function SettingsRadioSection({
variant="outline"
className="flex-1"
>
{busy && !rebooting ? 'Saving...' : 'Save'}
{busy && !rebooting ? 'Saving...' : 'Save Radio Config'}
</Button>
<Button onClick={handleSaveAndReboot} disabled={busy || rebooting} className="flex-1">
{rebooting ? 'Rebooting...' : 'Save & Reboot'}
{rebooting ? 'Rebooting...' : 'Save Radio Config & Reboot'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Some settings may require a reboot to take effect on some radios.
</p>
@@ -682,7 +682,7 @@ export function SettingsRadioSection({
{/* ── Messaging ── */}
<div className="space-y-2">
<Label className="text-base">Messaging</Label>
<h3 className="text-base font-semibold tracking-tight">Messaging</h3>
</div>
<div className="space-y-2">
@@ -695,7 +695,7 @@ export function SettingsRadioSection({
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
@@ -714,7 +714,7 @@ export function SettingsRadioSection({
/>
<div className="space-y-1">
<Label htmlFor="auto-resend-channel">Auto-Resend Unheard Channel Messages</Label>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, outgoing channel messages that receive no echo within 2 seconds are
automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
that already heard the original will ignore the duplicate. This functionality will NOT
@@ -732,7 +732,7 @@ export function SettingsRadioSection({
onChange={(e) => setFloodScope(e.target.value)}
placeholder="MyRegion"
/>
<p className="text-xs 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
that region can forward the traffic, while repeaters configured to deny other regions may
drop it. Leave empty to disable.
@@ -749,7 +749,7 @@ export function SettingsRadioSection({
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
<p className="text-[0.8125rem] text-muted-foreground">
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
@@ -769,140 +769,143 @@ export function SettingsRadioSection({
)}
<Button onClick={handleSaveFloodSettings} disabled={floodBusy} className="w-full">
{floodBusy ? 'Saving...' : 'Save Settings'}
{floodBusy ? 'Saving...' : 'Save Messaging Settings'}
</Button>
<Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label>
</div>
<div className="space-y-5">
<h3 className="text-base font-semibold tracking-tight">Advertising &amp; Discovery</h3>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-[0.8125rem] text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
<div className="space-y-3">
<Label>Mesh Discovery</Label>
<p className="text-xs text-muted-foreground">
Discover nearby node types that currently respond to mesh discovery requests: repeaters
and sensors.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ target: 'repeaters', label: 'Discover Repeaters' },
{ target: 'sensors', label: 'Discover Sensors' },
{ target: 'all', label: 'Discover Both' },
].map(({ target, label }) => (
<div className="space-y-2">
<h4 className="text-sm font-semibold">Send Advertisement</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
key={target}
type="button"
variant="outline"
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
))}
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
{discoverError && (
<p className="text-sm text-destructive" role="alert">
{discoverError}
</p>
)}
{meshDiscovery && (
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">
Last sweep: {meshDiscovery.results.length} node
{meshDiscovery.results.length === 1 ? '' : 's'}
</p>
<p className="text-xs text-muted-foreground">
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
</p>
</div>
{meshDiscovery.results.length === 0 ? (
<p className="text-sm text-muted-foreground">
No supported nodes responded during the last discovery sweep.
</p>
) : (
<div className="space-y-2">
{meshDiscovery.results.map((result) => (
<div
key={result.public_key}
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Heard here: {result.local_snr ?? 'n/a'} dB SNR / {result.local_rssi ?? 'n/a'}{' '}
dBm RSSI. Remote heard us: {result.remote_snr ?? 'n/a'} dB SNR.
</p>
</div>
))}
</div>
)}
</div>
)}
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold">Mesh Discovery</h4>
<p className="text-[0.8125rem] text-muted-foreground">
Discover nearby node types that currently respond to mesh discovery requests: repeaters
and sensors.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ target: 'repeaters', label: 'Discover Repeaters' },
{ target: 'sensors', label: 'Discover Sensors' },
{ target: 'all', label: 'Discover Both' },
].map(({ target, label }) => (
<Button
key={target}
type="button"
variant="outline"
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
className="w-full"
>
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
</Button>
))}
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
{discoverError && (
<p className="text-sm text-destructive" role="alert">
{discoverError}
</p>
)}
{meshDiscovery && (
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">
Last sweep: {meshDiscovery.results.length} node
{meshDiscovery.results.length === 1 ? '' : 's'}
</p>
<p className="text-xs text-muted-foreground">
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
</p>
</div>
{meshDiscovery.results.length === 0 ? (
<p className="text-sm text-muted-foreground">
No supported nodes responded during the last discovery sweep.
</p>
) : (
<div className="space-y-2">
{meshDiscovery.results.map((result) => (
<div
key={result.public_key}
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">
{result.node_type}
</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Heard here: {result.local_snr ?? 'n/a'} dB SNR /{' '}
{result.local_rssi ?? 'n/a'} dBm RSSI. Remote heard us:{' '}
{result.remote_snr ?? 'n/a'} dB SNR.
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
@@ -226,7 +226,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<div className="space-y-6">
{/* Network */}
<div>
<h4 className="text-sm font-medium mb-2">Network</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Network</h3>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.contact_count}</div>
@@ -247,7 +247,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Messages */}
<div>
<h4 className="text-sm font-medium mb-2">Messages</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Messages</h3>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_dms}</div>
@@ -268,7 +268,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Activity</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
@@ -305,7 +305,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Packets</h3>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
@@ -327,7 +327,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">
Packets per Hour (72h)
</h3>
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
</div>
</>
@@ -337,7 +339,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
{/* Path Hash Width */}
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Path Hash Width (24h)</h3>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
@@ -407,7 +409,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">
Busiest Channels (24h)
</h3>
<ResponsiveContainer
width="100%"
height={stats.busiest_channels_24h.length * 28 + 8}
@@ -451,7 +455,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
<h3 className="text-base font-semibold tracking-tight mb-2">Noise Floor (24h)</h3>
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
<div className="mb-2 text-xs text-muted-foreground">
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
@@ -43,6 +43,6 @@ describe('BulkAddChannelResultModal', () => {
expect(opsLink.getAttribute('href')).toContain('#channel/');
expect(meshLink.getAttribute('href')).toContain('#channel/');
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
expect(screen.getByText(/Ignored invalid channel names: bad_room/)).toBeTruthy();
});
});
+4 -2
View File
@@ -379,7 +379,7 @@ describe('ConversationPane', () => {
/>
);
expect(screen.getByText(/A full identity profile is not yet available/i)).toBeInTheDocument();
expect(screen.getByText(/profile details.*haven't arrived yet/i)).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
@@ -416,7 +416,9 @@ describe('ConversationPane', () => {
/>
);
expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument();
expect(
screen.getByText(/Sending is disabled until their identity is confirmed/i)
).toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
});
});
+99
View File
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
deleteFanoutConfig: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
getSettings: vi.fn(),
getRadioConfig: vi.fn(),
},
}));
@@ -97,6 +98,20 @@ beforeEach(() => {
mockedApi.getFanoutConfigs.mockResolvedValue([]);
mockedApi.getChannels.mockResolvedValue([]);
mockedApi.getContacts.mockResolvedValue([]);
mockedApi.getSettings.mockResolvedValue({
max_radio_contacts: 200,
auto_decrypt_dm_on_advert: true,
last_message_times: {},
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
});
mockedApi.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
name: 'TestNode',
@@ -975,6 +990,90 @@ describe('SettingsFanoutSection', () => {
);
});
it('shows Home Assistant topic summary with device-key-derived node ids', async () => {
mockedApi.getContacts.mockResolvedValue([
{
public_key: 'bb'.repeat(32),
name: 'Alice',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
direct_path_updated_at: null,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
favorite: false,
},
{
public_key: 'cc'.repeat(32),
name: 'Repeater One',
type: 2,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
direct_path_updated_at: null,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
favorite: false,
},
]);
mockedApi.getSettings.mockResolvedValue({
max_radio_contacts: 200,
auto_decrypt_dm_on_advert: true,
last_message_times: {},
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: ['cc'.repeat(32)],
auto_resend_channel: false,
telemetry_interval_hours: 8,
});
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Home Assistant MQTT Discovery');
confirmCreateIntegration();
expect(await screen.findByText('Published Topic Summary')).toBeInTheDocument();
fireEvent.click(await screen.findByLabelText(/Alice/));
fireEvent.click(await screen.findByLabelText(/Repeater One/));
await waitFor(() => {
expect(screen.getAllByText('node id aaaaaaaaaaaa').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('node id bbbbbbbbbbbb')).toBeInTheDocument();
expect(screen.getByText('node id cccccccccccc')).toBeInTheDocument();
});
expect(screen.getByText('meshcore/aaaaaaaaaaaa/health')).toBeInTheDocument();
expect(screen.getByText('meshcore/aaaaaaaaaaaa/events/message')).toBeInTheDocument();
expect(screen.getByText('meshcore/bbbbbbbbbbbb/gps')).toBeInTheDocument();
expect(screen.getByText('meshcore/cccccccccccc/telemetry')).toBeInTheDocument();
});
it('LetsMesh (US) preset pre-fills the expected broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-us',
+3 -3
View File
@@ -119,7 +119,7 @@ describe('NewMessageModal form reset', () => {
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
});
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
it('opens on the bulk tab when enabled and submits normalized channel names', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
@@ -145,7 +145,7 @@ describe('NewMessageModal form reset', () => {
expect(onClose).toHaveBeenCalled();
});
it('shows invalid bulk room names before submitting', async () => {
it('shows invalid bulk channel names before submitting', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
@@ -156,7 +156,7 @@ describe('NewMessageModal form reset', () => {
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
expect(screen.getByText('Invalid channel names: bad_room')).toBeTruthy();
});
});
+8 -8
View File
@@ -334,7 +334,7 @@ describe('SettingsModal', () => {
fireEvent.change(screen.getByLabelText('Advert Location Source'), {
target: { value: 'off' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@@ -348,7 +348,7 @@ describe('SettingsModal', () => {
openRadioSection();
fireEvent.click(screen.getByLabelText('Extra Direct ACK Transmission'));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ multi_acks_enabled: true }));
@@ -362,8 +362,8 @@ describe('SettingsModal', () => {
const maxContactsInput = screen.getByLabelText('Max Contacts on Radio');
fireEvent.change(maxContactsInput, { target: { value: '250' } });
// Click the "Save Settings" button in the Flood & Advert Control section
const saveButtons = screen.getAllByRole('button', { name: 'Save Settings' });
// Click the "Save Messaging Settings" button
const saveButtons = screen.getAllByRole('button', { name: 'Save Messaging Settings' });
fireEvent.click(saveButtons[0]);
await waitFor(() => {
@@ -377,8 +377,8 @@ describe('SettingsModal', () => {
});
openRadioSection();
// Click the "Save Settings" button in the Flood & Advert Control section
const saveButtons = screen.getAllByRole('button', { name: 'Save Settings' });
// Click the "Save Messaging Settings" button
const saveButtons = screen.getAllByRole('button', { name: 'Save Messaging Settings' });
fireEvent.click(saveButtons[0]);
await waitFor(() => {
@@ -542,7 +542,7 @@ describe('SettingsModal', () => {
});
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Save & Reboot' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledTimes(1);
expect(onReboot).toHaveBeenCalledTimes(1);
@@ -566,7 +566,7 @@ describe('SettingsModal', () => {
renderModal();
openLocalSection();
const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation');
const checkbox = screen.getByLabelText('Reopen Last Conversation');
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
+1 -9
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.11.3"
version = "3.12.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
@@ -19,14 +19,6 @@ dependencies = [
"pywebpush>=0.14.0",
]
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-xdist>=3.0",
"httpx>=0.27.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+57 -5
View File
@@ -24,6 +24,45 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
HA_CONFIG="$REPO_ROOT/ha_test_config"
HA_CLIENT_ID="http://localhost:8123/"
ha_storage_has_domain() {
local domain="$1"
HA_STORAGE_DIR="$HA_CONFIG/.storage" HA_DOMAIN="$domain" python3 - <<'PY'
import json
import os
import pathlib
import sys
storage = pathlib.Path(os.environ["HA_STORAGE_DIR"]) / "core.config_entries"
if not storage.exists():
sys.exit(1)
try:
data = json.loads(storage.read_text())
except Exception:
sys.exit(1)
entries = data.get("data", {}).get("entries", [])
found = any(entry.get("domain") == os.environ["HA_DOMAIN"] for entry in entries)
sys.exit(0 if found else 1)
PY
}
wait_for_storage_domain() {
local domain="$1"
local timeout_seconds="${2:-30}"
for i in $(seq 1 "$timeout_seconds"); do
if ha_storage_has_domain "$domain"; then
echo " Persisted $domain config entry after ${i}s"
return 0
fi
sleep 1
done
return 1
}
echo "==> Stopping any existing test containers..."
docker rm -f ha-test-mosquitto 2>/dev/null || true
@@ -81,7 +120,7 @@ done
echo "==> Running onboarding (user: dev / pass: dev)..."
ONBOARD_RESP=$(curl -s -X POST http://localhost:8123/api/onboarding/users \
-H "Content-Type: application/json" \
-d '{"client_id":"http://localhost:8123/","name":"Dev","username":"dev","password":"dev","language":"en"}')
-d "{\"client_id\":\"$HA_CLIENT_ID\",\"name\":\"Dev\",\"username\":\"dev\",\"password\":\"dev\",\"language\":\"en\"}")
AUTH_CODE=$(echo "$ONBOARD_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_code',''))" 2>/dev/null || echo "")
if [ -z "$AUTH_CODE" ]; then
@@ -99,7 +138,7 @@ fi
# Exchange auth code for tokens
echo "==> Exchanging auth code for access token..."
TOKEN_RESP=$(curl -s -X POST http://localhost:8123/auth/token \
-d "grant_type=authorization_code&code=$AUTH_CODE&client_id=http://localhost:8123/")
-d "grant_type=authorization_code&code=$AUTH_CODE&client_id=$HA_CLIENT_ID")
ACCESS_TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
if [ -z "$ACCESS_TOKEN" ]; then
@@ -126,7 +165,7 @@ curl -s -X POST http://localhost:8123/api/onboarding/analytics \
curl -s -X POST http://localhost:8123/api/onboarding/integration \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' > /dev/null 2>&1 || true
-d "{\"client_id\":\"$HA_CLIENT_ID\"}" > /dev/null 2>&1 || true
# ── Configure MQTT integration ───────────────────────────────────────────
@@ -150,7 +189,14 @@ else
RESULT_TYPE=$(echo "$MQTT_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('type',''))" 2>/dev/null || echo "")
if [ "$RESULT_TYPE" = "create_entry" ]; then
echo " MQTT integration configured successfully."
echo " MQTT integration configured in HA; waiting for storage flush..."
if wait_for_storage_domain "mqtt" 30; then
echo " MQTT integration configured successfully."
else
echo " ERROR: MQTT config entry never persisted to $HA_CONFIG/.storage/core.config_entries"
echo " Response: $MQTT_RESULT"
exit 1
fi
else
echo " WARNING: MQTT config flow returned: $RESULT_TYPE"
echo " Response: $MQTT_RESULT"
@@ -166,7 +212,7 @@ sudo tee -a "$HA_CONFIG/configuration.yaml" > /dev/null << 'EOF'
logger:
default: warning
logs:
homeassistant.components.mqtt: debug
homeassistant.components.mqtt: info
EOF
# Gracefully stop the backgrounded HA so it flushes config to disk
@@ -175,6 +221,12 @@ echo "==> Stopping background HA (graceful, flushing config)..."
docker stop ha-test-homeassistant > /dev/null 2>&1
docker rm ha-test-homeassistant > /dev/null 2>&1
if ! ha_storage_has_domain "mqtt"; then
echo " ERROR: MQTT config entry disappeared after Home Assistant shutdown."
echo " Check $HA_CONFIG/.storage/core.config_entries"
exit 1
fi
# ── Summary ───────────────────────────────────────────────────────────────
echo ""
@@ -36,9 +36,9 @@ test.describe('Reopen last conversation (device-local)', () => {
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i'))
).toBeVisible();
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByRole('button', { name: /Local Configuration/i }).click();
await page.getByLabel('Reopen to last viewed channel/conversation').check();
await page.getByLabel('Reopen Last Conversation').check();
await page.getByRole('button', { name: 'Back to Chat' }).click();
// Fresh launch path without hash should restore the saved conversation.
@@ -58,10 +58,10 @@ test.describe('Reopen last conversation (device-local)', () => {
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i'))
).toBeVisible();
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByRole('button', { name: /Local Configuration/i }).click();
const reopenToggle = page.getByLabel('Reopen to last viewed channel/conversation');
const reopenToggle = page.getByLabel('Reopen Last Conversation');
await reopenToggle.check();
await reopenToggle.uncheck();
+2 -2
View File
@@ -12,9 +12,9 @@ test.describe('Statistics page', () => {
await page.getByRole('button', { name: /Statistics/i }).click();
// Verify section headings/labels are visible (use heading role or exact match to avoid ambiguity)
await expect(page.locator('h4').getByText('Network')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('h3').getByText('Network')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Contacts', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Channels', { exact: true }).first()).toBeVisible();
await expect(page.locator('h4').getByText('Packets', { exact: true })).toBeVisible();
await expect(page.locator('h3').getByText('Packets', { exact: true })).toBeVisible();
});
});
+2 -2
View File
@@ -23,8 +23,8 @@ test.describe('Radio settings', () => {
await nameInput.clear();
await nameInput.fill(testName);
// Use "Save" (no reboot) — name changes apply immediately
await page.getByRole('button', { name: 'Save', exact: true }).click();
// Use "Save Radio Config" (no reboot) — name changes apply immediately
await page.getByRole('button', { name: 'Save Radio Config', exact: true }).click();
await expect(page.getByText('Radio config saved')).toBeVisible({ timeout: 10_000 });
// --- Step 2: Verify via API (send_appstart refreshes cached info) ---
+37
View File
@@ -15,6 +15,7 @@ from app.fanout.mqtt_ha import (
_node_id,
_radio_discovery_configs,
_repeater_discovery_configs,
_repeater_telemetry_payload,
)
# ---------------------------------------------------------------------------
@@ -249,6 +250,7 @@ class TestMqttHaFiltering:
assert topic == f"meshcore/{_node_id(key)}/telemetry"
assert payload["battery_volts"] == 4.1
assert payload["uptime_seconds"] == 86400
assert mod._publisher.publish.call_args.kwargs.get("retain") is not True
class TestMqttHaHealth:
@@ -383,6 +385,40 @@ class TestMqttHaLifecycle:
assert mod._radio_name == "MyRadio"
mod._publisher.start.assert_awaited_once()
@pytest.mark.asyncio
async def test_publish_discovery_replays_cached_repeater_telemetry_after_configs(self):
key = "ccdd11223344"
mod = MqttHaModule("test", _base_config(tracked_repeaters=[key]))
mod._publisher = MagicMock()
mod._publisher.publish = AsyncMock()
mod._radio_key = "aabbccddeeff"
mod._radio_name = "MyRadio"
latest = {
"timestamp": 1234,
"data": {
"battery_volts": 4.1,
"noise_floor_dbm": -112,
"lpp_sensors": [
{"channel": 1, "type_name": "temperature", "value": 23.5},
],
},
}
mod._resolve_contact_name = AsyncMock(return_value="Rep1")
mod._resolve_latest_telemetry = AsyncMock(return_value=latest)
await mod._publish_discovery()
calls = mod._publisher.publish.call_args_list
discovery_calls = [c for c in calls if c.args[0].startswith("homeassistant/")]
telemetry_calls = [c for c in calls if c.args[0] == f"meshcore/{_node_id(key)}/telemetry"]
assert telemetry_calls
assert telemetry_calls[-1].args[1] == _repeater_telemetry_payload(latest["data"])
assert telemetry_calls[-1].kwargs.get("retain") is not True
assert calls.index(telemetry_calls[-1]) > calls.index(discovery_calls[-1])
class TestMqttHaMessage:
@pytest.mark.asyncio
@@ -589,6 +625,7 @@ class TestMqttHaTelemetryWithLpp:
assert payload["battery_volts"] == 4.1
assert payload["lpp_temperature_ch1"] == 23.5
assert payload["lpp_humidity_ch2"] == 45.0
assert mod._publisher.publish.call_args.kwargs.get("retain") is not True
@pytest.mark.asyncio
async def test_on_telemetry_triggers_rediscovery_for_new_lpp_sensor(self):
+28 -2
View File
@@ -353,6 +353,32 @@ class TestSyncRadioTime:
assert result is False
mock_mc.commands.reboot.assert_not_called()
@pytest.mark.asyncio
async def test_background_failures_log_debug_instead_of_warning(self, caplog):
"""Periodic syncs should not keep emitting warning-level clock skew logs."""
import time as _time
radio_time = int(_time.time()) + 86400
mock_mc = MagicMock()
mock_mc.commands.set_time = AsyncMock(
return_value=Event(EventType.ERROR, {"reason": "illegal_arg"})
)
mock_mc.commands.get_time = AsyncMock(
return_value=Event(EventType.CURRENT_TIME, {"time": radio_time})
)
mock_mc.commands.reboot = AsyncMock()
with caplog.at_level("DEBUG"):
result = await sync_radio_time(mock_mc, warn_on_failure=False)
assert result is False
assert "Radio rejected time sync:" in caplog.text
assert not [
rec
for rec in caplog.records
if rec.levelname == "WARNING" and "Radio rejected time sync:" in rec.message
]
class TestSyncRecentContactsToRadio:
"""Test the sync_recent_contacts_to_radio function."""
@@ -1686,7 +1712,7 @@ class TestPeriodicSyncLoopRaces:
mock_cleanup.assert_called_once()
mock_sync.assert_called_once_with(mock_mc)
mock_time.assert_called_once_with(mock_mc)
mock_time.assert_called_once_with(mock_mc, warn_on_failure=False)
@pytest.mark.asyncio
async def test_skips_full_sync_below_threshold_but_still_syncs_time(self):
@@ -1710,7 +1736,7 @@ class TestPeriodicSyncLoopRaces:
mock_cleanup.assert_called_once()
mock_sync.assert_not_called()
mock_time.assert_called_once_with(mock_mc)
mock_time.assert_called_once_with(mock_mc, warn_on_failure=False)
# ---------------------------------------------------------------------------
Generated
+1 -14
View File
@@ -1533,7 +1533,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.11.3"
version = "3.12.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },
@@ -1550,14 +1550,6 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
test = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-xdist" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
@@ -1577,18 +1569,13 @@ requires-dist = [
{ name = "boto3", specifier = ">=1.38.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
{ name = "meshcore", specifier = "==2.3.2" },
{ name = "pycryptodome", specifier = ">=3.20.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24.0" },
{ name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.0" },
{ name = "pywebpush", specifier = ">=0.14.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" },
]
provides-extras = ["test"]
[package.metadata.requires-dev]
dev = [