data: better lora frequency handling for meshtastic (#733)

* data: better lora frequency handling for meshtastic

* ingestor: address review comments
This commit is contained in:
l5y authored and GitHub committed 2026-04-12 16:02:15 +02:00
1 parent aeb97477f0
commit 10e6c99196
6 files changed
+342 -8

No files matched your search

+40 -2
View File
@@ -16,6 +16,7 @@
from __future__ import annotations
import math
import os
from datetime import datetime, timezone
from typing import Any
@@ -81,6 +82,37 @@ Accepted values are ``meshtastic`` (default) and ``meshcore``.
"""
def _parse_lora_freq_env(raw: str | None) -> float | int | None:
"""Parse the ``FREQUENCY`` environment variable into a numeric LoRa frequency.
Returns an :class:`int` for whole-number strings (e.g. ``"868"``), a
:class:`float` for decimal strings (e.g. ``"869.525"``), or ``None`` when
*raw* is empty, absent, non-numeric, or non-finite (e.g. ``"inf"``).
Non-numeric labels such as ``"EU_868"`` intentionally return ``None`` so
that :data:`LORA_FREQ` is left unset and :func:`~interfaces._ensure_radio_metadata`
can still populate it from the detected radio configuration.
Parameters:
raw: Raw value of the ``FREQUENCY`` environment variable.
Returns:
Numeric frequency value, or ``None``.
"""
if not raw:
return None
stripped = raw.strip()
if not stripped:
return None
try:
as_float = float(stripped)
except ValueError:
return None
if not math.isfinite(as_float):
return None
return int(as_float) if as_float == int(as_float) else as_float
def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]:
"""Normalise a comma-separated list of channel names.
@@ -221,8 +253,14 @@ API_TOKEN = INSTANCES[0][1] if INSTANCES else os.environ.get("API_TOKEN", "")
ENERGY_SAVING = os.environ.get("ENERGY_SAVING") == "1"
"""When ``True``, enables the ingestor's energy saving mode."""
LORA_FREQ: float | int | str | None = None
"""Frequency of the local node's configured LoRa region in MHz or raw region label."""
LORA_FREQ: float | int | str | None = _parse_lora_freq_env(os.environ.get("FREQUENCY"))
"""Frequency of the local node's configured LoRa region in MHz or raw region label.
Pre-seeded from the ``FREQUENCY`` environment variable when set to a finite
numeric value, allowing operators to override auto-detected values.
Non-numeric or non-finite values are ignored so that auto-detection from the
radio interface can still fill this in.
"""
MODEM_PRESET: str | None = None
"""CamelCase modem preset name reported by the local node."""
+91 -2
View File
@@ -511,16 +511,96 @@ def _resolve_lora_message(local_config: Any) -> Any | None:
return None
# Maps Meshtastic region enum name to (base_freq_MHz, channel_spacing_MHz).
# Values are derived from the Meshtastic firmware RegionInfo tables.
# Used by _computed_channel_frequency to derive the actual radio frequency
# from the region and channel index.
_REGION_CHANNEL_PARAMS: dict[str, tuple[float, float]] = {
"US": (902.0, 0.25), # 902–928 MHz; e.g. ch 52 ≈ 915 MHz at 250 kHz spacing
"EU_433": (433.175, 0.2),
"EU_868": (869.525, 0.5), # actual primary ≈ 869.525 MHz, not 868
"CN": (470.0, 0.2),
"JP": (920.875, 0.5),
"ANZ": (916.0, 0.5),
"KR": (921.9, 0.5),
"TW": (923.0, 0.5),
"RU": (868.9, 0.5),
"IN": (865.0, 0.5),
"NZ_865": (864.0, 0.5),
"TH": (920.0, 0.5),
"LORA_24": (2400.0, 0.5),
"UA_433": (433.175, 0.2),
"UA_868": (868.0, 0.5),
"MY_433": (433.0, 0.2),
"MY_919": (919.0, 0.5),
"SG_923": (923.0, 0.5),
"PH_433": (433.0, 0.2),
"PH_868": (868.0, 0.5),
"PH_915": (915.0, 0.5),
"ANZ_433": (433.0, 0.2),
"KZ_433": (433.0, 0.2),
"KZ_863": (863.125, 0.5),
"NP_865": (865.0, 0.5),
"BR_902": (902.0, 0.25),
# IL (Israel) is absent from meshtastic Python lib 2.7.8 protobufs; the
# enum value is unresolvable at runtime. Operators on IL firmware should
# set the FREQUENCY environment variable to override.
}
def _computed_channel_frequency(
enum_name: str | None,
channel_num: int | None,
) -> int | None:
"""Compute the floor MHz frequency for a known region and channel index.
Looks up *enum_name* in :data:`_REGION_CHANNEL_PARAMS` and returns
``floor(base_freq + channel_num * spacing)``. Returns ``None`` when the
region is not in the table. A missing or negative *channel_num* is
treated as 0 so the base frequency is always usable.
Args:
enum_name: Region enum name as returned by
:func:`_enum_name_from_field`, e.g. ``"EU_868"`` or ``"US"``.
channel_num: Zero-based channel index from the device LoRa config.
Returns:
Floored MHz as :class:`int`, or ``None`` if the region is unknown.
"""
if enum_name is None:
return None
params = _REGION_CHANNEL_PARAMS.get(enum_name)
if params is None:
return None
base, spacing = params
idx = channel_num if (isinstance(channel_num, int) and channel_num >= 0) else 0
return math.floor(base + idx * spacing)
def _region_frequency(lora_message: Any) -> int | float | str | None:
"""Derive the LoRa region frequency in MHz or the region label from ``lora_message``.
Numeric override values are floored to the nearest MHz to align with the
integer frequencies expected elsewhere in the ingestion pipeline.
Frequency sources are tried in priority order:
1. ``override_frequency > 0`` — explicit radio override, floored to MHz.
2. :data:`_REGION_CHANNEL_PARAMS` lookup + ``channel_num`` — actual
band-plan frequency derived from the device's region and channel index,
floored to MHz.
3. Largest digit token ≥ 100 parsed from the region enum name string.
4. Largest digit token < 100 from the enum name (reversed scan).
5. Full enum name string, raw integer ≥ 100, or raw string as a label.
Args:
lora_message: A LoRa config protobuf message or compatible object.
Returns:
An integer MHz frequency, a fallback string label, or ``None``.
"""
if lora_message is None:
return None
# Step 1 — explicit radio override
override_frequency = getattr(lora_message, "override_frequency", None)
if override_frequency is not None:
if isinstance(override_frequency, (int, float)):
@@ -533,6 +613,15 @@ def _region_frequency(lora_message: Any) -> int | float | str | None:
if region_value is None:
return None
enum_name = _enum_name_from_field(lora_message, "region", region_value)
# Step 2 — lookup table + channel offset (actual band-plan frequency)
if enum_name:
channel_num = getattr(lora_message, "channel_num", None)
computed = _computed_channel_frequency(enum_name, channel_num)
if computed is not None:
return computed
# Steps 3–5 — parse digits from enum name (fallback for unknown regions)
if enum_name:
digits = re.findall(r"\d+", enum_name)
for token in digits:
+82
View File
@@ -287,3 +287,85 @@ class TestProtocolValidation:
# Restore to valid value so subsequent tests work
monkeypatch.setenv("PROTOCOL", "meshtastic")
importlib.reload(config)
# ---------------------------------------------------------------------------
# _parse_lora_freq_env
# ---------------------------------------------------------------------------
class TestParseLoraFreqEnv:
"""Tests for :func:`config._parse_lora_freq_env`."""
def test_none_returns_none(self):
"""None input returns None."""
assert config._parse_lora_freq_env(None) is None
def test_empty_string_returns_none(self):
"""Empty string returns None."""
assert config._parse_lora_freq_env("") is None
def test_whitespace_only_returns_none(self):
"""Whitespace-only string returns None."""
assert config._parse_lora_freq_env(" ") is None
def test_integer_string_returns_int(self):
"""Whole-number string returns int."""
result = config._parse_lora_freq_env("868")
assert result == 868
assert isinstance(result, int)
def test_float_integer_value_returns_int(self):
"""String like '915.0' (whole float) returns int 915."""
result = config._parse_lora_freq_env("915.0")
assert result == 915
assert isinstance(result, int)
def test_decimal_string_returns_float(self):
"""Decimal string returns float."""
result = config._parse_lora_freq_env("869.525")
assert result == pytest.approx(869.525)
assert isinstance(result, float)
def test_non_numeric_label_returns_none(self):
"""Non-numeric string returns None so auto-detection is not blocked."""
assert config._parse_lora_freq_env("EU_868") is None
def test_unit_suffixed_string_returns_none(self):
"""String like '915MHz' returns None (not numeric)."""
assert config._parse_lora_freq_env("915MHz") is None
def test_inf_returns_none(self):
"""'inf' is non-finite and returns None."""
assert config._parse_lora_freq_env("inf") is None
def test_large_exponent_returns_none(self):
"""'1e309' overflows to inf and returns None."""
assert config._parse_lora_freq_env("1e309") is None
def test_nan_returns_none(self):
"""'nan' is non-finite and returns None."""
assert config._parse_lora_freq_env("nan") is None
def test_whitespace_stripped(self):
"""Leading/trailing whitespace is ignored."""
assert config._parse_lora_freq_env(" 919 ") == 919
def test_frequency_env_preseeds_lora_freq(self, monkeypatch):
"""FREQUENCY env var pre-seeds LORA_FREQ at module load."""
import importlib
monkeypatch.setenv("FREQUENCY", "915")
importlib.reload(config)
assert config.LORA_FREQ == 915
# Restore
monkeypatch.delenv("FREQUENCY")
importlib.reload(config)
def test_no_frequency_env_leaves_lora_freq_none(self, monkeypatch):
"""Absent FREQUENCY env var leaves LORA_FREQ as None."""
import importlib
monkeypatch.delenv("FREQUENCY", raising=False)
importlib.reload(config)
assert config.LORA_FREQ is None
+1 -1
View File
@@ -227,7 +227,7 @@ def test_region_frequency_and_resolution_helpers():
assert freq == "915MHz"
freq = interfaces._region_frequency(LoraMessage(2))
assert freq == "US"
assert freq == 902 # "US" is in the region lookup table → base 902 MHz
class StringRegionMessage:
def __init__(self, region):
+125
View File
@@ -267,6 +267,72 @@ class TestEnumNameFromField:
assert ifaces._enum_name_from_field(msg, "region", 3) == "US_915"
# ---------------------------------------------------------------------------
# _computed_channel_frequency
# ---------------------------------------------------------------------------
class TestComputedChannelFrequency:
"""Tests for :func:`interfaces._computed_channel_frequency`."""
def test_none_enum_name_returns_none(self):
"""None enum_name returns None."""
assert ifaces._computed_channel_frequency(None, 0) is None
def test_unknown_region_returns_none(self):
"""Enum name not in lookup table returns None."""
assert ifaces._computed_channel_frequency("UNKNOWN_REGION", 0) is None
def test_us_channel_0_base_frequency(self):
"""US region, channel 0, returns floor(902.0 + 0*0.25) = 902."""
assert ifaces._computed_channel_frequency("US", 0) == 902
def test_us_channel_52_mid_band(self):
"""US region, channel 52, returns floor(902.0 + 52*0.25) = 915."""
assert ifaces._computed_channel_frequency("US", 52) == 915
def test_eu_868_channel_0_returns_869(self):
"""EU_868 region, channel 0, returns floor(869.525) = 869, not 868."""
assert ifaces._computed_channel_frequency("EU_868", 0) == 869
def test_eu_868_channel_1_returns_870(self):
"""EU_868 region, channel 1, returns floor(869.525 + 0.5) = 870."""
assert ifaces._computed_channel_frequency("EU_868", 1) == 870
def test_my_919_channel_0(self):
"""MY_919 region, channel 0, returns floor(919.0) = 919."""
assert ifaces._computed_channel_frequency("MY_919", 0) == 919
def test_lora_24_channel_0(self):
"""LORA_24 region, channel 0, returns floor(2400.0) = 2400."""
assert ifaces._computed_channel_frequency("LORA_24", 0) == 2400
def test_none_channel_num_defaults_to_zero(self):
"""None channel_num is treated as 0, returning the base frequency."""
assert ifaces._computed_channel_frequency("ANZ", None) == 916
def test_negative_channel_num_clamped_to_zero(self):
"""Negative channel_num is clamped to 0, returning the base frequency."""
assert ifaces._computed_channel_frequency("ANZ", -1) == 916
def test_result_is_int(self):
"""Return type is int (math.floor result), not float."""
result = ifaces._computed_channel_frequency("EU_868", 0)
assert isinstance(result, int)
def test_nz_865_channel_0(self):
"""NZ_865 region, channel 0, returns floor(864.0) = 864."""
assert ifaces._computed_channel_frequency("NZ_865", 0) == 864
def test_br_902_channel_4_spacing_0_25(self):
"""BR_902 region, channel 4, returns floor(902.0 + 4*0.25) = 903."""
assert ifaces._computed_channel_frequency("BR_902", 4) == 903
def test_kz_863_channel_0(self):
"""KZ_863 region, channel 0, returns floor(863.125) = 863."""
assert ifaces._computed_channel_frequency("KZ_863", 0) == 863
# ---------------------------------------------------------------------------
# _region_frequency
# ---------------------------------------------------------------------------
@@ -323,6 +389,65 @@ class TestRegionFrequency:
msg = SimpleNamespace(DESCRIPTOR=None, override_frequency=None, region="EU433")
assert ifaces._region_frequency(msg) == "EU433"
def test_us_enum_lookup_table_used(self):
"""US region with channel_num=0 returns 902 from lookup table, not None."""
enum_val = SimpleNamespace(name="US")
enum_type = SimpleNamespace(values_by_number={1: enum_val})
field_desc = SimpleNamespace(enum_type=enum_type)
desc = SimpleNamespace(fields_by_name={"region": field_desc})
msg = SimpleNamespace(
DESCRIPTOR=desc, override_frequency=None, region=1, channel_num=0
)
assert ifaces._region_frequency(msg) == 902
def test_eu_868_returns_869_not_868(self):
"""EU_868 region returns 869 from lookup table, not 868 parsed from name."""
enum_val = SimpleNamespace(name="EU_868")
enum_type = SimpleNamespace(values_by_number={3: enum_val})
field_desc = SimpleNamespace(enum_type=enum_type)
desc = SimpleNamespace(fields_by_name={"region": field_desc})
msg = SimpleNamespace(
DESCRIPTOR=desc, override_frequency=None, region=3, channel_num=0
)
assert ifaces._region_frequency(msg) == 869
def test_unrecognised_int_falls_through(self):
"""Raw int region with no DESCRIPTOR and value < 100 returns None."""
msg = SimpleNamespace(DESCRIPTOR=None, override_frequency=None, region=99)
assert ifaces._region_frequency(msg) is None
def test_missing_channel_num_attr_uses_base(self):
"""Region in lookup table with no channel_num attribute returns base freq."""
enum_val = SimpleNamespace(name="MY_919")
enum_type = SimpleNamespace(values_by_number={17: enum_val})
field_desc = SimpleNamespace(enum_type=enum_type)
desc = SimpleNamespace(fields_by_name={"region": field_desc})
# deliberately no channel_num attribute
msg = SimpleNamespace(DESCRIPTOR=desc, override_frequency=None, region=17)
assert ifaces._region_frequency(msg) == 919
def test_override_takes_priority_over_lookup_table(self):
"""override_frequency takes priority over the lookup table."""
enum_val = SimpleNamespace(name="EU_868")
enum_type = SimpleNamespace(values_by_number={3: enum_val})
field_desc = SimpleNamespace(enum_type=enum_type)
desc = SimpleNamespace(fields_by_name={"region": field_desc})
msg = SimpleNamespace(
DESCRIPTOR=desc, override_frequency=867.3, region=3, channel_num=0
)
assert ifaces._region_frequency(msg) == 867
def test_unknown_enum_name_falls_to_digit_parse(self):
"""Enum name not in lookup table falls through to digit parsing."""
enum_val = SimpleNamespace(name="FUTURE_999")
enum_type = SimpleNamespace(values_by_number={99: enum_val})
field_desc = SimpleNamespace(enum_type=enum_type)
desc = SimpleNamespace(fields_by_name={"region": field_desc})
msg = SimpleNamespace(
DESCRIPTOR=desc, override_frequency=None, region=99, channel_num=0
)
assert ifaces._region_frequency(msg) == 999
# ---------------------------------------------------------------------------
# _camelcase_enum_name
+3 -3
View File
@@ -589,10 +589,10 @@ def test_ensure_radio_metadata_extracts_config(mesh_module, capsys):
first_log = capsys.readouterr().out
assert iface.wait_calls == 1
assert mesh.config.LORA_FREQ == 868
assert mesh.config.LORA_FREQ == 869
assert mesh.config.MODEM_PRESET == "MediumFast"
assert "Captured LoRa radio metadata" in first_log
assert "lora_freq=868" in first_log
assert "lora_freq=869" in first_log
assert "modem_preset='MediumFast'" in first_log
secondary_lora = make_lora(7, "US_915", 2, "LONG_FAST", preset_field="preset")
@@ -602,7 +602,7 @@ def test_ensure_radio_metadata_extracts_config(mesh_module, capsys):
second_log = capsys.readouterr().out
assert second_iface.wait_calls == 1
assert mesh.config.LORA_FREQ == 868
assert mesh.config.LORA_FREQ == 869
assert mesh.config.MODEM_PRESET == "MediumFast"
assert second_log == ""