mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 12:00:28 +02:00
Compare commits
23 Commits
web-push
...
channel-mute
| Author | SHA1 | Date | |
|---|---|---|---|
| c8c8e6b549 | |||
| 491f159463 | |||
| ead74e975b | |||
| 4fbd245ee4 | |||
| dc7ec13cc5 | |||
| cfa2bf575c | |||
| e9ef68432a | |||
| 476adf393f | |||
| f7a311d74b | |||
| 09f807230b | |||
| c098f9eeb5 | |||
| 05493d06fc | |||
| 6c1b8bd7e9 | |||
| 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
|
||||
@@ -497,8 +503,9 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||
|
||||
**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>
|
||||
|
||||
@@ -83,7 +83,7 @@ Access the app at http://localhost:8000.
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
> [!TIP]
|
||||
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||
> Running on lightweight hardware, or just don't want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||
|
||||
> [!NOTE]
|
||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||
@@ -118,7 +118,7 @@ bash scripts/setup/install_docker.sh
|
||||
|
||||
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
|
||||
|
||||
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
||||
Your local `docker-compose.yml` is gitignored so future pulls don't overwrite your Docker settings.
|
||||
|
||||
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||
|
||||
@@ -240,6 +240,7 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
|
||||
## Where To Go Next
|
||||
|
||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||
- Home Assistant-specific guidance and entity/sensor naming schemes: [README_HA.md](README_HA.md)
|
||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ These are intended for diagnosing or working around radios that behave oddly.
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading (see [Contact Loading Issues](#contact-loading-issues) below) |
|
||||
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||
@@ -19,6 +20,29 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||
|
||||
## Contact Loading Issues
|
||||
|
||||
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
|
||||
|
||||
On BLE connections with many contacts (or radios with large contact tables from organic advertisements), the initial contact enumeration may time out. If this happens, the app will still attempt to load your favorites and recent contacts onto the radio on a best-effort basis, but without a full snapshot of what's already on the radio, some adds may be redundant or fail.
|
||||
|
||||
If the radio's contact table is already full (from contacts added by advertisements or another client), the app may not be able to load all desired contacts. In this case you'll see a warning that auto-DM acking may not work for all contacts. To resolve this:
|
||||
|
||||
- **Clear the radio's contact table** using another MeshCore client (e.g., the official companion app), then restart RemoteTerm
|
||||
- **Lower the contact fill target** in Radio Settings to reduce how many contacts the app tries to load
|
||||
- **Enable autoevict mode** (see below) to let the radio automatically make room
|
||||
- If you don't need auto-DM acking, you can safely ignore these warnings — **sending and receiving messages is never affected**
|
||||
|
||||
### Autoevict Mode
|
||||
|
||||
Setting `MESHCORE_LOAD_WITH_AUTOEVICT=true` enables an alternative contact loading strategy that avoids TABLE_FULL errors entirely. On connect, the app enables the radio's `AUTO_ADD_OVERWRITE_OLDEST` preference, which makes the radio automatically evict the oldest non-favorite contact when the contact table is full. This means:
|
||||
|
||||
- Contact adds never fail — the radio always makes room by evicting stale contacts
|
||||
- The app can load contacts even when it can't enumerate the radio's existing contact table (e.g., on slow BLE connections)
|
||||
- No contact removal step is needed during reconciliation
|
||||
|
||||
**Trade-off:** Contacts loaded by the app are not marked as radio-side favorites, so they are eviction candidates if the radio receives a new advertisement while full. In practice, freshly-loaded contacts have a recent `lastmod` timestamp and will be among the last to be evicted. If you disconnect the radio from RemoteTerm and use it standalone, your contacts will not be protected from eviction by newer advertisements.
|
||||
|
||||
## Sub-Path Reverse Proxy
|
||||
|
||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||
|
||||
+259
-38
@@ -19,6 +19,26 @@ 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 for MQTT topics:
|
||||
|
||||
- Full public key: `ae92577bae6c4f1d...`
|
||||
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
|
||||
- Example MQTT topic: `meshcore/ae92577bae6c/gps`
|
||||
|
||||
When this README shows `<node_id>`, it always means that 12-character value. Node IDs appear in:
|
||||
|
||||
- MQTT discovery topics under `homeassistant/...`
|
||||
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
|
||||
|
||||
**Entity IDs** are different — HA auto-generates them from the device name and entity name, not from the node ID. For example, a radio named "MyRadio" produces entities like `binary_sensor.myradio_connected` and `event.myradio_messages`. A contact named "Alice" produces `device_tracker.alice`. You can find your actual entity IDs in **Settings > Devices & Services > MQTT** in HA, and you can rename them in HA's UI without affecting the integration.
|
||||
|
||||
You can also see the MQTT topic IDs in RemoteTerm's Home Assistant integration UI:
|
||||
|
||||
- `What gets created in Home Assistant`
|
||||
- `Published topic summary`
|
||||
|
||||
## What Gets Created
|
||||
|
||||
### Local Radio Device
|
||||
@@ -27,24 +47,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.<radio_name>_connected` | Connectivity | Radio online/offline |
|
||||
| `sensor.<radio_name>_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.<repeater_name>_battery_voltage` | Voltage | V | Battery level |
|
||||
| `sensor.<repeater_name>_noise_floor` | Signal strength | dBm | Local noise floor |
|
||||
| `sensor.<repeater_name>_last_rssi` | Signal strength | dBm | Last received signal strength |
|
||||
| `sensor.<repeater_name>_last_snr` | -- | dB | Last signal-to-noise ratio |
|
||||
| `sensor.<repeater_name>_packets_received` | -- | count | Total packets received |
|
||||
| `sensor.<repeater_name>_packets_sent` | -- | count | Total packets sent |
|
||||
| `sensor.<repeater_name>_uptime` | Duration | s | Uptime since last reboot |
|
||||
|
||||
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
|
||||
|
||||
### Contact Device Trackers
|
||||
|
||||
@@ -52,11 +74,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `device_tracker.meshcore_*` | GPS position (latitude/longitude) |
|
||||
| `device_tracker.<contact_name>` | 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.<radio_name>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
|
||||
|
||||
| Attribute | Example | Description |
|
||||
|-----------|---------|-------------|
|
||||
@@ -71,7 +93,21 @@ A single `event.meshcore_messages` entity that fires for each message matching y
|
||||
|
||||
## Entity Naming
|
||||
|
||||
Entity IDs use the first 12 characters of the node's public key as an identifier. For example, a contact with public key `ae92577bae6c...` gets entity ID `device_tracker.meshcore_ae92577bae6c`. You can rename entities in HA's UI without affecting the integration.
|
||||
HA auto-generates entity IDs by slugifying the device name and entity name. For a radio named "My Radio", entities look like `binary_sensor.my_radio_connected` and `event.my_radio_messages`. For a repeater named "Hilltop", `sensor.hilltop_battery_voltage`. For a contact named "Alice", `device_tracker.alice`. You can rename entities in HA's UI without affecting the integration.
|
||||
|
||||
MQTT topic paths use the 12-character node ID (first 12 hex characters of the public key). For example:
|
||||
|
||||
- Local radio health: `meshcore/<radio_node_id>/health`
|
||||
- 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
|
||||
|
||||
@@ -79,7 +115,7 @@ Entity IDs use the first 12 characters of the node's public key as an identifier
|
||||
|
||||
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.<repeater_name>_battery_voltage`, below `3.8`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -87,22 +123,22 @@ automation:
|
||||
- alias: "Repeater battery low"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
entity_id: sensor.hilltop_battery_voltage
|
||||
below: 3.8
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
title: "Repeater Battery Low"
|
||||
message: >-
|
||||
{{ state_attr('sensor.meshcore_aabbccddeeff_battery_voltage', 'friendly_name') }}
|
||||
is at {{ states('sensor.meshcore_aabbccddeeff_battery_voltage') }}V
|
||||
{{ state_attr('sensor.hilltop_battery_voltage', 'friendly_name') }}
|
||||
is at {{ states('sensor.hilltop_battery_voltage') }}V
|
||||
```
|
||||
|
||||
### Radio offline alert
|
||||
|
||||
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.<radio_name>_connected`, to `off`, for `00:05:00`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -110,7 +146,7 @@ automation:
|
||||
- alias: "Radio offline"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.meshcore_aabbccddeeff_connected
|
||||
entity_id: binary_sensor.myradio_connected
|
||||
to: "off"
|
||||
for: "00:05:00"
|
||||
action:
|
||||
@@ -128,7 +164,7 @@ Trigger when a message arrives in a specific channel. Two approaches:
|
||||
|
||||
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
|
||||
|
||||
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_messages`, action: notification.
|
||||
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages`, action: notification.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -136,7 +172,7 @@ automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.myradio_messages
|
||||
action:
|
||||
- service: notify.mobile_app_your_phone
|
||||
data:
|
||||
@@ -150,7 +186,7 @@ automation:
|
||||
|
||||
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
|
||||
|
||||
**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.<radio_name>_messages` > Add condition > Template > enter the template below.
|
||||
|
||||
**YAML:**
|
||||
```yaml
|
||||
@@ -158,7 +194,7 @@ automation:
|
||||
- alias: "Emergency channel alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.myradio_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -180,7 +216,7 @@ automation:
|
||||
- alias: "DM from Alice"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.myradio_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -201,7 +237,7 @@ automation:
|
||||
- alias: "Keyword alert"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: event.meshcore_messages
|
||||
entity_id: event.myradio_messages
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
@@ -226,7 +262,7 @@ Add a sensor card to any dashboard:
|
||||
|
||||
```yaml
|
||||
type: sensor
|
||||
entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
entity: sensor.hilltop_battery_voltage
|
||||
name: "Hilltop Repeater Battery"
|
||||
```
|
||||
|
||||
@@ -236,14 +272,171 @@ Or an entities card for multiple repeaters:
|
||||
type: entities
|
||||
title: "Repeater Status"
|
||||
entities:
|
||||
- entity: sensor.meshcore_aabbccddeeff_battery_voltage
|
||||
- entity: sensor.hilltop_battery_voltage
|
||||
name: "Hilltop"
|
||||
- entity: sensor.meshcore_ccdd11223344_battery_voltage
|
||||
- entity: sensor.valley_battery_voltage
|
||||
name: "Valley"
|
||||
- entity: sensor.meshcore_eeff55667788_battery_voltage
|
||||
- entity: sensor.ridge_battery_voltage
|
||||
name: "Ridge"
|
||||
```
|
||||
|
||||
### Full monitoring dashboard with message feed
|
||||
|
||||
This example creates a dashboard with repeater vitals, a live message feed, and a network activity graph. Replace the three slug values below to match your setup — find your entity IDs in **Settings > Devices & Services > MQTT**.
|
||||
|
||||
```yaml
|
||||
# ┌─────────────────────────────────────────────────────┐
|
||||
# │ Replace these three values to match your entities │
|
||||
# │ │
|
||||
# │ radio_slug: the prefix on your radio sensors │
|
||||
# │ e.g. sensor.MYRADIO_noise_floor │
|
||||
# │ repeater_slug: the prefix on your repeater sensors │
|
||||
# │ e.g. sensor.HILLTOP_battery_voltage │
|
||||
# │ message_event: your message event entity ID │
|
||||
# │ e.g. event.MYRADIO_messages │
|
||||
# └─────────────────────────────────────────────────────┘
|
||||
#
|
||||
# radio_slug: myradio
|
||||
# repeater_slug: hilltop
|
||||
# message_event: event.myradio_messages
|
||||
```
|
||||
|
||||
**Step 1 — Dashboard YAML** (Settings > Dashboards > Add > edit in YAML):
|
||||
|
||||
```yaml
|
||||
views:
|
||||
- title: MeshCore
|
||||
icon: mdi:radio-tower
|
||||
cards:
|
||||
- type: entities
|
||||
title: Hilltop — Current # ← repeater name
|
||||
state_color: true
|
||||
entities:
|
||||
- entity: sensor.hilltop_battery_voltage # ← repeater_slug
|
||||
name: Battery
|
||||
- entity: sensor.hilltop_noise_floor # ← repeater_slug
|
||||
name: Noise Floor
|
||||
- entity: sensor.hilltop_last_rssi # ← repeater_slug
|
||||
name: Last RSSI
|
||||
- entity: sensor.hilltop_last_snr # ← repeater_slug
|
||||
name: Last SNR
|
||||
- entity: sensor.hilltop_uptime # ← repeater_slug
|
||||
name: Uptime
|
||||
- entity: sensor.hilltop_packets_received # ← repeater_slug
|
||||
name: Packets Rx
|
||||
- entity: sensor.hilltop_packets_sent # ← repeater_slug
|
||||
name: Packets Tx
|
||||
|
||||
- type: statistics-graph
|
||||
title: Battery Voltage
|
||||
entities:
|
||||
- sensor.hilltop_battery_voltage # ← repeater_slug
|
||||
stat_types: [mean, min, max]
|
||||
days_to_show: 7
|
||||
period: hour
|
||||
|
||||
- type: statistics-graph
|
||||
title: Noise Floor
|
||||
entities:
|
||||
- sensor.hilltop_noise_floor # ← repeater_slug
|
||||
stat_types: [mean, min, max]
|
||||
days_to_show: 7
|
||||
period: hour
|
||||
|
||||
- type: markdown
|
||||
title: Message Feed (Last 10)
|
||||
content: |
|
||||
{% for i in range(1, 11) %}
|
||||
{% set msg = states('input_text.meshcore_msg_' ~ i) %}
|
||||
{% if msg and msg not in ['unknown', '', 'unavailable'] %}
|
||||
{{ msg }}
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if states('input_text.meshcore_msg_1') in ['unknown', '', 'unavailable'] %}
|
||||
*No messages yet.*
|
||||
{% endif %}
|
||||
|
||||
- type: statistics-graph
|
||||
title: Overall Packets Received
|
||||
entities:
|
||||
- sensor.myradio_packets_received # ← radio_slug
|
||||
stat_types: [change]
|
||||
days_to_show: 7
|
||||
period: hour
|
||||
```
|
||||
|
||||
**Step 2 — Message feed helpers**: create 10 text helpers named `MeshCore Msg 1` through `MeshCore Msg 10` (Settings > Helpers > Add > Text). These act as a rolling buffer for the Markdown card above.
|
||||
|
||||
**Step 3 — Message feed automation** (Settings > Automations > Create > edit in YAML):
|
||||
|
||||
```yaml
|
||||
alias: MeshCore Message Feed Buffer
|
||||
description: Rolling buffer of recent mesh messages for dashboard display
|
||||
mode: queued
|
||||
max: 10
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: event.myradio_messages # ← message_event
|
||||
actions:
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_10
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_9') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_9
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_8') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_8
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_7') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_7
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_6') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_6
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_5') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_5
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_4') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_4
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_3') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_3
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_2') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_2
|
||||
data:
|
||||
value: "{{ states('input_text.meshcore_msg_1') }}"
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: input_text.meshcore_msg_1
|
||||
data:
|
||||
value: >-
|
||||
{{ as_timestamp(trigger.to_state.last_changed) |
|
||||
timestamp_custom('%-I:%M %p') }} |
|
||||
**{% if trigger.to_state.attributes.channel_name %}{{
|
||||
trigger.to_state.attributes.channel_name }}{% else %}DM{% endif %}** |
|
||||
{{ trigger.to_state.attributes.sender_name or 'Unknown' }}:
|
||||
{{ (trigger.to_state.attributes.text or '')[:180] }}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Devices don't appear in HA
|
||||
@@ -266,7 +459,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 +475,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).
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
load_with_autoevict: bool = False
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
+42
-19
@@ -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,
|
||||
@@ -435,7 +450,7 @@ def _message_event_discovery_config(
|
||||
device = _device_payload(radio_key, radio_name, "Radio")
|
||||
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
|
||||
cfg: dict[str, Any] = {
|
||||
"name": "MeshCore Messages",
|
||||
"name": "Messages",
|
||||
"unique_id": f"meshcore_{nid}_messages",
|
||||
"device": device,
|
||||
"state_topic": f"{prefix}/{nid}/events/message",
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import logging
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
"""Add muted column to channels table."""
|
||||
table_check = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
|
||||
)
|
||||
if not await table_check.fetchone():
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("PRAGMA table_info(channels)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
if "muted" not in columns:
|
||||
await conn.execute("ALTER TABLE channels ADD COLUMN muted INTEGER DEFAULT 0")
|
||||
|
||||
await conn.commit()
|
||||
@@ -346,6 +346,7 @@ class Channel(BaseModel):
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
favorite: bool = False
|
||||
muted: bool = False
|
||||
|
||||
|
||||
class ChannelMessageCounts(BaseModel):
|
||||
|
||||
@@ -14,6 +14,7 @@ from pywebpush import WebPushException
|
||||
|
||||
from app.push.send import send_push
|
||||
from app.push.vapid import get_vapid_private_key
|
||||
from app.repository.channels import ChannelRepository
|
||||
from app.repository.push_subscriptions import PushSubscriptionRepository
|
||||
from app.repository.settings import AppSettingsRepository
|
||||
|
||||
@@ -102,6 +103,15 @@ class PushManager:
|
||||
if state_key not in push_conversations:
|
||||
return
|
||||
|
||||
# Skip muted channels
|
||||
if data.get("type") == "CHAN" and data.get("conversation_key"):
|
||||
try:
|
||||
ch = await ChannelRepository.get_by_key(data["conversation_key"])
|
||||
if ch and ch.muted:
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("Push dispatch: failed to check channel mute state", exc_info=True)
|
||||
|
||||
try:
|
||||
subs = await PushSubscriptionRepository.get_all()
|
||||
except Exception:
|
||||
|
||||
+309
-83
@@ -43,9 +43,41 @@ from app.websocket import broadcast_error, broadcast_event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CHANNELS = 40
|
||||
_GET_CONTACTS_TIMEOUT = 10
|
||||
|
||||
AdvertMode = Literal["flood", "zero_hop"]
|
||||
|
||||
_AUTO_ADD_OVERWRITE_OLDEST = 0x01
|
||||
_RADIO_CONTACT_FAVORITE = 0x01
|
||||
|
||||
|
||||
async def _enable_autoevict_on_radio(mc: MeshCore) -> bool:
|
||||
"""Ensure the radio's AUTO_ADD_OVERWRITE_OLDEST preference bit is set."""
|
||||
try:
|
||||
current = await mc.commands.get_autoadd_config()
|
||||
if current is None or current.type == EventType.ERROR:
|
||||
logger.warning("Could not read autoadd config from radio: %s", current)
|
||||
return False
|
||||
current_flags = current.payload.get("config", 0)
|
||||
if current_flags & _AUTO_ADD_OVERWRITE_OLDEST:
|
||||
logger.debug("Radio autoevict already enabled (autoadd_config=0x%02x)", current_flags)
|
||||
return True
|
||||
new_flags = current_flags | _AUTO_ADD_OVERWRITE_OLDEST
|
||||
result = await mc.commands.set_autoadd_config(new_flags)
|
||||
if result is not None and result.type == EventType.OK:
|
||||
logger.info(
|
||||
"Enabled radio autoevict (autoadd_config 0x%02x -> 0x%02x)",
|
||||
current_flags,
|
||||
new_flags,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning("Failed to enable radio autoevict: %s", result)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Error enabling radio autoevict: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"""Return key contact fields for sync failure diagnostics."""
|
||||
@@ -239,7 +271,7 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
result = await mc.commands.get_contacts(timeout=_GET_CONTACTS_TIMEOUT)
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||
return False
|
||||
@@ -430,6 +462,16 @@ async def ensure_default_channels() -> None:
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Run fast startup sync, then background contact reconcile."""
|
||||
autoevict_requested = settings.load_with_autoevict
|
||||
autoevict = False
|
||||
|
||||
if autoevict_requested:
|
||||
autoevict = await _enable_autoevict_on_radio(mc)
|
||||
if not autoevict:
|
||||
logger.warning(
|
||||
"Autoevict requested but unavailable; falling back to snapshot-based "
|
||||
"background contact reconcile"
|
||||
)
|
||||
|
||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||
@@ -441,9 +483,25 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
snapshot_failed = "error" in contacts_result
|
||||
if snapshot_failed and not autoevict:
|
||||
logger.warning(
|
||||
"Radio contact snapshot failed — attempting best-effort contact "
|
||||
"loading without a full picture of what's already on the radio"
|
||||
)
|
||||
broadcast_error(
|
||||
"Could not enumerate radio contacts",
|
||||
"Loading favorites and recent contacts on a best-effort basis — "
|
||||
"some adds may be redundant or fail if the radio's contact table "
|
||||
"is already full. Set MESHCORE_LOAD_WITH_AUTOEVICT=true for more "
|
||||
"reliable loading without needing to read the radio first. "
|
||||
"See 'Contact Loading Issues' in the Advanced Setup documentation.",
|
||||
)
|
||||
|
||||
start_background_contact_reconciliation(
|
||||
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||
expected_mc=mc,
|
||||
autoevict=autoevict,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -461,9 +519,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 +912,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 +927,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 +964,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 +974,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 +999,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 +1014,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 +1034,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:
|
||||
@@ -1040,7 +1103,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
synced = 0
|
||||
|
||||
try:
|
||||
result = await mc.commands.get_contacts()
|
||||
result = await mc.commands.get_contacts(timeout=_GET_CONTACTS_TIMEOUT)
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
@@ -1103,12 +1166,24 @@ async def _reconcile_radio_contacts_in_background(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
autoevict: bool = False,
|
||||
) -> None:
|
||||
"""Converge radio contacts toward the desired favorites+recents working set."""
|
||||
"""Converge radio contacts toward the desired favorites+recents working set.
|
||||
|
||||
When *autoevict* is ``True`` the removal phase is skipped entirely and the
|
||||
desired working set is blind-refreshed. Re-adding the full desired list
|
||||
refreshes each contact's recency on supported firmware, so one successful
|
||||
full pass converges the radio toward the desired working set without relying
|
||||
on a stale contact snapshot.
|
||||
"""
|
||||
radio_contacts = dict(initial_radio_contacts)
|
||||
removed = 0
|
||||
loaded = 0
|
||||
failed = 0
|
||||
table_full = False
|
||||
autoevict_next_index = 0
|
||||
autoevict_full_pass_retries = 0
|
||||
_MAX_AUTOEVICT_RETRIES = 3
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -1116,18 +1191,32 @@ async def _reconcile_radio_contacts_in_background(
|
||||
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||
break
|
||||
|
||||
# Pre-lock snapshot for quick-exit checks; authoritative list is
|
||||
# re-fetched inside the radio lock below.
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
desired_fill_contacts = [
|
||||
contact for contact in selected_contacts if len(contact.public_key) >= 64
|
||||
]
|
||||
|
||||
if autoevict:
|
||||
if not desired_fill_contacts:
|
||||
logger.info(
|
||||
"Background contact blind fill complete: no desired contacts selected"
|
||||
)
|
||||
break
|
||||
if autoevict_next_index >= len(desired_fill_contacts):
|
||||
autoevict_next_index = 0
|
||||
desired_contacts = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||
}
|
||||
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
||||
removable_keys = (
|
||||
[] if autoevict else [key for key in radio_contacts if key not in desired_contacts]
|
||||
)
|
||||
missing_contacts = [
|
||||
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||
]
|
||||
|
||||
if not removable_keys and not missing_contacts:
|
||||
if not autoevict and not removable_keys and not missing_contacts:
|
||||
logger.info(
|
||||
"Background contact reconcile complete: %d contacts on radio working set",
|
||||
len(radio_contacts),
|
||||
@@ -1135,6 +1224,8 @@ async def _reconcile_radio_contacts_in_background(
|
||||
break
|
||||
|
||||
progressed = False
|
||||
autoevict_pass_complete = False
|
||||
autoevict_pass_failed = False
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"background_contact_reconcile",
|
||||
@@ -1148,100 +1239,232 @@ async def _reconcile_radio_contacts_in_background(
|
||||
|
||||
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
desired_fill_contacts = [
|
||||
contact for contact in selected_contacts if len(contact.public_key) >= 64
|
||||
]
|
||||
if autoevict and autoevict_next_index >= len(desired_fill_contacts):
|
||||
autoevict_next_index = 0
|
||||
desired_contacts = {
|
||||
contact.public_key.lower(): contact
|
||||
for contact in selected_contacts
|
||||
if len(contact.public_key) >= 64
|
||||
contact.public_key.lower(): contact for contact in desired_fill_contacts
|
||||
}
|
||||
|
||||
for public_key in list(radio_contacts):
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in desired_contacts:
|
||||
continue
|
||||
|
||||
remove_payload = (
|
||||
mc.get_contact_by_key_prefix(public_key[:12])
|
||||
or radio_contacts.get(public_key)
|
||||
or {"public_key": public_key}
|
||||
)
|
||||
try:
|
||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error removing contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if remove_result.type == EventType.OK:
|
||||
radio_contacts.pop(public_key, None)
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
removed += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Failed to remove contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
remove_result.payload,
|
||||
)
|
||||
|
||||
if budget > 0:
|
||||
for public_key, contact in desired_contacts.items():
|
||||
if not autoevict:
|
||||
for public_key in list(radio_contacts):
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in radio_contacts:
|
||||
continue
|
||||
|
||||
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||
radio_contacts[public_key] = {"public_key": public_key}
|
||||
if public_key in desired_contacts:
|
||||
continue
|
||||
|
||||
remove_payload = (
|
||||
mc.get_contact_by_key_prefix(public_key[:12])
|
||||
or radio_contacts.get(public_key)
|
||||
or {"public_key": public_key}
|
||||
)
|
||||
try:
|
||||
add_payload = contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(add_payload)
|
||||
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error adding contact %s during background reconcile: %s",
|
||||
"Error removing contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if add_result.type == EventType.OK:
|
||||
radio_contacts[public_key] = add_payload
|
||||
loaded += 1
|
||||
if remove_result.type == EventType.OK:
|
||||
radio_contacts.pop(public_key, None)
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
removed += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
reason = add_result.payload
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to add contact %s during background reconcile: %s%s",
|
||||
"Failed to remove contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
remove_result.payload,
|
||||
)
|
||||
|
||||
if budget > 0:
|
||||
if autoevict:
|
||||
# Budget is consumed by the slice bound rather than
|
||||
# per-operation decrement — autoevict skips the
|
||||
# removal phase so the full budget is always available.
|
||||
batch_contacts = desired_fill_contacts[
|
||||
autoevict_next_index : autoevict_next_index + budget
|
||||
]
|
||||
processed_contacts = 0
|
||||
for contact in batch_contacts:
|
||||
public_key = contact.public_key.lower()
|
||||
try:
|
||||
add_payload = contact.to_radio_dict()
|
||||
# In autoevict mode, app-loaded contacts should
|
||||
# remain evictable by the radio even if the
|
||||
# stored contact record carries the favorite bit.
|
||||
add_payload["flags"] = (
|
||||
int(add_payload.get("flags", 0)) & ~_RADIO_CONTACT_FAVORITE
|
||||
)
|
||||
add_result = await mc.commands.add_contact(add_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Error blind-filling contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
autoevict_pass_failed = True
|
||||
processed_contacts += 1
|
||||
continue
|
||||
|
||||
if add_result.type == EventType.OK:
|
||||
radio_contacts[public_key] = add_payload
|
||||
loaded += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
autoevict_pass_failed = True
|
||||
reason = add_result.payload
|
||||
if isinstance(reason, dict) and reason.get("error_code") == 3:
|
||||
logger.warning(
|
||||
"Radio contact table full — stopping "
|
||||
"contact reconcile (loaded %d this cycle)",
|
||||
loaded,
|
||||
)
|
||||
table_full = True
|
||||
break
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to blind-fill contact %s during background reconcile: %s%s",
|
||||
public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
)
|
||||
processed_contacts += 1
|
||||
|
||||
autoevict_next_index += processed_contacts
|
||||
autoevict_pass_complete = autoevict_next_index >= len(
|
||||
desired_fill_contacts
|
||||
)
|
||||
else:
|
||||
for public_key, contact in desired_contacts.items():
|
||||
if budget <= 0:
|
||||
break
|
||||
if public_key in radio_contacts:
|
||||
continue
|
||||
|
||||
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||
radio_contacts[public_key] = {"public_key": public_key}
|
||||
continue
|
||||
|
||||
try:
|
||||
add_payload = contact.to_radio_dict()
|
||||
add_result = await mc.commands.add_contact(add_payload)
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
budget -= 1
|
||||
logger.warning(
|
||||
"Error adding contact %s during background reconcile: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
budget -= 1
|
||||
if add_result.type == EventType.OK:
|
||||
radio_contacts[public_key] = add_payload
|
||||
loaded += 1
|
||||
progressed = True
|
||||
else:
|
||||
failed += 1
|
||||
reason = add_result.payload
|
||||
if isinstance(reason, dict) and reason.get("error_code") == 3:
|
||||
logger.warning(
|
||||
"Radio contact table full — stopping "
|
||||
"contact reconcile (loaded %d this cycle)",
|
||||
loaded,
|
||||
)
|
||||
table_full = True
|
||||
break
|
||||
hint = ""
|
||||
if reason is None:
|
||||
hint = (
|
||||
" (no response from radio — if this repeats, check for "
|
||||
"serial port contention from another process or try a "
|
||||
"power cycle)"
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to add contact %s during background reconcile: %s%s",
|
||||
public_key[:12],
|
||||
reason,
|
||||
hint,
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Background contact reconcile yielding: radio busy")
|
||||
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||
continue
|
||||
|
||||
if table_full:
|
||||
if autoevict:
|
||||
logger.error(
|
||||
"We're expecting the radio to be in AUTO_ADD_OVERWRITE_OLDEST mode, "
|
||||
"so a full-table error means we have no idea what is going on with "
|
||||
"this radio; it is misbehaving. You should consider DM auto-acking "
|
||||
"to be unreliable and/or not working for this radio. Sending and "
|
||||
"receiving messages are not impacted by this error unless other "
|
||||
"things are broken on your radio."
|
||||
)
|
||||
broadcast_error(
|
||||
"Could not load all desired contacts onto the radio for auto-DM ack",
|
||||
"Despite having auto-evict enabled, we got a contact-table-full error "
|
||||
"from your radio. DM auto-ack is likely unavailable.",
|
||||
)
|
||||
else:
|
||||
normal_table_full_message = (
|
||||
"The radio's contact table is full. Clearing your radio contacts "
|
||||
"using another client, lowering your contact fill target in "
|
||||
"settings, or setting MESHCORE_LOAD_WITH_AUTOEVICT=true may "
|
||||
"relieve this. See 'Contact Loading Issues' in the Advanced "
|
||||
"README.md"
|
||||
)
|
||||
logger.error(
|
||||
"Contact reconcile hit TABLE_FULL. %s",
|
||||
normal_table_full_message,
|
||||
)
|
||||
broadcast_error(
|
||||
"Could not load all desired contacts onto the radio for auto-DM ack",
|
||||
normal_table_full_message,
|
||||
)
|
||||
break
|
||||
|
||||
if autoevict and autoevict_pass_complete:
|
||||
if autoevict_pass_failed:
|
||||
autoevict_full_pass_retries += 1
|
||||
if autoevict_full_pass_retries >= _MAX_AUTOEVICT_RETRIES:
|
||||
logger.warning(
|
||||
"Background contact blind fill giving up after %d full passes "
|
||||
"with persistent failures (loaded %d, failed %d)",
|
||||
autoevict_full_pass_retries,
|
||||
loaded,
|
||||
failed,
|
||||
)
|
||||
break
|
||||
autoevict_next_index = 0
|
||||
else:
|
||||
logger.info(
|
||||
"Background contact blind fill complete: refreshed %d desired contacts",
|
||||
len(desired_fill_contacts),
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||
if not progressed:
|
||||
continue
|
||||
@@ -1264,6 +1487,7 @@ def start_background_contact_reconciliation(
|
||||
*,
|
||||
initial_radio_contacts: dict[str, dict],
|
||||
expected_mc: MeshCore,
|
||||
autoevict: bool = False,
|
||||
) -> None:
|
||||
"""Start or replace the background contact reconcile task for the current radio."""
|
||||
global _contact_reconcile_task
|
||||
@@ -1275,11 +1499,13 @@ def start_background_contact_reconciliation(
|
||||
_reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts=initial_radio_contacts,
|
||||
expected_mc=expected_mc,
|
||||
autoevict=autoevict,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Started background contact reconcile for %d radio contact(s)",
|
||||
"Started background contact reconcile for %d radio contact(s)%s",
|
||||
len(initial_radio_contacts),
|
||||
" (autoevict mode)" if autoevict else "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class ChannelRepository:
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -45,6 +45,7 @@ class ChannelRepository:
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
muted=bool(row["muted"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -53,7 +54,7 @@ class ChannelRepository:
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -69,6 +70,7 @@ class ChannelRepository:
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
muted=bool(row["muted"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -84,6 +86,17 @@ class ChannelRepository:
|
||||
rowcount = cursor.rowcount
|
||||
return rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def set_muted(key: str, value: bool) -> bool:
|
||||
"""Set or clear the muted flag for a channel. Returns True if row was found."""
|
||||
async with db.tx() as conn:
|
||||
async with conn.execute(
|
||||
"UPDATE channels SET muted = ? WHERE key = ?",
|
||||
(1 if value else 0, key.upper()),
|
||||
) as cursor:
|
||||
rowcount = cursor.rowcount
|
||||
return rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
|
||||
@@ -701,6 +701,7 @@ class MessageRepository:
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
AND COALESCE(c.muted, 0) = 0
|
||||
{blocked_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
|
||||
@@ -94,6 +94,15 @@ class FavoriteToggleResponse(BaseModel):
|
||||
favorite: bool
|
||||
|
||||
|
||||
class MuteChannelRequest(BaseModel):
|
||||
key: str = Field(description="Channel key to toggle mute status")
|
||||
|
||||
|
||||
class MuteChannelToggleResponse(BaseModel):
|
||||
key: str
|
||||
muted: bool
|
||||
|
||||
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
@@ -260,6 +269,25 @@ async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
|
||||
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
||||
|
||||
|
||||
@router.post("/muted-channels/toggle", response_model=MuteChannelToggleResponse)
|
||||
async def toggle_muted_channel(request: MuteChannelRequest) -> MuteChannelToggleResponse:
|
||||
"""Toggle a channel's muted status."""
|
||||
channel = await ChannelRepository.get_by_key(request.key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
new_value = not channel.muted
|
||||
await ChannelRepository.set_muted(request.key, new_value)
|
||||
logger.info("%s channel mute: %s", "Muted" if new_value else "Unmuted", request.key[:12])
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(request.key)
|
||||
if refreshed:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
|
||||
return MuteChannelToggleResponse(key=request.key, muted=new_value)
|
||||
|
||||
|
||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
|
||||
"""Toggle a public key's blocked status."""
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Generated
+186
-1194
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.11.3",
|
||||
"version": "3.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -66,7 +66,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.19.0",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.0"
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -25,7 +25,13 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import type {
|
||||
BulkCreateHashtagChannelsResult,
|
||||
Channel,
|
||||
Conversation,
|
||||
Message,
|
||||
RawPacket,
|
||||
} from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
@@ -207,6 +213,12 @@ export function App() {
|
||||
removeConversationMessagesRef.current(conversationId),
|
||||
});
|
||||
|
||||
// Keep channels in a ref for WS callback mute filtering
|
||||
const channelsRef = useRef<Channel[]>([]);
|
||||
useEffect(() => {
|
||||
channelsRef.current = channels;
|
||||
}, [channels]);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string) => {
|
||||
// Optimistically toggle the favorite flag
|
||||
@@ -343,6 +355,20 @@ export function App() {
|
||||
useFaviconBadge(unreadCounts, mentions, channels);
|
||||
useUnreadTitle(unreadCounts, contacts, channels);
|
||||
|
||||
const handleToggleMute = useCallback(
|
||||
async (key: string) => {
|
||||
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
|
||||
try {
|
||||
await api.toggleChannelMute(key);
|
||||
await refreshUnreads();
|
||||
} catch {
|
||||
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
|
||||
toast.error('Failed to update mute');
|
||||
}
|
||||
},
|
||||
[setChannels, refreshUnreads]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
setChannelUnreadMarker(null);
|
||||
@@ -408,6 +434,7 @@ export function App() {
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
channelsRef,
|
||||
activeConversationRef,
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
@@ -586,6 +613,7 @@ export function App() {
|
||||
onRunTracePath: api.requestRadioTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onToggleMute: handleToggleMute,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||
|
||||
@@ -343,6 +343,12 @@ export const api = {
|
||||
body: JSON.stringify({ type, id }),
|
||||
}),
|
||||
|
||||
toggleChannelMute: (key: string) =>
|
||||
fetchJson<{ key: string; muted: boolean }>('/settings/muted-channels/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
|
||||
// Fanout
|
||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||
createFanoutConfig: (config: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, BellOff, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
@@ -32,6 +32,7 @@ interface ChatHeaderProps {
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onToggleMute?: (key: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
@@ -57,6 +58,7 @@ export function ChatHeader({
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
onToggleFavorite,
|
||||
onToggleMute,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onDeleteChannel,
|
||||
@@ -313,95 +315,125 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
|
||||
<div className="relative" ref={notifDropdownRef}>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||
title="Notification settings"
|
||||
aria-label="Notification settings"
|
||||
aria-expanded={notifDropdownOpen}
|
||||
>
|
||||
<Bell
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
notificationsEnabled || pushEnabledForConversation
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
{(notificationsSupported ||
|
||||
pushSupported ||
|
||||
(conversation.type === 'channel' && onToggleMute)) &&
|
||||
!activeContactIsRoomServer && (
|
||||
<div className="relative" ref={notifDropdownRef}>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||
title="Notification settings"
|
||||
aria-label="Notification settings"
|
||||
aria-expanded={notifDropdownOpen}
|
||||
>
|
||||
{activeChannel?.muted ? (
|
||||
<BellOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
) : (
|
||||
<Bell
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
notificationsEnabled || pushEnabledForConversation
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
fill={
|
||||
notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{notifDropdownOpen && (
|
||||
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
||||
{notificationsSupported && (
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={notificationsEnabled}
|
||||
disabled={notificationsPermission === 'denied'}
|
||||
onChange={onToggleNotifications}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Desktop notifications (legacy)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{notificationsPermission === 'denied'
|
||||
? 'Blocked by browser — check site permissions'
|
||||
: 'Alerts while this tab is open'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{pushSupported && onTogglePush && (
|
||||
<>
|
||||
</button>
|
||||
{notifDropdownOpen && (
|
||||
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
||||
{notificationsSupported && (
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={!!pushEnabledForConversation}
|
||||
onChange={onTogglePush}
|
||||
checked={notificationsEnabled}
|
||||
disabled={notificationsPermission === 'denied'}
|
||||
onChange={onToggleNotifications}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Web Push (beta testing)
|
||||
Desktop notifications (legacy)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{pushSubscribed
|
||||
? 'Alerts even when the browser is closed'
|
||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||
{notificationsPermission === 'denied'
|
||||
? 'Blocked by browser — check site permissions'
|
||||
: 'Alerts while this tab is open'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
All notification types require a trusted HTTPS context. Depending on your
|
||||
browser, a snakeoil certificate may not be sufficient.
|
||||
</span>
|
||||
{onOpenPushSettings && (
|
||||
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
||||
Manage Web Push enabled devices in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotifDropdownOpen(false);
|
||||
onOpenPushSettings();
|
||||
}}
|
||||
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
Settings → Local
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{pushSupported && onTogglePush && (
|
||||
<>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={!!pushEnabledForConversation}
|
||||
onChange={onTogglePush}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Web Push (beta testing)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{pushSubscribed
|
||||
? 'Alerts even when the browser is closed'
|
||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
All notification types require a trusted HTTPS context. Depending on your
|
||||
browser, a snakeoil certificate may not be sufficient.
|
||||
</span>
|
||||
{onOpenPushSettings && (
|
||||
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
||||
Manage Web Push enabled devices in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotifDropdownOpen(false);
|
||||
onOpenPushSettings();
|
||||
}}
|
||||
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
Settings → Local
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{conversation.type === 'channel' && onToggleMute && (
|
||||
<>
|
||||
<hr className="border-border" />
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={!!activeChannel?.muted}
|
||||
onChange={() => onToggleMute(conversation.id)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Mute channel
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
Hide unread counts and suppress all notifications
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ interface ConversationPaneProps {
|
||||
) => Promise<RadioTraceResponse>;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onToggleMute: (key: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
@@ -103,17 +104,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>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +144,7 @@ export function ConversationPane({
|
||||
onRunTracePath,
|
||||
onPathDiscovery,
|
||||
onToggleFavorite,
|
||||
onToggleMute,
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
@@ -307,6 +309,7 @@ export function ConversationPane({
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleMute={onToggleMute}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Cable,
|
||||
ChartNetwork,
|
||||
CheckCheck,
|
||||
@@ -49,6 +50,7 @@ type ConversationRow = {
|
||||
unreadCount: number;
|
||||
isMention: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
muted?: boolean;
|
||||
contact?: Contact;
|
||||
};
|
||||
|
||||
@@ -250,6 +252,10 @@ export function Sidebar({
|
||||
if (isPublicChannelKey(a.key)) return -1;
|
||||
if (isPublicChannelKey(b.key)) return 1;
|
||||
|
||||
// Muted channels always sort to the bottom
|
||||
if (a.muted && !b.muted) return 1;
|
||||
if (!a.muted && b.muted) return -1;
|
||||
|
||||
if (sectionSortOrders.channels === 'recent') {
|
||||
const timeA = getLastMessageTime('channel', a.key);
|
||||
const timeB = getLastMessageTime('channel', b.key);
|
||||
@@ -530,9 +536,10 @@ export function Sidebar({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
unreadCount: getUnreadCount('channel', channel.key),
|
||||
isMention: hasMention('channel', channel.key),
|
||||
unreadCount: channel.muted ? 0 : getUnreadCount('channel', channel.key),
|
||||
isMention: channel.muted ? false : hasMention('channel', channel.key),
|
||||
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
||||
muted: channel.muted,
|
||||
});
|
||||
|
||||
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
||||
@@ -584,23 +591,31 @@ export function Sidebar({
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{row.muted ? (
|
||||
<span aria-label="Channel muted" title="Channel muted">
|
||||
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
) : (
|
||||
<>
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</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
|
||||
|
||||
@@ -35,6 +35,7 @@ interface UseRealtimeAppStateArgs {
|
||||
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
||||
blockedKeysRef: MutableRefObject<string[]>;
|
||||
blockedNamesRef: MutableRefObject<string[]>;
|
||||
channelsRef: MutableRefObject<Channel[]>;
|
||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||
recordMessageEvent: (args: {
|
||||
@@ -94,6 +95,7 @@ export function useRealtimeAppState({
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
channelsRef,
|
||||
activeConversationRef,
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
@@ -191,16 +193,24 @@ export function useRealtimeAppState({
|
||||
return;
|
||||
}
|
||||
|
||||
const isMutedChannel =
|
||||
msg.type === 'CHAN' &&
|
||||
!!msg.conversation_key &&
|
||||
channelsRef.current.some((c) => c.key === msg.conversation_key && c.muted);
|
||||
|
||||
const { added: isNewMessage, activeConversation: isForActiveConversation } =
|
||||
observeMessage(msg);
|
||||
recordMessageEvent({
|
||||
msg,
|
||||
activeConversation: isForActiveConversation,
|
||||
isNewMessage,
|
||||
hasMention: checkMention(msg.text),
|
||||
});
|
||||
|
||||
if (!msg.outgoing && isNewMessage) {
|
||||
if (!isMutedChannel) {
|
||||
recordMessageEvent({
|
||||
msg,
|
||||
activeConversation: isForActiveConversation,
|
||||
isNewMessage,
|
||||
hasMention: checkMention(msg.text),
|
||||
});
|
||||
}
|
||||
|
||||
if (!msg.outgoing && isNewMessage && !isMutedChannel) {
|
||||
notifyIncomingMessage?.(msg);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
});
|
||||
|
||||
function installMockFetch() {
|
||||
mockFetch.mockReset();
|
||||
global.fetch = mockFetch;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
@@ -26,6 +27,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
@@ -43,6 +45,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,15 @@ import { api } from '../api';
|
||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
is_hashtag: isHashtag,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(channel: Channel): ChannelDetail {
|
||||
|
||||
@@ -7,7 +7,15 @@ import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
is_hashtag: isHashtag,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
@@ -90,6 +90,7 @@ const channel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
@@ -142,6 +143,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
throw new Error('unused');
|
||||
}),
|
||||
onToggleFavorite: vi.fn(async () => {}),
|
||||
onToggleMute: vi.fn(async () => {}),
|
||||
onDeleteContact: vi.fn(async () => {}),
|
||||
onDeleteChannel: vi.fn(async () => {}),
|
||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||
@@ -379,7 +381,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 +418,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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const BOT_CHANNEL: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
|
||||
@@ -15,6 +15,7 @@ const TEST_CHANNEL: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
|
||||
@@ -42,6 +42,7 @@ const defaultProps = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
on_radio: true,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
key: '11111111111111111111111111111111',
|
||||
@@ -202,6 +203,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
key: '22222222222222222222222222222222',
|
||||
@@ -210,6 +212,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ describe('useContactsAndChannels', () => {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
existing_count: 1,
|
||||
|
||||
@@ -34,6 +34,7 @@ const publicChannel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const sentMessage: Message = {
|
||||
|
||||
@@ -11,6 +11,7 @@ const publicChannel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
||||
|
||||
@@ -14,7 +14,15 @@ import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
function makeChannel(key: string, favorite = false): Channel {
|
||||
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
|
||||
return {
|
||||
key,
|
||||
name: key,
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContact(publicKey: string, favorite = false): Contact {
|
||||
|
||||
@@ -29,6 +29,7 @@ const publicChannel: Channel = {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const incomingDm: Message = {
|
||||
@@ -65,6 +66,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
||||
setContacts,
|
||||
blockedKeysRef: { current: [] as string[] },
|
||||
channelsRef: { current: [publicChannel] },
|
||||
blockedNamesRef: { current: [] as string[] },
|
||||
activeConversationRef: { current: null as Conversation | null },
|
||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||
|
||||
@@ -36,6 +36,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ export interface Channel {
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
favorite: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelMessageCounts {
|
||||
|
||||
+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"]
|
||||
|
||||
@@ -81,7 +81,8 @@ echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -ne "${BLUE}[build]${NC} "
|
||||
cd "$REPO_ROOT/frontend"
|
||||
npx --quiet tsc 2>&1 && npx --quiet vite build --logLevel error 2>&1
|
||||
npx --quiet tsc 2>&1
|
||||
npx --quiet vite build --logLevel error 2>&1
|
||||
echo -e "${GREEN}Passed!${NC}"
|
||||
|
||||
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||
# change, not every individual assertion.
|
||||
LATEST_SCHEMA_VERSION = 58
|
||||
LATEST_SCHEMA_VERSION = 59
|
||||
|
||||
@@ -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):
|
||||
|
||||
+343
-4
@@ -15,6 +15,7 @@ from meshcore.events import Event
|
||||
import app.radio_sync as radio_sync
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.radio_sync import (
|
||||
_enable_autoevict_on_radio,
|
||||
_message_poll_loop,
|
||||
_periodic_advert_loop,
|
||||
_periodic_sync_loop,
|
||||
@@ -76,6 +77,7 @@ async def _insert_contact(
|
||||
name="Alice",
|
||||
on_radio=False,
|
||||
contact_type=0,
|
||||
flags=0,
|
||||
last_contacted=None,
|
||||
last_advert=None,
|
||||
direct_path=None,
|
||||
@@ -88,7 +90,7 @@ async def _insert_contact(
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"flags": 0,
|
||||
"flags": flags,
|
||||
"direct_path": direct_path,
|
||||
"direct_path_len": direct_path_len,
|
||||
"direct_path_hash_mode": direct_path_hash_mode,
|
||||
@@ -353,6 +355,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."""
|
||||
@@ -490,10 +518,101 @@ class TestSyncAndOffloadAll:
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
mock_start.assert_called_once_with(
|
||||
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
|
||||
initial_radio_contacts=radio_contacts, expected_mc=mock_mc, autoevict=False
|
||||
)
|
||||
assert result["contact_reconcile_started"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_snapshot_reconcile_when_autoevict_enable_fails(self, test_db):
|
||||
mock_mc = MagicMock()
|
||||
radio_contacts = {KEY_A: {"public_key": KEY_A}}
|
||||
|
||||
with (
|
||||
patch.object(radio_sync.settings, "load_with_autoevict", True),
|
||||
patch(
|
||||
"app.radio_sync._enable_autoevict_on_radio",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||
):
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
mock_start.assert_called_once_with(
|
||||
initial_radio_contacts=radio_contacts,
|
||||
expected_mc=mock_mc,
|
||||
autoevict=False,
|
||||
)
|
||||
assert result["contact_reconcile_started"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autoevict_success_passes_flag_to_reconcile(self, test_db):
|
||||
mock_mc = MagicMock()
|
||||
radio_contacts = {KEY_A: {"public_key": KEY_A}}
|
||||
|
||||
with (
|
||||
patch.object(radio_sync.settings, "load_with_autoevict", True),
|
||||
patch(
|
||||
"app.radio_sync._enable_autoevict_on_radio",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||
):
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
mock_start.assert_called_once_with(
|
||||
initial_radio_contacts=radio_contacts,
|
||||
expected_mc=mock_mc,
|
||||
autoevict=True,
|
||||
)
|
||||
assert result["contact_reconcile_started"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_best_effort_reconcile_when_snapshot_fails(self, test_db):
|
||||
"""When sync_contacts_from_radio errors, reconcile still starts with empty snapshot."""
|
||||
mock_mc = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.sync_contacts_from_radio",
|
||||
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}, "error": "timeout"}),
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.sync_and_offload_channels",
|
||||
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
|
||||
),
|
||||
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
|
||||
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
|
||||
patch("app.radio_sync.broadcast_error") as mock_broadcast,
|
||||
):
|
||||
result = await sync_and_offload_all(mock_mc)
|
||||
|
||||
mock_start.assert_called_once_with(
|
||||
initial_radio_contacts={},
|
||||
expected_mc=mock_mc,
|
||||
autoevict=False,
|
||||
)
|
||||
assert result["contact_reconcile_started"] is True
|
||||
mock_broadcast.assert_called_once()
|
||||
assert "best-effort" in mock_broadcast.call_args.args[1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advert_fill_skips_repeaters(self, test_db):
|
||||
"""Recent advert fallback only considers non-repeaters."""
|
||||
@@ -772,6 +891,81 @@ class TestSyncAndOffloadAll:
|
||||
assert payload["public_key"] == KEY_A
|
||||
|
||||
|
||||
class TestEnableAutoevictOnRadio:
|
||||
"""Test _enable_autoevict_on_radio read-modify-write flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sets_flag_when_not_already_set(self):
|
||||
mc = MagicMock()
|
||||
mc.commands.get_autoadd_config = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={"config": 0x00})
|
||||
)
|
||||
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
|
||||
result = await _enable_autoevict_on_radio(mc)
|
||||
|
||||
assert result is True
|
||||
mc.commands.set_autoadd_config.assert_awaited_once_with(0x01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_when_already_enabled(self):
|
||||
mc = MagicMock()
|
||||
mc.commands.get_autoadd_config = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={"config": 0x01})
|
||||
)
|
||||
mc.commands.set_autoadd_config = AsyncMock()
|
||||
|
||||
result = await _enable_autoevict_on_radio(mc)
|
||||
|
||||
assert result is True
|
||||
mc.commands.set_autoadd_config.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_other_flags(self):
|
||||
mc = MagicMock()
|
||||
mc.commands.get_autoadd_config = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={"config": 0x04})
|
||||
)
|
||||
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
|
||||
result = await _enable_autoevict_on_radio(mc)
|
||||
|
||||
assert result is True
|
||||
mc.commands.set_autoadd_config.assert_awaited_once_with(0x05)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_get_error(self):
|
||||
mc = MagicMock()
|
||||
mc.commands.get_autoadd_config = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.ERROR, payload=None)
|
||||
)
|
||||
|
||||
result = await _enable_autoevict_on_radio(mc)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_set_failure(self):
|
||||
mc = MagicMock()
|
||||
mc.commands.get_autoadd_config = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={"config": 0x00})
|
||||
)
|
||||
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.ERROR))
|
||||
|
||||
result = await _enable_autoevict_on_radio(mc)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_exception(self):
|
||||
mc = MagicMock()
|
||||
mc.commands.get_autoadd_config = AsyncMock(side_effect=RuntimeError("timeout"))
|
||||
|
||||
result = await _enable_autoevict_on_radio(mc)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestBackgroundContactReconcile:
|
||||
"""Test the yielding background contact reconcile loop."""
|
||||
|
||||
@@ -818,6 +1012,151 @@ class TestBackgroundContactReconcile:
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["public_key"] == KEY_B
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autoevict_blind_fill_readds_full_desired_set(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", flags=0x01, last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
alice = await ContactRepository.get_by_key(KEY_A)
|
||||
bob = await ContactRepository.get_by_key(KEY_B)
|
||||
assert alice is not None
|
||||
assert bob is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK))
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
yield mock_mc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
|
||||
patch(
|
||||
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||
side_effect=[[alice, bob], [alice, bob]],
|
||||
),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={KEY_A: {"public_key": KEY_A}},
|
||||
expected_mc=mock_mc,
|
||||
autoevict=True,
|
||||
)
|
||||
|
||||
mock_mc.commands.remove_contact.assert_not_called()
|
||||
assert mock_mc.commands.add_contact.await_count == 2
|
||||
loaded_keys = [
|
||||
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
|
||||
]
|
||||
assert loaded_keys == [KEY_A, KEY_B]
|
||||
loaded_flags = [
|
||||
call.args[0]["flags"] for call in mock_mc.commands.add_contact.call_args_list
|
||||
]
|
||||
assert loaded_flags == [0, 0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autoevict_table_full_breaks_with_error(self, test_db):
|
||||
"""TABLE_FULL during autoevict stops the loop and broadcasts an error."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
alice = await ContactRepository.get_by_key(KEY_A)
|
||||
assert alice is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
table_full_result = MagicMock(type=EventType.ERROR, payload={"error_code": 3})
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=table_full_result)
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
yield mock_mc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
|
||||
patch(
|
||||
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||
side_effect=[[alice], [alice]],
|
||||
),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
patch("app.radio_sync.broadcast_error") as mock_broadcast,
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={},
|
||||
expected_mc=mock_mc,
|
||||
autoevict=True,
|
||||
)
|
||||
|
||||
mock_broadcast.assert_called_once()
|
||||
assert "auto-evict" in mock_broadcast.call_args.args[1].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autoevict_retry_cap_stops_after_max_retries(self, test_db):
|
||||
"""Autoevict gives up after _MAX_AUTOEVICT_RETRIES full passes with failures."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
alice = await ContactRepository.get_by_key(KEY_A)
|
||||
assert alice is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
# Every add fails with a non-TABLE_FULL error
|
||||
fail_result = MagicMock(type=EventType.ERROR, payload={"error_code": 99})
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=fail_result)
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def _radio_operation(*args, **kwargs):
|
||||
del args, kwargs
|
||||
yield mock_mc
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _get_selected():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return [alice]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
radio_sync.radio_manager,
|
||||
"radio_operation",
|
||||
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
|
||||
),
|
||||
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
|
||||
patch(
|
||||
"app.radio_sync.get_contacts_selected_for_radio_sync",
|
||||
side_effect=_get_selected,
|
||||
),
|
||||
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await radio_sync._reconcile_radio_contacts_in_background(
|
||||
initial_radio_contacts={},
|
||||
expected_mc=mock_mc,
|
||||
autoevict=True,
|
||||
)
|
||||
|
||||
# 2 calls per iteration (pre-lock + in-lock), 3 retries = 6 calls,
|
||||
# plus 1 pre-lock call on the initial iteration = at most 8.
|
||||
# The key assertion: it terminates rather than looping forever.
|
||||
assert mock_mc.commands.add_contact.await_count <= 4
|
||||
assert call_count <= 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=3000)
|
||||
@@ -1686,7 +2025,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 +2049,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