+
+
+
+
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();
diff --git a/src/api/routes/config_routes.py b/src/api/routes/config_routes.py
index 3eedd19..3822ee4 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.clear_channel_keys()
for name, key_b64 in channel_keys.items():
_crypto.add_channel_key(name, key_b64)
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
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] = []
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()