Compare commits

..

13 Commits

Author SHA1 Message Date
jkingsman c8c8e6b549 Add channel mute 2026-04-19 19:31:26 -07:00
Jack Kingsman 491f159463 Merge pull request #205 from jkingsman/dependabot/npm_and_yarn/frontend/npm_and_yarn-916abd5bfa
Bump the npm_and_yarn group across 1 directory with 4 updates
2026-04-19 18:43:06 -07:00
jkingsman ead74e975b Update tests for vitest bump 2026-04-19 18:36:13 -07:00
dependabot[bot] 4fbd245ee4 Bump the npm_and_yarn group across 1 directory with 4 updates
Bumps the npm_and_yarn group with 3 updates in the /frontend directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [flatted](https://github.com/WebReflection/flatted) and [picomatch](https://github.com/micromatch/picomatch).


Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

Updates `esbuild` from 0.21.5 to 0.25.12
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.12)

Updates `flatted` from 3.4.0 to 3.4.2
- [Commits](https://github.com/WebReflection/flatted/compare/v3.4.0...v3.4.2)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 0.25.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 01:15:37 +00:00
Jack Kingsman dc7ec13cc5 Instructions for full monitoring feed 2026-04-19 16:25:18 -07:00
Jack Kingsman cfa2bf575c Correct HA documentation to use the actual node name 2026-04-19 15:11:25 -07:00
Jack Kingsman e9ef68432a Make caps consistent 2026-04-19 14:51:09 -07:00
Jack Kingsman 476adf393f Merge pull request #204 from jkingsman/extended-contact-fetch-timeout
Work better with radios that are flakey around providing current contact load state (BLE?)
2026-04-19 14:12:35 -07:00
Jack Kingsman f7a311d74b Always clear fav flag on a blind load 2026-04-19 01:25:08 -07:00
Jack Kingsman 09f807230b Patch up some vagaries and maintain best-effort loading. 2026-04-19 00:51:45 -07:00
Jack Kingsman c098f9eeb5 Be better about blind loading/auto-evict logging and run-through 2026-04-19 00:27:53 -07:00
Jack Kingsman 05493d06fc Extend contact read timeouts and add circular load/autoevict load mode 2026-04-18 21:06:27 -07:00
Jack Kingsman 6c1b8bd7e9 Phrasing fixups 2026-04-17 12:49:56 -07:00
43 changed files with 1347 additions and 1425 deletions
+1
View File
@@ -503,6 +503,7 @@ 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`, `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`.
+3 -2
View File
@@ -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
+24
View File
@@ -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.
+194 -39
View File
@@ -21,25 +21,23 @@ Devices will appear in HA under **Settings > Devices & Services > MQTT** within
## How MeshCore IDs Map Into Home Assistant
RemoteTerm uses each node's public key to derive a stable short identifier:
RemoteTerm uses each node's public key to derive a stable short identifier for MQTT topics:
- Full public key: `ae92577bae6c4f1d...`
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
- Example runtime topic: `meshcore/ae92577bae6c/gps`
- Example MQTT topic: `meshcore/ae92577bae6c/gps`
When this README shows `<node_id>`, it always means that 12-character value.
When this README shows `<node_id>`, it always means that 12-character value. Node IDs appear in:
The same node ID appears in:
- Home Assistant entity IDs
- Home Assistant discovery topics under `homeassistant/...`
- MQTT discovery topics under `homeassistant/...`
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
You can also see these IDs in RemoteTerm's Home Assistant integration UI:
**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`
- `Published topic summary`
## What Gets Created
@@ -49,8 +47,8 @@ Always created. Updates every 60 seconds.
| Entity | Type | Description |
|--------|------|-------------|
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
| `sensor.meshcore_<radio_node_id>_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
@@ -60,13 +58,13 @@ Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm'
| Entity | Type | Unit | Description |
|--------|------|------|-------------|
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
| `sensor.meshcore_<repeater_node_id>_uptime` | Duration | s | Uptime since last reboot |
| `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.
@@ -76,11 +74,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
| Entity | Description |
|--------|-------------|
| `device_tracker.meshcore_<contact_node_id>` | GPS position (latitude/longitude) |
| `device_tracker.<contact_name>` | GPS position (latitude/longitude) |
### Message Event Entity
A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
A single radio-scoped event entity, `event.<radio_name>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
| Attribute | Example | Description |
|-----------|---------|-------------|
@@ -95,9 +93,9 @@ A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, f
## Entity Naming
Entity IDs use the first 12 characters of the node's public key as an identifier. For example, a contact with public key `ae92577bae6c...` gets entity ID `device_tracker.meshcore_ae92577bae6c`. You can rename entities in HA's UI without affecting the integration.
HA auto-generates entity IDs by slugifying the device name and entity name. For a radio named "My Radio", entities look like `binary_sensor.my_radio_connected` and `event.my_radio_messages`. For a repeater named "Hilltop", `sensor.hilltop_battery_voltage`. For a contact named "Alice", `device_tracker.alice`. You can rename entities in HA's UI without affecting the integration.
That same 12-character node ID is also used in the MQTT topic paths. For example:
MQTT topic paths use the 12-character node ID (first 12 hex characters of the public key). For example:
- Local radio health: `meshcore/<radio_node_id>/health`
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
@@ -117,7 +115,7 @@ That same 12-character node ID is also used in the MQTT topic paths. For example
Notify when a tracked repeater's battery drops below a threshold.
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_<repeater_node_id>_battery_voltage`, below `3.8`, action: notification.
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.<repeater_name>_battery_voltage`, below `3.8`, action: notification.
**YAML:**
```yaml
@@ -125,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_<radio_node_id>_connected`, to `off`, for `00:05:00`, action: notification.
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.<radio_name>_connected`, to `off`, for `00:05:00`, action: notification.
**YAML:**
```yaml
@@ -148,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:
@@ -166,7 +164,7 @@ Trigger when a message arrives in a specific channel. Two approaches:
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages`, action: notification.
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages`, action: notification.
**YAML:**
```yaml
@@ -174,7 +172,7 @@ automation:
- alias: "Emergency channel alert"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
action:
- service: notify.mobile_app_your_phone
data:
@@ -188,7 +186,7 @@ automation:
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages` > Add condition > Template > enter the template below.
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages` > Add condition > Template > enter the template below.
**YAML:**
```yaml
@@ -196,7 +194,7 @@ automation:
- alias: "Emergency channel alert"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
condition:
- condition: template
value_template: >-
@@ -218,7 +216,7 @@ automation:
- alias: "DM from Alice"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
condition:
- condition: template
value_template: >-
@@ -239,7 +237,7 @@ automation:
- alias: "Keyword alert"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
condition:
- condition: template
value_template: >-
@@ -264,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"
```
@@ -274,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
+1
View File
@@ -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 = ""
+1 -1
View File
@@ -450,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",
+23
View File
@@ -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()
+1
View File
@@ -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):
+10
View File
@@ -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:
+295 -74
View File
@@ -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 {
@@ -1045,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(
@@ -1108,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:
@@ -1121,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),
@@ -1140,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",
@@ -1153,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
@@ -1269,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
@@ -1280,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 "",
)
+15 -2
View File
@@ -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."""
+1
View File
@@ -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
""",
+28
View File
@@ -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."""
+186 -1194
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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: {
+109 -77
View File
@@ -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 &rarr; 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 &rarr; 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"
@@ -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>;
@@ -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}
+32 -17
View File
@@ -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>
@@ -1194,7 +1194,7 @@ function MqttHaConfigEditor({
<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
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">
+17 -7
View File
@@ -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);
}
},
+1
View File
@@ -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,
@@ -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 () => {}),
+1 -1
View File
@@ -1057,7 +1057,7 @@ describe('SettingsFanoutSection', () => {
selectCreateIntegration('Home Assistant MQTT Discovery');
confirmCreateIntegration();
expect(await screen.findByText('Published Topic Summary')).toBeInTheDocument();
expect(await screen.findByText('Published topic summary')).toBeInTheDocument();
fireEvent.click(await screen.findByLabelText(/Alice/));
fireEvent.click(await screen.findByLabelText(/Repeater One/));
@@ -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 = {
+1
View File
@@ -42,6 +42,7 @@ const defaultProps = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
],
onNavigateToMessage: vi.fn(),
+1
View File
@@ -14,6 +14,7 @@ function makeChannel(key: string, name: string): Channel {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
}
+3
View File
@@ -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]> = {}) {
+9 -1
View File
@@ -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,
};
}
+1
View File
@@ -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 {
+2 -1
View File
@@ -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}"
+1 -1
View File
@@ -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
+315 -2
View File
@@ -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,
@@ -516,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."""
@@ -798,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."""
@@ -844,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)