diff --git a/data/mesh_ingestor/interfaces/__init__.py b/data/mesh_ingestor/interfaces/__init__.py index e261113..2653f65 100644 --- a/data/mesh_ingestor/interfaces/__init__.py +++ b/data/mesh_ingestor/interfaces/__init__.py @@ -79,6 +79,7 @@ from .radio import ( # noqa: E402 _REGION_CHANNEL_PARAMS, _camelcase_enum_name, _computed_channel_frequency, + _custom_preset_label, _ensure_radio_metadata, _enum_name_from_field, _has_field, diff --git a/data/mesh_ingestor/interfaces/radio.py b/data/mesh_ingestor/interfaces/radio.py index 7970022..dfe9295 100644 --- a/data/mesh_ingestor/interfaces/radio.py +++ b/data/mesh_ingestor/interfaces/radio.py @@ -222,11 +222,46 @@ def _camelcase_enum_name(name: str | None) -> str | None: return "".join(camel_parts) +def _custom_preset_label(lora_message: Any) -> str: + """Return a compact custom-radio label when ``use_preset`` is disabled. + + Reads ``spread_factor``, ``bandwidth``, and ``coding_rate`` from + *lora_message*. When all three are non-zero the label follows the same + compact convention used by MeshCore (e.g. ``"Custom SF8/BW62/CR6"``). + When any parameter is absent or zero, returns the bare ``"Custom"`` string + so the caller always gets a non-empty label. + + Args: + lora_message: A LoRa config protobuf message or compatible object. + + Returns: + A string starting with ``"Custom"``. + """ + sf = getattr(lora_message, "spread_factor", None) or None + bw = getattr(lora_message, "bandwidth", None) or None + cr = getattr(lora_message, "coding_rate", None) or None + if sf and bw and cr: + return f"Custom SF{int(sf)}/BW{int(bw)}/CR{int(cr)}" + return "Custom" + + def _modem_preset(lora_message: Any) -> str | None: - """Return the CamelCase modem preset configured on ``lora_message``.""" + """Return the CamelCase modem preset configured on ``lora_message``. + + When ``lora_message.use_preset`` is explicitly ``False``, the device is + using raw radio parameters instead of a named preset. In that case the + enum value (which defaults to 0 / LongFast even in custom mode) is + ignored and :func:`_custom_preset_label` is returned instead. + """ if lora_message is None: return None + # use_preset=False means the device is in custom-parameter mode. The + # modem_preset enum field defaults to 0 (LongFast) even then, so we must + # check this flag before reading the enum. `is False` (identity check) + # avoids false positives when the attribute is absent (returns None). + if getattr(lora_message, "use_preset", None) is False: + return _custom_preset_label(lora_message) descriptor = getattr(lora_message, "DESCRIPTOR", None) fields_by_name = getattr(descriptor, "fields_by_name", {}) if descriptor else {} if "modem_preset" in fields_by_name: diff --git a/tests/test_interfaces_unit.py b/tests/test_interfaces_unit.py index 79fcdfb..996357f 100644 --- a/tests/test_interfaces_unit.py +++ b/tests/test_interfaces_unit.py @@ -615,6 +615,108 @@ class TestModemPreset: msg = SimpleNamespace(DESCRIPTOR=desc, modem_preset=99) assert ifaces._modem_preset(msg) is None + def test_use_preset_false_full_params(self): + """use_preset=False with all params returns compact custom label.""" + msg = SimpleNamespace( + DESCRIPTOR=None, + use_preset=False, + spread_factor=8, + bandwidth=62, + coding_rate=6, + ) + assert ifaces._modem_preset(msg) == "Custom SF8/BW62/CR6" + + def test_use_preset_false_partial_params(self): + """use_preset=False with only some params returns bare 'Custom'.""" + msg = SimpleNamespace( + DESCRIPTOR=None, + use_preset=False, + spread_factor=8, + bandwidth=62, + # coding_rate absent + ) + assert ifaces._modem_preset(msg) == "Custom" + + def test_use_preset_false_no_params(self): + """use_preset=False with no radio params returns bare 'Custom'.""" + msg = SimpleNamespace(DESCRIPTOR=None, use_preset=False) + assert ifaces._modem_preset(msg) == "Custom" + + def test_use_preset_true_falls_through_to_enum(self): + """use_preset=True is ignored; existing enum logic is used.""" + enum_val = SimpleNamespace(name="LONG_FAST") + enum_type = SimpleNamespace(values_by_number={0: enum_val}) + field_desc = SimpleNamespace(enum_type=enum_type) + desc = SimpleNamespace(fields_by_name={"modem_preset": field_desc}) + msg = SimpleNamespace( + DESCRIPTOR=desc, + use_preset=True, + modem_preset=0, + spread_factor=8, + bandwidth=62, + coding_rate=6, + ) + assert ifaces._modem_preset(msg) == "LongFast" + + def test_use_preset_absent_falls_through_to_enum(self): + """No use_preset attribute falls through to existing enum logic.""" + enum_val = SimpleNamespace(name="LONG_FAST") + enum_type = SimpleNamespace(values_by_number={0: enum_val}) + field_desc = SimpleNamespace(enum_type=enum_type) + desc = SimpleNamespace(fields_by_name={"modem_preset": field_desc}) + # SimpleNamespace with no use_preset attribute + msg = SimpleNamespace(DESCRIPTOR=desc, modem_preset=0) + assert ifaces._modem_preset(msg) == "LongFast" + + +# --------------------------------------------------------------------------- +# _custom_preset_label +# --------------------------------------------------------------------------- + + +class TestCustomPresetLabel: + """Tests for :func:`interfaces._custom_preset_label`.""" + + def test_all_params_present(self): + """All three non-zero params produce the compact label.""" + msg = SimpleNamespace(spread_factor=12, bandwidth=125, coding_rate=5) + assert ifaces._custom_preset_label(msg) == "Custom SF12/BW125/CR5" + + def test_integer_conversion(self): + """Float param values are cast to int in the label.""" + msg = SimpleNamespace(spread_factor=8.0, bandwidth=62.5, coding_rate=6.0) + assert ifaces._custom_preset_label(msg) == "Custom SF8/BW62/CR6" + + def test_missing_coding_rate_returns_custom(self): + """Absent coding_rate yields the bare 'Custom' fallback.""" + msg = SimpleNamespace(spread_factor=8, bandwidth=62) + assert ifaces._custom_preset_label(msg) == "Custom" + + def test_missing_bandwidth_returns_custom(self): + """Absent bandwidth yields the bare 'Custom' fallback.""" + msg = SimpleNamespace(spread_factor=8, coding_rate=6) + assert ifaces._custom_preset_label(msg) == "Custom" + + def test_missing_spread_factor_returns_custom(self): + """Absent spread_factor yields the bare 'Custom' fallback.""" + msg = SimpleNamespace(bandwidth=62, coding_rate=6) + assert ifaces._custom_preset_label(msg) == "Custom" + + def test_zero_bandwidth_treated_as_absent(self): + """A zero bandwidth value is treated as absent, yielding 'Custom'.""" + msg = SimpleNamespace(spread_factor=8, bandwidth=0, coding_rate=6) + assert ifaces._custom_preset_label(msg) == "Custom" + + def test_zero_spread_factor_treated_as_absent(self): + """A zero spread_factor value is treated as absent, yielding 'Custom'.""" + msg = SimpleNamespace(spread_factor=0, bandwidth=62, coding_rate=6) + assert ifaces._custom_preset_label(msg) == "Custom" + + def test_no_params_at_all(self): + """A message with none of the param attributes returns 'Custom'.""" + msg = SimpleNamespace() + assert ifaces._custom_preset_label(msg) == "Custom" + # --------------------------------------------------------------------------- # _ensure_radio_metadata caching