mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
data: better lora frequency handling for meshtastic (#733)
* data: better lora frequency handling for meshtastic * ingestor: address review comments
This commit is contained in:
6 files changed
+342
-8
No files matched your search
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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 == ""
|
||||
|
||||
|
||||
Reference in new issue
Block a user