From 276242c458275bba5cfc27da2582f95045c141f3 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 09:10:16 -0700 Subject: [PATCH 1/5] Add frequency slot lookup to Radio Config and channel delete to Channels 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 --- frontend/css/radio_cards.css | 4 ++ frontend/js/radio_channels.js | 43 ++++++++++++++++ frontend/js/radio_config_card.js | 84 +++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/frontend/css/radio_cards.css b/frontend/css/radio_cards.css index 73c9679..1969158 100644 --- a/frontend/css/radio_cards.css +++ b/frontend/css/radio_cards.css @@ -203,6 +203,10 @@ min-width: 0; } +.config-pane__inputs .r-field--slot { + flex: 0 0 auto; +} + .config-pane__hint { font-size: 0.7rem; color: var(--text-muted); diff --git a/frontend/js/radio_channels.js b/frontend/js/radio_channels.js index 487f41a..6a41a16 100644 --- a/frontend/js/radio_channels.js +++ b/frontend/js/radio_channels.js @@ -10,6 +10,7 @@ class RadioChannels { constructor(containerEl) { this._container = containerEl; this._channels = []; + this._focusedRow = null; } render(channels) { @@ -60,6 +61,10 @@ class RadioChannels { ${rows}
+ @@ -76,6 +81,12 @@ class RadioChannels { document.getElementById('r-ch-save').addEventListener( 'click', () => this._save(), ); + + const delBtn = document.getElementById('r-ch-delete'); + // preventDefault on mousedown keeps focus on the row input so blur + // does not fire before click, which would hide the button too early. + delBtn.addEventListener('mousedown', (e) => e.preventDefault()); + delBtn.addEventListener('click', () => this._deleteRow()); } _wireRowHandlers(scope) { @@ -94,6 +105,38 @@ class RadioChannels { scope.querySelectorAll('[data-field="name"]').forEach((input) => { input.addEventListener('input', () => this._refreshHash(input.closest('tr'))); }); + + scope.querySelectorAll('.ch-table__row input').forEach((input) => { + input.addEventListener('focus', () => { + const row = input.closest('tr'); + const tbody = document.getElementById('r-ch-body'); + const isPrimary = Array.from(tbody.rows).indexOf(row) === 0; + this._focusedRow = isPrimary ? null : row; + this._syncDeleteBtn(); + }); + input.addEventListener('blur', () => { + // Defer so focus can settle on another row input before we hide. + setTimeout(() => { + if (!this._container.querySelector('.ch-table__row input:focus')) { + this._focusedRow = null; + this._syncDeleteBtn(); + } + }, 0); + }); + }); + } + + _syncDeleteBtn() { + const btn = document.getElementById('r-ch-delete'); + if (btn) btn.style.display = this._focusedRow ? '' : 'none'; + } + + _deleteRow() { + if (!this._focusedRow) return; + if (!confirm('Delete this channel?')) return; + this._focusedRow.remove(); + this._focusedRow = null; + this._syncDeleteBtn(); } _refreshHash(row) { diff --git a/frontend/js/radio_config_card.js b/frontend/js/radio_config_card.js index 6b98e45..7b1fda3 100644 --- a/frontend/js/radio_config_card.js +++ b/frontend/js/radio_config_card.js @@ -9,6 +9,13 @@ * apply at runtime; freq/preset/region require a service restart. * A horizontal readout strip shows the computed sync word, preamble, * and effective SF/BW/CR for the current selection. + * + * The Slot field (left of Frequency) translates between the Meshtastic + * 1-indexed slot number and MHz using the general formula from the + * firmware: freq = freqStart + (BW/2000) + ((slot-1) * (BW/1000)). + * Supported for BW 125/250/500 across all regions. Entering a slot + * fills the frequency; entering a frequency fills the slot (or shows + * "--" if no match). */ class RadioConfigCard { constructor(api) { @@ -18,6 +25,7 @@ class RadioConfigCard { this._regions = []; this._currentRadio = null; this._currentTx = null; + this._effectiveBw = null; } mount(rootEl) { @@ -45,6 +53,11 @@ class RadioConfigCard {
Tuning
+
+ + +
numSlots) return null; + return parseFloat((band.start + spacing / 2 + (slot - 1) * spacing).toFixed(4)); + } + + // Returns the 1-indexed slot number for a frequency at the given BW in + // the given region, or null if the frequency does not land on a slot. + _freqToSlot(freq, bw, regionId) { + const band = this._regionBand(regionId); + if (!band || ![125, 250, 500].includes(bw)) return null; + const spacing = bw / 1000; + const numSlots = Math.floor((band.end - band.start) / spacing); + const raw = (freq - band.start - spacing / 2) / spacing + 1; + const n = Math.round(raw); + if (n >= 1 && n <= numSlots && Math.abs(raw - n) < 0.001) return n; + return null; + } + + _updateSlotFromFreq() { + const freqVal = parseFloat(this._root.querySelector('#r-freq').value); + const slotEl = this._root.querySelector('#r-slot'); + if (isNaN(freqVal) || !this._effectiveBw) { + slotEl.value = ''; + return; + } + const region = this._root.querySelector('#r-region').value; + const slot = this._freqToSlot(freqVal, this._effectiveBw, region); + slotEl.value = slot !== null ? String(slot) : '--'; + } + _wire() { const presetSel = this._root.querySelector('#r-preset'); presetSel.onchange = () => { @@ -161,6 +227,7 @@ class RadioConfigCard { if (name === 'CUSTOM') return; const p = this._presets.find((x) => x.name === name); if (!p) return; + this._effectiveBw = p.bw_khz; this._renderReadouts({ spreading_factor: p.sf, bandwidth_khz: p.bw_khz, @@ -168,12 +235,27 @@ class RadioConfigCard { sync_word: this._currentRadio.sync_word, preamble_length: this._currentRadio.preamble_length, }); + this._updateSlotFromFreq(); }; const regionSel = this._root.querySelector('#r-region'); regionSel.onchange = () => { const r = this._regions.find((x) => x.id === regionSel.value); - if (r) this._root.querySelector('#r-freq').value = r.frequency_mhz; + if (!r) return; + this._root.querySelector('#r-freq').value = r.frequency_mhz; + this._updateSlotFromFreq(); + }; + + const freqEl = this._root.querySelector('#r-freq'); + freqEl.onchange = () => this._updateSlotFromFreq(); + + const slotEl = this._root.querySelector('#r-slot'); + slotEl.onchange = () => { + const slotVal = parseInt(slotEl.value, 10); + if (isNaN(slotVal) || !this._effectiveBw) return; + const region = this._root.querySelector('#r-region').value; + const freq = this._slotToFreq(slotVal, this._effectiveBw, region); + if (freq !== null) freqEl.value = freq; }; this._root.querySelector('#r-save-config').onclick = () => this._save(); From 97bc99664d1f1ec97eb064e20b007b771d7d0e2c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 13:16:55 -0700 Subject: [PATCH 2/5] Fix: clear channel keys from crypto service before re-adding on Save 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 --- src/api/routes/config_routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/routes/config_routes.py b/src/api/routes/config_routes.py index 3eedd19..375af7b 100644 --- a/src/api/routes/config_routes.py +++ b/src/api/routes/config_routes.py @@ -338,6 +338,7 @@ async def update_channels(req: ChannelsUpdate): raise HTTPException(403, str(exc)) if _crypto and hasattr(_crypto, "add_channel_key"): + _crypto._keys.clear() for name, key_b64 in channel_keys.items(): _crypto.add_channel_key(name, key_b64) From a3491c1077932d6b101b4861b540e91b3db06c1c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 13:29:03 -0700 Subject: [PATCH 3/5] Add slot-based frequency resolution to radio config 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 --- config/default.yaml | 6 +++++- src/config.py | 49 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/config/default.yaml b/config/default.yaml index 05611c9..a907378 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -1,6 +1,10 @@ radio: region: "US" - frequency_mhz: 906.875 + # Frequency: set frequency_mhz (explicit MHz) OR slot (Meshtastic 1-indexed + # slot number). frequency_mhz wins if both are present. If neither is set, + # the regional default is used. Examples: + # frequency_mhz: 906.875 + # slot: 20 spreading_factor: 11 bandwidth_khz: 250.0 coding_rate: "4/5" diff --git a/src/config.py b/src/config.py index e04819c..f9318f7 100644 --- a/src/config.py +++ b/src/config.py @@ -12,10 +12,35 @@ from src.version import __version__ +# Band-start frequencies (MHz) for the Meshtastic slot formula +# freq = freqStart + BW/2000 + (slot-1) * BW/1000 +# Values match _REGION_BAND_LIMITS_HZ in hal/concentrator_config.py. +_REGION_FREQ_START: dict[str, float] = { + "US": 902.0, + "EU_868": 863.0, + "ANZ": 915.0, + "IN": 865.0, + "KR": 920.0, + "SG_923": 917.0, +} + +# Regional default frequencies used when neither frequency_mhz nor slot +# is set. Values match REGION_DEFAULTS in radio/presets.py. +_REGION_DEFAULT_FREQ: dict[str, float] = { + "US": 906.875, + "EU_868": 869.525, + "ANZ": 916.0, + "IN": 865.4625, + "KR": 921.9, + "SG_923": 923.0, +} + + @dataclass class RadioConfig: region: str = "US" - frequency_mhz: float = 906.875 + frequency_mhz: Optional[float] = None # resolved at load time; wins over slot + slot: Optional[int] = None # Meshtastic 1-indexed slot; used when frequency_mhz absent spreading_factor: int = 11 bandwidth_khz: float = 250.0 coding_rate: str = "4/8" @@ -168,6 +193,27 @@ class AppConfig: transmit: TransmitConfig = field(default_factory=TransmitConfig) +def _resolve_radio_frequency(radio: "RadioConfig") -> None: + """Resolve radio.frequency_mhz at startup. + + Priority (first match wins): + 1. frequency_mhz set in YAML -> use as-is, slot ignored + 2. slot set in YAML -> compute from slot + bandwidth + region + 3. neither set -> regional default frequency + """ + if radio.frequency_mhz is not None: + return + if radio.slot is not None: + freq_start = _REGION_FREQ_START.get(radio.region) + if freq_start is not None: + spacing = radio.bandwidth_khz / 1000 + radio.frequency_mhz = round( + freq_start + spacing / 2 + (radio.slot - 1) * spacing, 4 + ) + return + radio.frequency_mhz = _REGION_DEFAULT_FREQ.get(radio.region, 906.875) + + def _merge_dataclass(instance, overrides: dict): """Apply dict overrides onto a dataclass instance, merging nested dataclasses.""" if not overrides: @@ -232,6 +278,7 @@ def load_config(config_path: Optional[str] = None) -> AppConfig: local = config_path or os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml") _apply_yaml(cfg, _validated_config_path(local)) + _resolve_radio_frequency(cfg.radio) return cfg From 95ca8929e1822109ef671e2822aabe98ab86ec8e Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 17:42:18 -0700 Subject: [PATCH 4/5] Refactor: expose clear_channel_keys() on CryptoService 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 --- src/api/routes/config_routes.py | 2 +- src/decode/crypto_service.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/routes/config_routes.py b/src/api/routes/config_routes.py index 375af7b..3822ee4 100644 --- a/src/api/routes/config_routes.py +++ b/src/api/routes/config_routes.py @@ -338,7 +338,7 @@ async def update_channels(req: ChannelsUpdate): raise HTTPException(403, str(exc)) if _crypto and hasattr(_crypto, "add_channel_key"): - _crypto._keys.clear() + _crypto.clear_channel_keys() for name, key_b64 in channel_keys.items(): _crypto.add_channel_key(name, key_b64) diff --git a/src/decode/crypto_service.py b/src/decode/crypto_service.py index 8fe1283..9b9873d 100644 --- a/src/decode/crypto_service.py +++ b/src/decode/crypto_service.py @@ -33,6 +33,10 @@ def add_channel_key(self, channel_name: str, key_b64: str) -> None: raw = base64.b64decode(key_b64) self._keys[channel_name] = self._expand_key(raw) + def clear_channel_keys(self) -> None: + """Drop all non-default channel keys.""" + self._keys.clear() + def get_all_keys(self) -> list[bytes]: """Return all available keys, default first then channel keys.""" keys: list[bytes] = [] From 8cdd2fae4b403bf580a76dc48d8ec64d47af122b Mon Sep 17 00:00:00 2001 From: KMX415 Date: Thu, 30 Apr 2026 23:52:18 -0400 Subject: [PATCH 5/5] Add unit tests for radio frequency resolver 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. --- tests/test_radio_frequency_resolver.py | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/test_radio_frequency_resolver.py diff --git a/tests/test_radio_frequency_resolver.py b/tests/test_radio_frequency_resolver.py new file mode 100644 index 0000000..d4440bf --- /dev/null +++ b/tests/test_radio_frequency_resolver.py @@ -0,0 +1,118 @@ +"""Tests for ``_resolve_radio_frequency`` and the regional fallback tables. + +The resolver lets ``radio.frequency_mhz`` default to ``None`` in config +and pick a sensible value at runtime: an explicit MHz wins, otherwise a +configured slot is converted via the Meshtastic formula, otherwise a +regional default is used. +""" + +from __future__ import annotations + +import unittest + +from src.config import ( + _REGION_DEFAULT_FREQ, + _REGION_FREQ_START, + RadioConfig, + _resolve_radio_frequency, +) + + +class TestResolveRadioFrequency(unittest.TestCase): + def _radio(self, **overrides) -> RadioConfig: + radio = RadioConfig() + radio.frequency_mhz = None + radio.slot = None + for k, v in overrides.items(): + setattr(radio, k, v) + return radio + + def test_explicit_frequency_wins_over_slot_and_region(self): + radio = self._radio(region="US", slot=20, frequency_mhz=903.0) + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 903.0) + + def test_explicit_frequency_wins_over_region_default(self): + radio = self._radio(region="EU_868", frequency_mhz=868.5) + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 868.5) + + def test_slot_resolves_us_longfast(self): + # US LongFast slot 20 = 902.0 + 0.125 + 19*0.25 = 906.875 MHz + radio = self._radio(region="US", slot=20, bandwidth_khz=250.0) + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 906.875) + + def test_slot_resolves_eu_longfast(self): + # EU_868 LongFast slot 1 = 863.0 + 0.125 + 0*0.25 = 863.125 MHz + radio = self._radio(region="EU_868", slot=1, bandwidth_khz=250.0) + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 863.125) + + def test_slot_resolves_125khz_bw(self): + # US 125 kHz slot 1 = 902.0 + 0.0625 + 0 = 902.0625 MHz + radio = self._radio(region="US", slot=1, bandwidth_khz=125.0) + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 902.0625) + + def test_us_default_is_906_875(self): + radio = self._radio(region="US") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 906.875) + + def test_eu_default_is_869_525(self): + radio = self._radio(region="EU_868") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 869.525) + + def test_anz_default_is_916(self): + radio = self._radio(region="ANZ") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 916.0) + + def test_in_default_is_865_4625(self): + radio = self._radio(region="IN") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 865.4625) + + def test_kr_default_is_921_9(self): + radio = self._radio(region="KR") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 921.9) + + def test_sg_default_is_923(self): + radio = self._radio(region="SG_923") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 923.0) + + def test_unknown_region_falls_back_to_us_default(self): + radio = self._radio(region="MARS") + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 906.875) + + def test_slot_with_unknown_region_falls_through_to_default(self): + # No FREQ_START entry for region: slot is ignored, falls + # through to _REGION_DEFAULT_FREQ. + radio = self._radio(region="MARS", slot=20, bandwidth_khz=250.0) + _resolve_radio_frequency(radio) + self.assertEqual(radio.frequency_mhz, 906.875) + + +class TestRegionTables(unittest.TestCase): + def test_freq_start_covers_all_supported_regions(self): + from src.cli.setup_wizard import SUPPORTED_REGIONS + + for region in SUPPORTED_REGIONS: + with self.subTest(region=region): + self.assertIn(region, _REGION_FREQ_START) + + def test_default_freq_covers_all_supported_regions(self): + from src.cli.setup_wizard import SUPPORTED_REGIONS + + for region in SUPPORTED_REGIONS: + with self.subTest(region=region): + self.assertIn(region, _REGION_DEFAULT_FREQ) + + +if __name__ == "__main__": + unittest.main()