Skip to content

Add frequency slot lookup to Radio Config and channel delete to Channels#38

Merged
KMX415 merged 5 commits into
KMX415:mainfrom
kendelmccarley:main
May 1, 2026
Merged

Add frequency slot lookup to Radio Config and channel delete to Channels#38
KMX415 merged 5 commits into
KMX415:mainfrom
kendelmccarley:main

Conversation

@kendelmccarley
Copy link
Copy Markdown
Contributor

Summary

  • Adds a Slot ↔ Frequency lookup field to the Radio Config tuning pane so slot numbers can be entered directly without manual
    frequency lookups
  • Adds a contextual Delete Channel button to the Channels card that appears when a non-primary channel row has focus

Why

These changes make it easier to develop and test multiple LoRa channel configurations. The Meshtastic slot numbering is the common currency when coordinating frequencies with other operators, but the dashboard previously only accepted raw MHz values, requiring a manual table lookup every time. Being able to delete an entry will ease development and testing.

Type

  • Feature

Testing

  • Local only

Hardware:

  • Pi: Raspberry Pi (RAK V2)
  • Concentrator: RAK2287 / SX1302
  • Node/radio: --
  • Region: US
  • OS: Raspberry Pi OS (Linux 6.12.75+rpt-rpi-v8)

Impact

No config schema changes. Slot field is a pure UI convenience — only the resolved frequency_mhz value is written to config on save. Delete Channel removes the row from the DOM; the channel list is only committed on "Save Channels".

AI-assisted?

Yes — developed with Claude Code (Anthropic). Logic reviewed against Meshtastic firmware formula and validated against the slot tables in docs/RADIO-CONFIG-EXPLAINED.md.

root and others added 3 commits April 30, 2026 09:10
Radio Config card (Tuning pane):
- New Slot input to the left of Frequency. Entering a slot number fills
  the corresponding center frequency; entering a frequency fills the slot
  number (or shows "--" if the frequency is not on a slot boundary).
- Bidirectional sync updates on onchange for both fields; preset and
  region changes also re-evaluate the slot.
- Covers BW 125/250/500 across all six regions (US, EU_868, ANZ, IN,
  KR, SG_923) using the Meshtastic firmware formula:
  freq = freqStart + BW/2000 + (slot-1) * BW/1000.
- Region band limits embedded as a small lookup table; toFixed(4) used
  so BW 125 offsets (0.0625 MHz) round correctly.

Channels card:
- "Delete Channel" button appears in the actions bar (left of Add
  Channel) when any input in a non-primary row has focus; hidden
  otherwise.  mousedown preventDefault keeps row focus alive so the
  button does not disappear before the click lands.
- Asks for browser confirm() before removing the row.
- Channel 0 (primary) is never deletable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The update_channels handler was only calling add_channel_key() for
channels in the new list, leaving deleted channels' keys alive in
_crypto._keys for the lifetime of the process. Clear _keys before
the re-add loop so a Save immediately reflects deletions without
requiring a service restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RadioConfig gains a slot field (Optional[int]). At startup, load_config()
calls _resolve_radio_frequency() which applies this priority:

  1. frequency_mhz set in YAML  -> use as-is, slot is ignored
  2. slot set in YAML           -> compute via Meshtastic firmware formula
                                   freq = freqStart + BW/2000 + (slot-1)*BW/1000
  3. neither set                -> regional default frequency

frequency_mhz is now Optional[float] = None in RadioConfig so the
presence/absence of the field in YAML is detectable after merging.
_REGION_FREQ_START and _REGION_DEFAULT_FREQ lookup tables are inlined
in config.py (stable Meshtastic spec values, no new imports needed).

default.yaml drops the explicit frequency_mhz line so that a local.yaml
with only slot: N resolves correctly without the default layer clobbering
the None sentinel. A comment block documents both usage options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@KMX415
Copy link
Copy Markdown
Owner

KMX415 commented Apr 30, 2026

Thanks Kendel: this is exactly the kind of PR I want to see. Slot lookup has been overdue (operators coordinate by slot number, not MHz), and the crypto-keys cleanup is a real bug I'd missed. You also already wrote the whole thing against the v0.7.1 file split (radio_config_card.js, r-* BEM prefixes, r-field--slot as a modifier on the existing block), which is a nice surprise given v0.7.1 only landed today.

Slot formula crosses out clean: US LongFast slot 20 = 902.0 + 0.125 + 19*0.25 = 906.875 MHz, matches ConcentratorChannelPlan.for_region("US") defaults. The boundary tolerance (Math.abs(raw - n) < 0.001) is the right guard for between slots. The focus/blur dance with setTimeout(..., 0) for the delete button is the right pattern, not a kludge.

Two small asks before merge:

1. The crypto fix reaches into a private attribute.

_crypto._keys.clear() couples the route handler to CryptoService's internal storage. Could you add a public method on CryptoService and call that instead?

def clear_channel_keys(self) -> None:
    """Drop all non-default channel keys."""
    self._keys.clear()

Then update_channels does _crypto.clear_channel_keys(). Same behavior, no encapsulation break.

2. Regression test on the cleanup.

Without one, the bug grows back the next time someone refactors update_channels. Something like: after update_channels removes a channel, that channel's PSK is no longer reachable via crypto.get_all_keys().

Optional but appreciated: if you have a node on a non-US region around (or a friend with EU_868 / ANZ hardware), one cross-region slot sanity check would lock the formula in across all six bands. US is solid; EU and ANZ are where the band-start values diverge.

Thanks again: this is the good version of an AI-assisted PR.

Replace direct _crypto._keys.clear() in the route handler with a
public method on CryptoService. Eliminates the encapsulation break
flagged in PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kendelmccarley
Copy link
Copy Markdown
Contributor Author

Yes, I ran into the key cleanup issue about five minutes after submitting the PR. I had two identical channels, with the resulting behavior that Ch 0 could TX and Ch 1 grabbed all the RX. When I deleted the duplicate channel, the second channel's key remained and it was swallowing received messages. That fix has been pushed back to my fork.

I also added the slot number to config.yaml, with logic to resolve any conflict between slot number and frequency in the favor of the frequency. This keeps the change from breaking existing installations. Using the formula to calculate frequency should eliminate the need to set the default in each region, so that change was made as well. Although I can't see a reason it wouldn't work correctly, the change is untested through a new install.

@KMX415
Copy link
Copy Markdown
Owner

KMX415 commented May 1, 2026

Thanks for the fast turnaround Kendel, and for going above and beyond on the schema work too. The clear_channel_keys() refactor is exactly what I was hoping for, and the bug repro you described (Ch 0 TX, Ch 1 RX swallowing messages from the deleted Ch 1's stale key) is a great catch.

The slot-in-config schema work in a3491c1 is also genuinely thoughtful. The priority order (explicit MHz wins, then slot, then regional default) is the right shape, and it mirrors the max_duty_cycle_source explicit vs derived pattern that just landed in v0.7.1. Backward compat looks intact too: existing Meshpoints with frequency_mhz set in their local.yaml keep that, and ones without it fall through to _REGION_DEFAULT_FREQ which returns the same values they had before.

One small note for future cleanup (no need to block this PR): _REGION_FREQ_START and _REGION_DEFAULT_FREQ mirror values that already live in hal/concentrator_config.py::_REGION_BAND_LIMITS_HZ and radio/presets.py::REGION_DEFAULTS. Three sources of truth for the same numbers is a slow drift risk over time. Worth picking one canonical source eventually and having the resolver consume it. Easy to file as a follow-up cleanup, no rush.

I'll do a quick fresh-SD validation on EU_868 on my end before merging, since you flagged the multi-region piece as untested and I have the hardware for it. That way you don't have to chase region-by-region.

Really appreciate the work on this one. Both the slot lookup and the channel deletion (and the bug you caught while building it) are genuinely useful.

P.S. Apologies for the earlier version of this comment. AI is dumb sometimes. The above is what I should have led with.

Covers _resolve_radio_frequency() across all three priority paths (explicit MHz, slot-based formula, regional fallback) and verifies both region tables include every entry in SUPPORTED_REGIONS.

15 tests, all passing.
@KMX415
Copy link
Copy Markdown
Owner

KMX415 commented May 1, 2026

CI green and hardware-validated end-to-end. Merging this shortly.

Tests run:

  1. CI on commit 8cdd2fa: lint clean, full pytest suite green including 15 new tests for the resolver in tests/test_radio_frequency_resolver.py (covers all 6 supported regions, both fallthrough and explicit-MHz paths, plus a region-table coverage check against setup_wizard.SUPPORTED_REGIONS).

  2. Hardware regression on an existing US Meshpoint: switched to your branch, set an off-default frequency_mhz: 905.0 in local.yaml. Boot banner showed Frequency 905.000 MHz / SF11 / BW250 (US). NodeInfo TX at 905 MHz, 723ms airtime, no errors. Confirms explicit MHz wins over region default, which is the regression case to catch first since every existing in-wild Meshpoint has explicit MHz pinned.

  3. Fresh-install on a wiped Pi: removed local.yaml and the SQLite DB, re-ran meshpoint setup, selected region: EU_868. Boot banner showed Frequency 869.525 MHz / SF11 / BW250 (EU_868). NodeInfo TX at 869.525 MHz, 764ms airtime. Confirms the new fresh-install path works (wizard writes only region, resolver fills frequency from _REGION_DEFAULT_FREQ).

  4. Channel delete UI on the same fresh-install Pi: focus a non-primary channel row, delete button appears, click removes the row, save persists. mousedown preventDefault correctly keeps focus alive while the click lands.

The schema change you bundled in a3491c1 was beyond the PR's original scope, but it's backward-compatible (existing yaml configs with explicit MHz are preserved by the resolver), it simplifies the radio config for fresh installs, and it fixes a latent bug where new EU/IN/etc. installs were getting 906.875 by default. I'd been wanting to make this exact change. Thanks for taking it on.

Plan: merging to main without a version bump. The improvements will ride along with the next feature release (v0.7.2), so the changelog will credit this work then.

Thanks for the work and the patience through review.

@KMX415 KMX415 merged commit 53c1987 into KMX415:main May 1, 2026
1 check passed
KMX415 pushed a commit that referenced this pull request May 1, 2026
Bundles three improvements to the Radio configuration surface plus a
small schema cleanup that simplifies the radio config for fresh
installs.

Frontend (Radio tab):
* Bidirectional slot ↔ frequency lookup on the Radio Configuration
  card. Typing a slot number updates the frequency input via the
  Meshtastic firmware formula
  (freq = freqStart + BW/2000 + (slot-1) * BW/1000); typing a
  frequency clears the slot when it does not map to a channel
  center. Covers BW 125/250/500 across all six supported regions.
* Per-channel delete button on the Channels card with mousedown
  preventDefault to keep row focus alive while the click fires.
  Channel 0 (primary) is never deletable.

Backend:
* New CryptoService.clear_channel_keys() public method replaces a
  direct ._keys.clear() call from config_routes.update_channels,
  keeping the internal dict private. Save now reflects channel
  deletions immediately without requiring a service restart.
* RadioConfig.frequency_mhz is now Optional[float] (default None).
  New radio.slot field for slot-based config. New
  _resolve_radio_frequency() helper applies priority:
    1. frequency_mhz set in YAML → use as-is
    2. slot set in YAML → compute via the firmware formula
    3. neither set → regional default
  Backward-compatible: existing configs with explicit
  frequency_mhz are preserved unchanged.

config/default.yaml: removed hardcoded frequency_mhz: 906.875,
documented the explicit-MHz vs slot vs regional-default options
inline. Fresh installs now get correct regional defaults
automatically (previously EU/IN/etc. installs silently inherited
US 906.875 unless the user explicitly overrode).

Tests:
* tests/test_radio_frequency_resolver.py covers all 6 supported
  regions across the explicit-MHz, slot, and fallthrough paths,
  plus a region-table coverage check against
  setup_wizard.SUPPORTED_REGIONS. 15 tests, all passing.

Hardware-validated end-to-end:
* Existing-Meshpoint regression on RAK V2 (.141): explicit
  frequency_mhz: 905.0 preserved through the resolver, banner
  reads `905.000 MHz / SF11 / BW250 (US)`, NodeInfo TX confirmed
  at 905 MHz.
* Fresh-install on RAK V2 (.49): wiped local.yaml and SQLite,
  re-ran setup wizard with region: EU_868, banner reads
  `869.525 MHz / SF11 / BW250 (EU_868)`, NodeInfo TX confirmed
  at 869.525 MHz.
* Channel delete UI exercised on the fresh-install Pi: focus a
  non-primary row, delete button appears, click removes the row,
  save persists.

Co-authored-by: kendelmccarley <60525114+kendelmccarley@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants