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
6 changes: 5 additions & 1 deletion config/default.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 4 additions & 0 deletions frontend/css/radio_cards.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions frontend/js/radio_channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class RadioChannels {
constructor(containerEl) {
this._container = containerEl;
this._channels = [];
this._focusedRow = null;
}

render(channels) {
Expand Down Expand Up @@ -60,6 +61,10 @@ class RadioChannels {
<tbody id="r-ch-body">${rows}</tbody>
</table>
<div class="r-card__actions">
<button class="r-btn r-btn--warn" id="r-ch-delete"
style="display:none">
Delete Channel
</button>
<button class="r-btn r-btn--secondary" id="r-ch-add">
+ Add Channel
</button>
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
84 changes: 83 additions & 1 deletion frontend/js/radio_config_card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -18,6 +25,7 @@ class RadioConfigCard {
this._regions = [];
this._currentRadio = null;
this._currentTx = null;
this._effectiveBw = null;
}

mount(rootEl) {
Expand Down Expand Up @@ -45,6 +53,11 @@ class RadioConfigCard {
<div class="config-pane">
<div class="config-pane__label">Tuning</div>
<div class="config-pane__inputs">
<div class="r-field r-field--slot">
<label class="r-field__label" for="r-slot">Slot</label>
<input type="text" class="r-input r-input--mono r-input--narrow"
id="r-slot" placeholder="--" />
</div>
<div class="r-field">
<label class="r-field__label" for="r-freq">Frequency (MHz)</label>
<input type="number" class="r-input r-input--mono r-input--narrow"
Expand Down Expand Up @@ -134,6 +147,8 @@ class RadioConfigCard {
this._root.querySelector('#r-freq').value = this._currentRadio.frequency_mhz || '';
this._root.querySelector('#r-tx-power').value = this._currentTx.tx_power_dbm || '';
this._root.querySelector('#r-hop-limit').value = this._currentTx.hop_limit || '';
this._effectiveBw = this._currentRadio.bandwidth_khz || null;
this._updateSlotFromFreq();
}

_renderReadouts(radio) {
Expand All @@ -154,26 +169,93 @@ class RadioConfigCard {
sub.textContent = freq ? `${preset} -- ${freq}` : preset;
}

// Meshtastic band limits (freqStart, freqEnd) per region in MHz.
// freqStart is the lower ISM band boundary used in the slot formula:
// freq = freqStart + (BW/2000) + ((slot-1) * (BW/1000))
_regionBand(regionId) {
const bands = {
US: { start: 902.0, end: 928.0 },
EU_868: { start: 863.0, end: 870.0 },
ANZ: { start: 915.0, end: 928.0 },
IN: { start: 865.0, end: 867.0 },
KR: { start: 920.0, end: 923.0 },
SG_923: { start: 917.0, end: 925.0 },
};
return bands[regionId] || null;
}

// Returns the center frequency (MHz) for a 1-indexed slot at the given
// BW (kHz) in the given region. Returns null for unsupported inputs.
_slotToFreq(slot, 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);
if (slot < 1 || slot > 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 = () => {
const name = presetSel.value;
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,
coding_rate: p.cr,
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();
Expand Down
1 change: 1 addition & 0 deletions src/api/routes/config_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
49 changes: 48 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/decode/crypto_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
Loading
Loading