mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 20:06:13 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6e1218888 | |||
| ad0e398704 | |||
| 39f5bb2b51 | |||
| 5257cb0b1b | |||
| b1547773c5 | |||
| 71da6841c1 | |||
| 6f00e857c2 | |||
| 303becf4b8 | |||
| b1020e6e34 | |||
| 87a892fc6e |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,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>
|
||||
|
||||
@@ -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've received a message from this sender but don't have their full
|
||||
identity yet. This contact stays read-only until their identity is confirmed —
|
||||
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'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's profile details (name, location) haven't arrived yet. They
|
||||
will fill in automatically when the sender's next advertisement is heard.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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've received a message from this sender but don't have their full identity yet.
|
||||
Sending is disabled until their identity is confirmed — 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's profile details (name, location) haven't arrived yet. They will fill
|
||||
in automatically when the sender's next advert is heard.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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">— 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> —
|
||||
radio online/offline
|
||||
<code className="text-[0.6875rem]">
|
||||
{`binary_sensor.meshcore_${localRadioNodeId}_connected`}
|
||||
</code>{' '}
|
||||
— radio online/offline
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_noise_floor</code> —
|
||||
radio noise floor (dBm)
|
||||
<code className="text-[0.6875rem]">
|
||||
{`sensor.meshcore_${localRadioNodeId}_noise_floor`}
|
||||
</code>{' '}
|
||||
— radio noise floor (dBm)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Per tracked repeater</span> —
|
||||
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. —
|
||||
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. — CayenneLPP sensors (auto-detected from repeater)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Per tracked contact</span> — 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> —
|
||||
latitude/longitude
|
||||
<code className="text-[0.6875rem]">
|
||||
{`device_tracker.meshcore_${exampleContactNodeId}`}
|
||||
</code>{' '}
|
||||
— 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> — trigger
|
||||
automations on sender, channel, or message content
|
||||
<code className="text-[0.6875rem]">
|
||||
{`event.meshcore_${localRadioNodeId}_messages`}
|
||||
</code>{' '}
|
||||
— 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'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 “{contactSearch}”
|
||||
</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 — 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'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'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'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 “{search}”
|
||||
</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'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'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'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'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'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 & Discovery</Label>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-base font-semibold tracking-tight">Advertising & 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user