Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Meshflow/common/mc_channel_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ def _scope_suffix(channel: MessageChannel) -> str:
return ""


def mc_channel_mirror_label(channel: MessageChannel) -> str:
"""Name/tag for feeder mirror tables where region_scope has its own column."""
if channel.mc_channel_type == MeshCoreChannelType.HASHTAG:
tag = (channel.name or "").strip().lstrip("#")
if tag:
return f"#{tag}"
name = (channel.name or "").strip()
if name:
return name
return str(channel.pk)


def mc_channel_scope_display(channel: MessageChannel) -> str:
return channel.region_scope or "—"


def mc_channel_admin_label(channel: MessageChannel) -> str:
"""Human label for admin lists: #hashtag for HASHTAG, plain name for PUBLIC."""
if channel.mc_channel_type == MeshCoreChannelType.HASHTAG:
Expand Down
18 changes: 18 additions & 0 deletions Meshflow/common/tests/test_mc_channel_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from common.mc_channel_labels import (
mc_channel_admin_label,
mc_channel_mirror_label,
mc_channel_scope_display,
message_channel_to_apply_entry,
)
from common.protocol import Protocol
Expand Down Expand Up @@ -47,6 +49,22 @@ def test_mc_channel_admin_label_hashtag_with_scope(create_constellation):
assert mc_channel_admin_label(ch) == "#galloway · sample-west"


@pytest.mark.django_db
def test_mc_channel_mirror_label_and_scope_display(create_constellation):
constellation = create_constellation()
ch = MessageChannel.objects.create(
name="galloway",
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.HASHTAG,
region_scope="sample-west",
)
assert mc_channel_mirror_label(ch) == "#galloway"
assert mc_channel_scope_display(ch) == "sample-west"
ch.region_scope = None
assert mc_channel_scope_display(ch) == "—"


@pytest.mark.django_db
def test_message_channel_to_apply_entry_hashtag(create_constellation):
constellation = create_constellation()
Expand Down
35 changes: 35 additions & 0 deletions Meshflow/constellations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@

from .models import Constellation, MeshCoreMessageChannel, MessageChannel

_SCOPE_FILTER_NONE = "__none__"
_SCOPE_FILTER_ANY = "__any__"


class MeshCoreRegionScopeFilter(admin.SimpleListFilter):
"""Filter MeshCore channels by region_scope (null, any, or a specific scope)."""

title = _("Region scope")
parameter_name = "region_scope"

def lookups(self, request, model_admin):
choices = [
(_SCOPE_FILTER_NONE, _("No scope")),
(_SCOPE_FILTER_ANY, _("Has scope")),
]
qs = model_admin.get_queryset(request).filter(region_scope__isnull=False)
scopes = qs.values_list("region_scope", flat=True).distinct().order_by("region_scope")
choices.extend((scope, scope) for scope in scopes if scope)
return choices

def queryset(self, request, queryset):
if self.value() == _SCOPE_FILTER_NONE:
return queryset.filter(region_scope__isnull=True)
if self.value() == _SCOPE_FILTER_ANY:
return queryset.filter(region_scope__isnull=False)
if self.value():
return queryset.filter(region_scope=self.value())
return queryset


class ConstellationAdminForm(forms.ModelForm):
class Meta:
Expand Down Expand Up @@ -109,11 +138,13 @@ class MeshCoreMessageChannelAdmin(admin.ModelAdmin):
list_display = (
"admin_label",
"mc_channel_type_display",
"region_scope_display",
"constellation",
"id",
)
list_filter = (
("mc_channel_type", admin.ChoicesFieldListFilter),
MeshCoreRegionScopeFilter,
"constellation",
)
search_fields = ("name", "region_scope", "constellation__name")
Expand Down Expand Up @@ -147,3 +178,7 @@ def admin_label(self, obj):
@admin.display(description=_("Type"), ordering="mc_channel_type")
def mc_channel_type_display(self, obj):
return mc_channel_type_name(obj)

@admin.display(description=_("Scope"), ordering="region_scope")
def region_scope_display(self, obj):
return obj.region_scope or "—"
11 changes: 7 additions & 4 deletions Meshflow/nodes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from common.mc_channel_labels import (
managed_node_mc_channel_links,
managed_node_mc_channels_queryset,
mc_channel_admin_label,
mc_channel_mirror_label,
mc_channel_scope_display,
mc_channel_type_name,
)
from common.mesh_node_helpers import (
Expand Down Expand Up @@ -572,20 +573,22 @@ def mc_channels_mirror(self, obj):
)
row_html = format_html_join(
"",
"<tr><td>{}</td><td>{}</td><td><strong>{}</strong></td></tr>",
"<tr><td>{}</td><td>{}</td><td>{}</td><td><strong>{}</strong></td></tr>",
(
(
link.mc_channel_idx,
mc_channel_type_name(link.message_channel),
mc_channel_admin_label(link.message_channel),
mc_channel_scope_display(link.message_channel),
mc_channel_mirror_label(link.message_channel),
)
for link in links
),
)
return format_html(
"<table><thead><tr><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{}</tbody></table>",
"<table><thead><tr><th>{}</th><th>{}</th><th>{}</th><th>{}</th></tr></thead><tbody>{}</tbody></table>",
_("Slot"),
_("Type"),
_("Scope"),
_("Label"),
row_html,
)
Expand Down
1 change: 1 addition & 0 deletions docs/features/meshcore/mc-channel-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ This feature is **configuration only** — it does not upload mesh traffic. Pack
| [region-scope.md](region-scope.md) | Region scope semantics, API field, bot/UI behaviour |
| [region-scope-progress.md](region-scope-progress.md) | Execution log ([#391](https://github.com/pskillen/meshflow-api/issues/391)) |
| [region-scope-outstanding.md](region-scope-outstanding.md) | Protocol gaps and follow-ups |
| [bug-channel-scope-read-write.md](bug-channel-scope-read-write.md) | Investigation: scoped channels not on radio after bot apply ([#129](https://github.com/pskillen/meshflow-bot/issues/129)) |

**Related (other features)**

Expand Down
102 changes: 102 additions & 0 deletions docs/features/meshcore/mc-channel-sync/bug-channel-scope-read-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Bug: Scoped MC channels not persisted on physical radio

**Status:** Investigating
**Tracking:** [meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129)
**Related:** [region-scope.md](./region-scope.md), [region-scope-outstanding.md](./region-scope-outstanding.md), [#391](https://github.com/pskillen/meshflow-api/issues/391)

## Symptom

Operator configures **multiple MeshCore channels with distinct `region_scope` values** in Meshflow (UI or admin), pushes config to the feeder via the bot (**apply to radio** / admin **Push MC channel config**).

After apply:

1. Disconnect the radio from the bot (USB).
2. Connect the same device to the **MeshCore Android companion** over USB.
3. In companion channel settings, the radio shows **only one scope** configured (e.g. `sco`, default) and **every channel slot uses that default scope** — not the per-channel scopes from Meshflow.

Meshflow API / UI mirror may still show the intended scopes (bot merges scope from apply payload into `mc-channel-sync` when `CHANNEL_INFO` omits scope). **The failure is on the device**, not only in API bookkeeping.

## Expected vs observed

| Layer | Expected (operator) | Observed |
| --- | --- | --- |
| Meshflow catalog / mirror | Each canonical channel has its `region_scope` | OK (when apply + sync path runs) |
| Companion per-channel scope | Each slot has the scope assigned in Meshflow | All slots show **default** scope |
| Companion global / flood scope list | Scopes used on device exist | Only **one** scope (e.g. default) |
| Bot connect sync (`get_channel`) | Readback reflects per-slot scope where Android shows it | **Names only**; all `region_scope` null on wire → API mirror loses scope |

## Current bot apply path (code)

**Entry:** WebSocket `apply_mc_channel_config` → `MeshflowBot.on_apply_mc_channel_config` → `apply_device_channels` (`meshflow-bot/src/meshcore/channels.py`).

For each snapshot row:

1. **`set_channel(mc_channel_idx, name)`** — writes **channel name only** (hashtag gets `#` prefix). **No `region_scope` argument** on this call.
2. **`set_flood_scope(scope_arg)`** — called immediately after each successful `set_channel`. `scope_arg` is the row’s `region_scope` or `"*"` when null (`_apply_active_flood_scope`).

Documented limitation ([region-scope.md](./region-scope.md)): `set_flood_scope` sets the companion **active flood scope** (global operator scope for sending), **not** a per-slot field returned by `CHANNEL_INFO`. The apply loop therefore calls `set_flood_scope` once per row; **only the last row’s scope remains active** on the radio until the operator changes channel in the app.

**Read path:** `get_channel` / `CHANNEL_INFO` returns **name + secret only** (no per-channel scope). Post-apply sync uses `merge_channel_region_scopes(..., scope_hints=applied_channels)` so the **API** snapshot includes intent scopes without reading them from firmware.

## Hypotheses (ranked)

| # | Hypothesis | If true |
| --- | --- | --- |
| H1 | **No wire API used for per-channel scope on SET_CHANNEL** — bot only sets names; firmware stores per-slot scope via a different command we do not call | Android per-slot UI stays default; fix needs meshcore_py + bot change |
| H2 | **`set_flood_scope` is global only** — it does not write per-channel scope into NVS; Android “per channel scope” is a separate setting | Explains one global scope + all channels default in slot UI |
| H3 | **`set_flood_scope` missing or failing** on feeder (`meshcore.commands.set_flood_scope` absent or ERROR) | Bot logs WARNING; device never leaves default scope |
| H4 | **Wrong scope value on wire** — normalization, `*` for null, or ordering (scope before `set_channel`) | Check apply logs and payload hex |
| H5 | **Firmware / companion version** — older build ignores scope writes | Repro depends on device firmware; compare with manual scope set in Android |
| H6 | **Scopes must exist in companion region list before per-channel bind** — `set_flood_scope` only sets active TX key; Android lists regions separately | `ioi`/`gla` never registered; all slots show default `sco` |
| H7 | **Apply is partial** — only listed slots written; stale slots 4–8 remain | Duplicate `#glasgow` in slots 3 and 6 after 5-channel push → [meshflow-bot#130](https://github.com/pskillen/meshflow-bot/issues/130) |

## Verification checklist

- [x] **Bot logs during apply** (docker / local): search for
`set_flood_scope unavailable`, `set_flood_scope(%r) failed`, `active flood scope=`, `set_channel(%s) failed`
- [ ] **Apply payload** — confirm each WS/API entry includes `region_scope` (`message_channel_to_apply_entry` in `Meshflow/common/mc_channel_labels.py`)
- [ ] **`meshcore` package version** on feeder (`requirements.txt` currently `>=2.3.7,<3.0.0`) and whether `Commands.set_flood_scope` exists
- [ ] **meshcore_py / firmware docs** — command to set **per-channel** region scope vs `CMD_SET_FLOOD_SCOPE`
- [x] **After apply, before unplug** — bot `log_device_channels` lines (scope from **scope_hints**, not device truth)
- [x] **Android companion** — manual slot scope on device; bot read does not see it (see investigation log 2026-06-04 connect read)
- [x] **API mirror after connect sync** — Admin UI: channels created, **no** `region_scope` (confirms read path → reconcile gap)
- [ ] **Optional:** serial trace or meshcore_py REPL `get_channel(i)` raw payload after apply

## Investigation log

| Date | Who | Action / result |
| --- | --- | --- |
| 2026-06-04 | Operator | Reported repro: apply scoped channels via bot → unplug → Android USB → one scope (`sco`/default), all channels default scope. |
| 2026-06-04 | Agent | Documented apply path (`set_channel` + `set_flood_scope` only); aligned with [region-scope-outstanding.md](./region-scope-outstanding.md) open item “confirm per-channel vs global on hardware”. **No device trace yet.** |
| 2026-06-04 | Agent | Filed [meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129). |
| 2026-06-04 | Operator | **Android 1.44.0, firmware 1.15.0.** Apply 5 channels: Public→`sco`, #scotland→`sco`, #norniron→`ioi`, #glasgow→`gla`, #test→null. Bot logs: `set_flood_scope` OK for slots 0–3 (no INFO line for null scope on #test). Post-apply sync shows scopes 0–3 from **scope_hints** only; slots 4–8 unchanged on device (`test` at 4, duplicate `#glasgow` at 3+6). Android after storage clear: **no scopes in node list**; **all channels default `sco`**. Confirms H2/H3 rejected (commands succeed); supports H6 (regions `ioi`/`gla` not on device list; `set_flood_scope` ≠ per-channel bind). |
| 2026-06-04 | Operator | **Connect read (no apply).** Configured on Android only: `#test-gla` (explicit **gla**), `#test-sco` and `#test-unset` (both unset in UI → default **sco**). USB to bot → connect sync logged 3 channels, **all** `region_scope=(none)`. **Admin UI:** three canonical channels created correctly, **none** have `region_scope`. Confirms `CHANNEL_INFO` / `get_channel` cannot read per-slot scope; connect sync cannot distinguish gla vs default-sco; API mirror is misled unless `enrich_snapshot_region_scope` matches prior links by name only. Strengthens H1/H2. |

## Fixes to apply

Tracked work to reduce confusion while the protocol gap ([#129](https://github.com/pskillen/meshflow-bot/issues/129)) is resolved.

| Item | Repo | Description |
| --- | --- | --- |
| [meshflow-bot#130](https://github.com/pskillen/meshflow-bot/issues/130) | meshflow-bot | **Clear unlisted channel slots** when applying MC channel config so stale slots (duplicate hashtags, old names) are removed from the device. |
| Apply verify logging | meshflow-bot | On **apply** (`apply_device_channels` or caller): (1) log **desired** config from the apply payload (per `mc_channel_idx`: name, type, `region_scope`). (2) **Read back** from device via `read_device_channels` **without** `scope_hints`. (3) log **readback** config with clear labels (`DESIRED` vs `READBACK`). (4) **WARNING** on mismatch (slot index, name, and `region_scope` when present on read path). Expect scope mismatches until firmware exposes scope in `CHANNEL_INFO`; names/indices should match after #130 + scope fix. |

## Next steps

1. ~~Capture **one full apply** log line set~~ — done (see investigation log 2026-06-04 operator).
2. Spike **meshcore_py + firmware 1.15**: `set_default_flood_scope` (registers default + auto-creates region per [default-scope blog](https://blog.meshcore.io/2026/04/17/default-scope)) vs command for **per-channel** scope bind (Android issue [#1575](https://github.com/meshcore-dev/MeshCore/issues/1575): region must be created then activated per channel in app).
3. ~~Decide whether apply should **clear unlisted slots**~~ → [meshflow-bot#130](https://github.com/pskillen/meshflow-bot/issues/130).
4. If only global scope exists today, update [region-scope.md](./region-scope.md) operator docs and UI copy so we do not imply per-slot persistence until firmware supports it.
5. ~~File tracking issue~~ → [meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129).
6. Implement **apply verify logging** (see [Fixes to apply](#fixes-to-apply)) to stop treating post-apply `scope_hints` merge as device truth.

## Related code

| Repo | Path |
| --- | --- |
| meshflow-bot | `src/meshcore/channels.py` — `apply_device_channels`, `_apply_active_flood_scope`, `read_device_channels` |
| meshflow-bot | `src/bot.py` — `on_apply_mc_channel_config`, `scope_hints` on post-apply sync |
| meshflow-api | `Meshflow/meshcore_packets/services/channel_apply.py` — `build_apply_channels_for_managed_node` |
| meshflow-api | `Meshflow/common/mc_channel_labels.py` — `message_channel_to_apply_entry` (`region_scope` in payload) |

**External:** [Region filtering](https://blog.meshcore.io/2026/01/20/region-filtering) · [Default scope](https://blog.meshcore.io/2026/04/17/default-scope)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Items **skipped**, **incomplete**, or **discovered during execution** for [#391]

## Protocol / device

- [ ] **Active investigation:** [bug-channel-scope-read-write.md](./bug-channel-scope-read-write.md) — bot apply does not leave per-channel scopes on device ([meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129))
- [ ] Per-channel `region_scope` in companion `CHANNEL_INFO` — not on wire today; bot may sync `null` until meshcore_py/firmware expose it
- [ ] Apply path: confirm whether `set_flood_scope` is per-channel or global on hardware (spike on real feeder)
- [ ] Apply path: confirm whether `set_flood_scope` is per-channel or global on hardware (spike on real feeder) — see bug log verification checklist
- [x] Bot merges `region_scope` from apply payload into post-apply sync (CHANNEL_INFO does not return scope)
- [ ] Per-channel scope in `CHANNEL_INFO` / extended `SET_CHANNEL` — firmware still name+secret only; active scope is `set_flood_scope` (global) per slot during apply
1 change: 1 addition & 0 deletions docs/features/meshcore/packet-path-tracing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ It is intentionally separate from the Meshtastic `traceroute` subsystem:

- [Progress](./packet-path-tracing-progress.md) — what has shipped / is in flight
- [Outstanding](./packet-path-tracing-outstanding.md) — open decisions and discovered debt
- [Bug: no path info](./bug-no-path-info.md) — investigation when `heard[]` / path hashes are empty ([#385](https://github.com/pskillen/meshflow-api/issues/385))

## Related docs

Expand Down
Loading