mirror of
https://github.com/openhop-dev/openhop_repeater.git
synced 2026-10-23 13:32:50 +00:00
The snapshot that keeps non-default radios reporting the band they are actually on was seeded lazily, on the first status publish. That publish waits for a broker to connect, so a node whose MQTT is briefly unreachable could take a live radio save first and bake the pending settings in as though they were what the hardware got -- reproducing the wrong-band attribution the snapshot exists to prevent. Capture it in MeshCoreToMqttPusher.__init__ instead, together with the default radio id. That id was being recomputed from live config on every publish, but fabric.default_radio is itself restart-required: a pending change would refresh the radio that was not retuned and freeze the one that was, reporting both wrongly where rebuilding everything got one right. Seeding at construction also removes the unlocked check-then-set on _applied_radio_map, which the status-publish timer thread shared with whatever triggered the first publish.
653 lines
23 KiB
Python
653 lines
23 KiB
Python
"""Radio attribution on the MQTT observer feed.
|
|
|
|
A two-radio bridge is one MeshCore node with one pubkey, and the path byte it
|
|
appends is the same whichever radio carried the packet, so nothing on the air
|
|
says which band a hop used. The node itself does know, and these tests lock the
|
|
two halves that let an observer read it: the ingress/egress radio ids on each
|
|
published packet, and the id -> frequency map in the status message that gives
|
|
those ids meaning.
|
|
|
|
Single-radio nodes must publish exactly what they published before any of this
|
|
existed.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from repeater.config import (
|
|
build_radio_status_entries,
|
|
capture_radio_status_baseline,
|
|
get_node_info,
|
|
get_radio_for_board,
|
|
)
|
|
from repeater.data_acquisition.mqtt_handler import MeshCoreToMqttPusher
|
|
from repeater.data_acquisition.storage_utils import PacketRecord
|
|
|
|
LOCAL_RADIO = {
|
|
"id": "local",
|
|
"radio_type": "sx1262",
|
|
"radio": {
|
|
"frequency": 869618000,
|
|
"bandwidth": 62500,
|
|
"spreading_factor": 8,
|
|
"coding_rate": 8,
|
|
"preamble_length": 32,
|
|
},
|
|
}
|
|
LINK_RADIO = {
|
|
"id": "link",
|
|
"radio_type": "sx1262",
|
|
"radio": {
|
|
"frequency": 864200000,
|
|
"bandwidth": 62500,
|
|
"spreading_factor": 11,
|
|
"coding_rate": 8,
|
|
"preamble_length": 32,
|
|
},
|
|
}
|
|
|
|
|
|
class _FakeIdentity:
|
|
def __init__(self, public_key_hex: str):
|
|
self._pk = bytes.fromhex(public_key_hex)
|
|
|
|
def get_public_key(self) -> bytes:
|
|
return self._pk
|
|
|
|
|
|
def _packet_record(**overrides) -> dict:
|
|
record = {
|
|
"timestamp": 1700000000.0,
|
|
"type": 4,
|
|
"route": 1,
|
|
"rssi": -90,
|
|
"snr": 7.5,
|
|
"score": 0.5,
|
|
"payload_length": 32,
|
|
"packet_hash": "DEADBEEF" + "00" * 4,
|
|
"raw_packet": bytes(range(40)).hex(),
|
|
"airtime_ms": 123.0,
|
|
}
|
|
record.update(overrides)
|
|
return record
|
|
|
|
|
|
def _serialize(record: dict, include_radio_ids: bool) -> dict:
|
|
packet = PacketRecord.from_packet_record(
|
|
record,
|
|
origin="test-node",
|
|
origin_id="AB" * 32,
|
|
include_radio_ids=include_radio_ids,
|
|
)
|
|
assert packet is not None
|
|
return packet.to_dict()
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Packet payload
|
|
# --------------------------------------------------------------------
|
|
def test_single_radio_payload_carries_no_radio_ids():
|
|
"""The ids say nothing a single-radio status message doesn't already say."""
|
|
payload = _serialize(
|
|
_packet_record(rx_radio_id="radio0", tx_radio_id="radio0", tx_radio_ids=["radio0"]),
|
|
include_radio_ids=False,
|
|
)
|
|
|
|
assert "rx_radio_id" not in payload
|
|
assert "tx_radio_ids" not in payload
|
|
|
|
|
|
def test_single_radio_payload_is_unchanged_from_pre_attribution_builds():
|
|
"""Observers parsing the old schema must see byte-identical JSON."""
|
|
expected_keys = {
|
|
"origin",
|
|
"origin_id",
|
|
"timestamp",
|
|
"type",
|
|
"direction",
|
|
"time",
|
|
"date",
|
|
"len",
|
|
"packet_type",
|
|
"route",
|
|
"payload_len",
|
|
"raw",
|
|
"SNR",
|
|
"RSSI",
|
|
"score",
|
|
"duration",
|
|
"hash",
|
|
}
|
|
|
|
assert set(_serialize(_packet_record(), include_radio_ids=False)) == expected_keys
|
|
|
|
|
|
def test_bridge_egress_on_both_sides_is_one_packet_with_both_radios():
|
|
"""Repeating on both sides duplicates the TX, not the upload: one payload
|
|
naming both egress radios, not two payloads."""
|
|
payload = _serialize(
|
|
_packet_record(
|
|
rx_radio_id="local",
|
|
tx_radio_id="link",
|
|
tx_radio_ids=["link", "local"],
|
|
),
|
|
include_radio_ids=True,
|
|
)
|
|
|
|
assert payload["rx_radio_id"] == "local"
|
|
assert payload["tx_radio_ids"] == ["link", "local"]
|
|
|
|
|
|
def test_tx_radio_ids_is_a_list_even_for_a_single_egress():
|
|
"""A consumer must never have to branch on the JSON type of this field."""
|
|
payload = _serialize(
|
|
_packet_record(rx_radio_id="local", tx_radio_id="link"),
|
|
include_radio_ids=True,
|
|
)
|
|
|
|
assert payload["tx_radio_ids"] == ["link"]
|
|
|
|
|
|
def test_packet_heard_but_not_repeated_has_no_egress_key():
|
|
"""Absent, not empty: the node received it and transmitted nothing."""
|
|
payload = _serialize(
|
|
_packet_record(rx_radio_id="link", tx_radio_id=None, tx_radio_ids=None),
|
|
include_radio_ids=True,
|
|
)
|
|
|
|
assert payload["rx_radio_id"] == "link"
|
|
assert "tx_radio_ids" not in payload
|
|
|
|
|
|
def test_pre_fabric_rows_with_null_ids_omit_the_fields():
|
|
"""Rows written before radio attribution existed carry NULL ids."""
|
|
payload = _serialize(_packet_record(), include_radio_ids=True)
|
|
|
|
assert "rx_radio_id" not in payload
|
|
assert "tx_radio_ids" not in payload
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Status radio map
|
|
# --------------------------------------------------------------------
|
|
def test_radio_status_entries_map_each_id_to_its_air_settings():
|
|
entries = build_radio_status_entries(
|
|
{"radios": [LOCAL_RADIO, LINK_RADIO], "fabric": {"default_radio": "local"}}
|
|
)
|
|
|
|
assert entries == [
|
|
{"id": "local", "radio": "869.618,62.5,8,8"},
|
|
{"id": "link", "radio": "864.2,62.5,11,8"},
|
|
]
|
|
|
|
|
|
def test_radio_status_entry_matches_the_single_radio_status_string():
|
|
"""The per-radio value reuses the top-level ``radio`` format, so consumers
|
|
need no second parser."""
|
|
config = {
|
|
"radio_type": "sx1262",
|
|
"radio": LOCAL_RADIO["radio"],
|
|
"repeater": {"node_name": "test-node"},
|
|
}
|
|
|
|
entries = build_radio_status_entries(config)
|
|
|
|
assert len(entries) == 1
|
|
assert entries[0]["radio"] == get_node_info(config)["radio_config"]
|
|
|
|
|
|
def test_unreadable_radio_settings_publish_no_map_at_all():
|
|
"""A partial map would attribute a packet to the wrong band."""
|
|
broken = {"id": "link", "radio_type": "sx1262", "radio": {"frequency": "not-a-number"}}
|
|
|
|
assert build_radio_status_entries({"radios": [LOCAL_RADIO, broken]}) == []
|
|
|
|
|
|
def _at_power(radio: dict, tx_power) -> dict:
|
|
"""The fixture radio with a tx_power, or with the key removed for None."""
|
|
air = dict(radio["radio"])
|
|
if tx_power is None:
|
|
air.pop("tx_power", None)
|
|
else:
|
|
air["tx_power"] = tx_power
|
|
return {**radio, "radio": air}
|
|
|
|
|
|
def test_each_radio_reports_its_own_tx_power():
|
|
"""Two radios on one node often run different power, and that is not
|
|
derivable from the air settings."""
|
|
entries = build_radio_status_entries(
|
|
{"radios": [_at_power(LOCAL_RADIO, 14), _at_power(LINK_RADIO, 22)]}
|
|
)
|
|
|
|
assert [(entry["id"], entry["tx_power"]) for entry in entries] == [("local", 14), ("link", 22)]
|
|
|
|
|
|
def _built_tx_power(board: dict) -> int:
|
|
"""The tx_power get_radio_for_board actually hands the driver for this config."""
|
|
seen = {}
|
|
|
|
class _Driver:
|
|
def __init__(self, **kwargs):
|
|
seen.update(kwargs)
|
|
|
|
def begin(self):
|
|
return True
|
|
|
|
def __getattr__(self, name):
|
|
return MagicMock()
|
|
|
|
def _kiss(port, baudrate, radio_config, **kwargs):
|
|
seen.update(radio_config)
|
|
return _Driver()
|
|
|
|
with (
|
|
patch("openhop_core.hardware.tcp_radio.TCPLoRaRadio", _Driver),
|
|
patch("openhop_core.hardware.usb_radio.USBLoRaRadio", _Driver),
|
|
patch("openhop_core.hardware.kiss_modem_wrapper.KissModemWrapper", _kiss),
|
|
):
|
|
get_radio_for_board(board)
|
|
return seen["tx_power"]
|
|
|
|
|
|
@pytest.mark.parametrize("radio_type", ["kiss", "modem_tcp", "modem_usb"])
|
|
def test_a_radio_type_default_power_is_what_the_driver_is_given(radio_type):
|
|
"""Asserted against get_radio_for_board rather than against a repeat of the
|
|
same literal, so a default changed there fails here instead of quietly
|
|
making the status map describe a power no radio is using."""
|
|
board = {
|
|
**_at_power(LOCAL_RADIO, None),
|
|
"radio_type": radio_type,
|
|
"kiss": {"port": "/dev/null"},
|
|
"modem_tcp": {"host": "127.0.0.1"},
|
|
"modem_usb": {"port": "/dev/null"},
|
|
}
|
|
radios = [{**board, "id": "local"}, _at_power(LINK_RADIO, 22)]
|
|
|
|
entries = build_radio_status_entries({"radios": radios})
|
|
|
|
assert entries[0]["tx_power"] == _built_tx_power(board)
|
|
|
|
|
|
def test_a_boolean_tx_power_is_reported_as_unknown():
|
|
"""int(True) is 1, a plausible-looking dBm nobody configured."""
|
|
entries = build_radio_status_entries(
|
|
{"radios": [_at_power(LOCAL_RADIO, True), _at_power(LINK_RADIO, 22)]}
|
|
)
|
|
|
|
assert "tx_power" not in entries[0]
|
|
|
|
|
|
def test_an_unreadable_optional_field_does_not_warn_on_every_status(caplog):
|
|
"""Status is published on a timer; a warning here would repeat forever."""
|
|
with caplog.at_level(logging.WARNING, logger="repeater.config"):
|
|
build_radio_status_entries(
|
|
{"radios": [_at_power(LOCAL_RADIO, "loud"), _at_power(LINK_RADIO, 22)]}
|
|
)
|
|
|
|
assert caplog.records == []
|
|
|
|
|
|
def test_an_unknown_tx_power_is_omitted_without_voiding_the_map():
|
|
"""sx1262 has no default power, and power does not decide which band a
|
|
packet was on: the entry still attributes it correctly."""
|
|
entries = build_radio_status_entries(
|
|
{"radios": [_at_power(LOCAL_RADIO, None), _at_power(LINK_RADIO, 22)]}
|
|
)
|
|
|
|
assert "tx_power" not in entries[0]
|
|
assert entries[0]["radio"] == "869.618,62.5,8,8"
|
|
assert entries[1]["tx_power"] == 22
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Status message on the wire
|
|
# --------------------------------------------------------------------
|
|
def _make_config(radios=None) -> dict:
|
|
config = {
|
|
"repeater": {"node_name": "test-node"},
|
|
"radio_type": "sx1262",
|
|
"radio": LOCAL_RADIO["radio"],
|
|
"duty_cycle": {"max_airtime_per_minute": 3600},
|
|
"mqtt_brokers": {
|
|
"iata_code": "LAX",
|
|
"status_interval": 0,
|
|
"brokers": [
|
|
{
|
|
"name": "test-broker",
|
|
"enabled": True,
|
|
"host": "broker.example",
|
|
"port": 1883,
|
|
"transport": "tcp",
|
|
"format": "letsmesh",
|
|
"use_jwt_auth": False,
|
|
"tls": {"enabled": False, "insecure": False},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
if radios:
|
|
config["radios"] = radios
|
|
config["fabric"] = {"tx_mode": "bridge", "default_radio": "link"}
|
|
return config
|
|
|
|
|
|
def _publish_status(config: dict) -> dict:
|
|
pusher = MeshCoreToMqttPusher(local_identity=_FakeIdentity("AB" * 32), config=config)
|
|
conn = pusher.connections[0]
|
|
captured = []
|
|
conn._running = True
|
|
conn.client = MagicMock()
|
|
conn.client.publish = lambda topic, payload, retain=False, qos=0: captured.append(payload)
|
|
|
|
pusher.publish_status(state="online")
|
|
|
|
assert len(captured) == 1
|
|
return json.loads(captured[0])
|
|
|
|
|
|
def test_status_publishes_the_radio_map_for_a_two_radio_node():
|
|
status = _publish_status(_make_config(radios=[LOCAL_RADIO, LINK_RADIO]))
|
|
|
|
assert status["radios"] == [
|
|
{"id": "local", "radio": "869.618,62.5,8,8"},
|
|
{"id": "link", "radio": "864.2,62.5,11,8"},
|
|
]
|
|
|
|
|
|
def test_status_radio_map_carries_tx_power():
|
|
status = _publish_status(
|
|
_make_config(radios=[_at_power(LOCAL_RADIO, 14), _at_power(LINK_RADIO, 22)])
|
|
)
|
|
|
|
assert status["radios"] == [
|
|
{"id": "local", "radio": "869.618,62.5,8,8", "tx_power": 14},
|
|
{"id": "link", "radio": "864.2,62.5,11,8", "tx_power": 22},
|
|
]
|
|
|
|
|
|
def test_status_radio_field_reports_the_default_radio_on_a_fabric_node():
|
|
"""The top-level ``radio:`` block is the base entries inherit from, so it
|
|
can name a band no radio is on. Observers ignoring ``radios`` get the
|
|
radio Fabric transmits on by default."""
|
|
status = _publish_status(_make_config(radios=[LOCAL_RADIO, LINK_RADIO]))
|
|
|
|
assert status["radio"] == "864.2,62.5,11,8"
|
|
|
|
|
|
def test_single_radio_status_has_no_radio_map():
|
|
status = _publish_status(_make_config())
|
|
|
|
assert "radios" not in status
|
|
assert status["radio"] == "869.618,62.5,8,8"
|
|
|
|
|
|
def test_status_ids_resolve_every_radio_id_a_packet_can_name():
|
|
"""The two halves must join: every id the packet payload can carry has an
|
|
entry in the status map."""
|
|
config = _make_config(radios=[LOCAL_RADIO, LINK_RADIO])
|
|
status = _publish_status(config)
|
|
payload = _serialize(
|
|
_packet_record(rx_radio_id="local", tx_radio_id="link", tx_radio_ids=["link", "local"]),
|
|
include_radio_ids=True,
|
|
)
|
|
|
|
known = {entry["id"] for entry in status["radios"]}
|
|
assert payload["rx_radio_id"] in known
|
|
assert set(payload["tx_radio_ids"]) <= known
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Collector wiring
|
|
# --------------------------------------------------------------------
|
|
def _make_collector(tmp_path, radios=None):
|
|
"""A StorageCollector with its storage backends stubbed out."""
|
|
from unittest.mock import patch
|
|
|
|
from repeater.data_acquisition.storage_collector import StorageCollector
|
|
|
|
config = {"storage": {"storage_dir": str(tmp_path)}, "repeater": {"node_name": "test-node"}}
|
|
config["radio_type"] = "sx1262"
|
|
config["radio"] = LOCAL_RADIO["radio"]
|
|
if radios:
|
|
config["radios"] = radios
|
|
|
|
with (
|
|
patch("repeater.data_acquisition.storage_collector.SQLiteHandler"),
|
|
patch("repeater.data_acquisition.storage_collector.RRDToolHandler"),
|
|
patch("repeater.data_acquisition.hardware_stats.HardwareStatsCollector"),
|
|
):
|
|
collector = StorageCollector(config=config)
|
|
|
|
collector._stats_stop_event.set()
|
|
if collector._stats_thread is not None:
|
|
collector._stats_thread.join(timeout=1)
|
|
collector.mqtt_handler = MagicMock()
|
|
collector.mqtt_handler.public_key = "AB" * 32
|
|
return collector
|
|
|
|
|
|
def _published_payload(collector, record) -> dict:
|
|
collector._publish_packet_to_mqtt(record)
|
|
collector.mqtt_handler.publish_packet.assert_called_once()
|
|
return collector.mqtt_handler.publish_packet.call_args[0][0]
|
|
|
|
|
|
def test_collector_publishes_radio_ids_on_a_two_radio_node(tmp_path):
|
|
collector = _make_collector(tmp_path, radios=[LOCAL_RADIO, LINK_RADIO])
|
|
assert collector._multi_radio is True
|
|
|
|
payload = _published_payload(
|
|
collector,
|
|
_packet_record(rx_radio_id="local", tx_radio_id="link", tx_radio_ids=["link", "local"]),
|
|
)
|
|
|
|
assert payload["rx_radio_id"] == "local"
|
|
assert payload["tx_radio_ids"] == ["link", "local"]
|
|
|
|
|
|
def test_collector_omits_radio_ids_on_a_single_radio_node(tmp_path):
|
|
collector = _make_collector(tmp_path)
|
|
assert collector._multi_radio is False
|
|
|
|
payload = _published_payload(
|
|
collector, _packet_record(rx_radio_id="radio0", tx_radio_id="radio0")
|
|
)
|
|
|
|
assert "rx_radio_id" not in payload
|
|
assert "tx_radio_ids" not in payload
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# A live save must not move a radio that was not retuned
|
|
# --------------------------------------------------------------------
|
|
def test_applied_map_holds_non_default_radios_across_a_live_edit():
|
|
"""Entries inherit the top-level radio block key by key, so editing the
|
|
default radio changes what a partial entry is *configured* with. Only the
|
|
default radio is actually retuned, so the rest must keep reporting the band
|
|
they are still transmitting on."""
|
|
config = {
|
|
"radio_type": "sx1262",
|
|
"radio": {
|
|
"frequency": 869618000,
|
|
"bandwidth": 62500,
|
|
"spreading_factor": 8,
|
|
"coding_rate": 8,
|
|
"preamble_length": 32,
|
|
},
|
|
"fabric": {"default_radio": "local"},
|
|
"radios": [
|
|
{"id": "local", "radio": {"frequency": 869618000}},
|
|
# Omits bandwidth/SF/CR, so it inherits them.
|
|
{"id": "link", "radio": {"frequency": 864200000}},
|
|
],
|
|
}
|
|
|
|
applied = {entry["id"]: entry for entry in build_radio_status_entries(config)}
|
|
assert applied["link"]["radio"] == "864.2,62.5,8,8"
|
|
|
|
# What api_endpoints._set_radio_param does for the default radio: write the
|
|
# target entry, and mirror it into the legacy top-level block.
|
|
config["radios"][0]["radio"]["bandwidth"] = 500000
|
|
config["radio"]["bandwidth"] = 500000
|
|
|
|
entries = build_radio_status_entries(config, applied=applied)
|
|
by_id = {entry["id"]: entry for entry in entries}
|
|
|
|
assert by_id["local"]["radio"] == "869.618,500.0,8,8" # really retuned
|
|
assert by_id["link"]["radio"] == "864.2,62.5,8,8" # still on 62.5 kHz
|
|
|
|
# Without the applied map, link would follow the mirrored top-level value
|
|
# onto a band its hardware was never given -- the bug this guards.
|
|
naive = {e["id"]: e["radio"] for e in build_radio_status_entries(config)}
|
|
assert naive["link"] == "864.2,500.0,8,8"
|
|
|
|
|
|
def test_applied_map_is_ignored_for_a_radio_the_config_has_just_added():
|
|
"""A radio with no applied entry reports what it is configured with.
|
|
|
|
Adding a radio is restart-required, so until then it has no hardware on the
|
|
air at all and arguably should not be published. This pins the behaviour
|
|
that was already there -- the map has always been built from the current
|
|
radios[] list -- rather than endorsing it; fixing it means publishing the
|
|
boot topology, which is a change of its own.
|
|
"""
|
|
config = {
|
|
"radio_type": "sx1262",
|
|
"radio": LOCAL_RADIO["radio"],
|
|
"fabric": {"default_radio": "local"},
|
|
"radios": [LOCAL_RADIO, LINK_RADIO],
|
|
}
|
|
applied = {"local": {"id": "local", "radio": "869.618,62.5,8,8"}}
|
|
|
|
entries = build_radio_status_entries(config, applied=applied)
|
|
|
|
assert entries[1] == {"id": "link", "radio": "864.2,62.5,11,8"}
|
|
|
|
|
|
def test_no_applied_map_rebuilds_every_entry_from_config():
|
|
"""A fresh start has nothing to preserve."""
|
|
config = {"radios": [LOCAL_RADIO, LINK_RADIO], "fabric": {"default_radio": "local"}}
|
|
|
|
assert build_radio_status_entries(config) == build_radio_status_entries(config, applied=None)
|
|
|
|
|
|
def _publish_status_across_a_live_edit(config: dict, mutate) -> tuple:
|
|
"""Two status publishes from one pusher, with a config edit between them."""
|
|
pusher = MeshCoreToMqttPusher(local_identity=_FakeIdentity("AB" * 32), config=config)
|
|
conn = pusher.connections[0]
|
|
captured = []
|
|
conn._running = True
|
|
conn.client = MagicMock()
|
|
conn.client.publish = lambda topic, payload, retain=False, qos=0: captured.append(payload)
|
|
|
|
pusher.publish_status(state="online")
|
|
mutate(config)
|
|
pusher.publish_status(state="online")
|
|
|
|
assert len(captured) == 2
|
|
return tuple(json.loads(payload) for payload in captured)
|
|
|
|
|
|
def test_published_status_holds_a_non_retuned_radio_across_a_live_edit():
|
|
"""The handler-level version of the same guarantee: an observer must not be
|
|
told a radio moved band when only the default radio was retuned."""
|
|
local = copy.deepcopy(LOCAL_RADIO)
|
|
config = _make_config(radios=[local, {"id": "link", "radio": {"frequency": 864200000}}])
|
|
config["radio"] = copy.deepcopy(LOCAL_RADIO["radio"])
|
|
config["fabric"]["default_radio"] = "local"
|
|
|
|
def widen_the_default_radio(cfg):
|
|
# Mirrors api_endpoints._set_radio_param for the default radio.
|
|
cfg["radios"][0]["radio"]["bandwidth"] = 500000
|
|
cfg["radio"]["bandwidth"] = 500000
|
|
|
|
first, second = _publish_status_across_a_live_edit(config, widen_the_default_radio)
|
|
|
|
assert {e["id"]: e["radio"] for e in first["radios"]} == {
|
|
"local": "869.618,62.5,8,8",
|
|
"link": "864.2,62.5,8,8",
|
|
}
|
|
assert {e["id"]: e["radio"] for e in second["radios"]} == {
|
|
"local": "869.618,500.0,8,8", # retuned
|
|
"link": "864.2,62.5,8,8", # untouched, still reported as it is
|
|
}
|
|
|
|
|
|
def test_a_live_save_before_the_first_publish_does_not_poison_the_baseline():
|
|
"""The first status publish waits for a broker to connect. A node whose MQTT
|
|
is briefly unreachable can take a radio save first, so a baseline captured
|
|
on that first publish would bake the pending settings in as though they were
|
|
what the hardware got."""
|
|
config = _make_config(
|
|
radios=[copy.deepcopy(LOCAL_RADIO), {"id": "link", "radio": {"frequency": 864200000}}]
|
|
)
|
|
config["radio"] = copy.deepcopy(LOCAL_RADIO["radio"])
|
|
config["fabric"]["default_radio"] = "local"
|
|
|
|
pusher = MeshCoreToMqttPusher(local_identity=_FakeIdentity("AB" * 32), config=config)
|
|
|
|
# The save lands while MQTT is still down, before any status was published.
|
|
config["radios"][0]["radio"]["bandwidth"] = 500000
|
|
config["radio"]["bandwidth"] = 500000
|
|
|
|
conn = pusher.connections[0]
|
|
captured = []
|
|
conn._running = True
|
|
conn.client = MagicMock()
|
|
conn.client.publish = lambda topic, payload, retain=False, qos=0: captured.append(payload)
|
|
pusher.publish_status(state="online")
|
|
|
|
by_id = {e["id"]: e["radio"] for e in json.loads(captured[0])["radios"]}
|
|
assert by_id["local"] == "869.618,500.0,8,8" # really retuned
|
|
assert by_id["link"] == "864.2,62.5,8,8" # never was
|
|
|
|
|
|
def test_a_pending_default_radio_change_does_not_move_which_entry_is_refreshed():
|
|
"""``fabric.default_radio`` is restart-required, so the running Fabric keeps
|
|
the default it booted with. Reading the default from live config would
|
|
refresh the radio that was not retuned and freeze the one that was --
|
|
reporting both wrongly, where rebuilding everything at least got one right.
|
|
"""
|
|
config = {
|
|
"radio_type": "sx1262",
|
|
"radio": {
|
|
"frequency": 869618000,
|
|
"bandwidth": 62500,
|
|
"spreading_factor": 8,
|
|
"coding_rate": 8,
|
|
"preamble_length": 32,
|
|
},
|
|
"fabric": {"default_radio": "link"},
|
|
"radios": [
|
|
{"id": "local", "radio": {"frequency": 869618000}},
|
|
{"id": "link", "radio": {"frequency": 864200000}},
|
|
],
|
|
}
|
|
|
|
applied, default_id = capture_radio_status_baseline(config)
|
|
assert default_id == "link"
|
|
|
|
# A save retunes the running default (link), and an import queues a change
|
|
# of default that only takes effect on restart.
|
|
config["radios"][1]["radio"]["bandwidth"] = 500000
|
|
config["radio"]["bandwidth"] = 500000
|
|
config["fabric"]["default_radio"] = "local"
|
|
|
|
by_id = {
|
|
e["id"]: e["radio"]
|
|
for e in build_radio_status_entries(config, applied=applied, default_radio_id=default_id)
|
|
}
|
|
|
|
assert by_id["link"] == "864.2,500.0,8,8" # the radio that was retuned
|
|
assert by_id["local"] == "869.618,62.5,8,8" # the radio that was not
|
|
|
|
# Following the pending default instead gets both radios wrong.
|
|
naive = {e["id"]: e["radio"] for e in build_radio_status_entries(config, applied=applied)}
|
|
assert naive["link"] == "864.2,62.5,8,8"
|
|
assert naive["local"] == "869.618,500.0,8,8"
|