diff --git a/repeater/airtime.py b/repeater/airtime.py index 5a70c5e..685295f 100644 --- a/repeater/airtime.py +++ b/repeater/airtime.py @@ -1,5 +1,7 @@ import logging +import threading import time +from dataclasses import dataclass from typing import Optional, Tuple from openhop_core.protocol.packet_utils import calculate_lora_airtime_ms @@ -142,6 +144,100 @@ class AirtimeManager: } +class _RadioAirtimeBudget: + """A radio-specific calculator backed by a possibly shared ledger.""" + + def __init__( + self, + ledger: AirtimeManager, + calculator: AirtimeManager, + lock: threading.RLock, + ) -> None: + self._ledger = ledger + self._calculator = calculator + self._lock = lock + + def _replace(self, ledger: AirtimeManager, calculator: AirtimeManager) -> None: + self._ledger = ledger + self._calculator = calculator + + def calculate_airtime(self, *args, **kwargs) -> float: + with self._lock: + return self._calculator.calculate_airtime(*args, **kwargs) + + def can_transmit(self, airtime_ms: float) -> Tuple[bool, float]: + with self._lock: + return self._ledger.can_transmit(airtime_ms) + + def record_tx(self, airtime_ms: float) -> None: + with self._lock: + self._ledger.record_tx(airtime_ms) + + def record_rx(self, airtime_ms: float) -> None: + with self._lock: + self._ledger.record_rx(airtime_ms) + + def get_stats(self) -> dict: + with self._lock: + return self._ledger.get_stats() + + @property + def max_airtime_per_minute(self) -> float: + with self._lock: + return self._ledger.max_airtime_per_minute + + @property + def tx_history(self) -> list: + with self._lock: + return self._ledger.tx_history + + @property + def total_airtime_ms(self) -> float: + with self._lock: + return self._ledger.total_airtime_ms + + @property + def total_rx_airtime_ms(self) -> float: + with self._lock: + return self._ledger.total_rx_airtime_ms + + @property + def radio_config(self) -> dict: + with self._lock: + return self._calculator.radio_config + + @property + def spreading_factor(self): + with self._lock: + return self._calculator.spreading_factor + + @property + def bandwidth(self): + with self._lock: + return self._calculator.bandwidth + + @property + def coding_rate(self): + with self._lock: + return self._calculator.coding_rate + + @property + def preamble_length(self): + with self._lock: + return self._calculator.preamble_length + + +@dataclass +class _BudgetState: + by_radio: dict + order: list + by_channel: dict + channel_of: dict + profile_backed: bool + default_radio_id: Optional[str] + default: _RadioAirtimeBudget + + class AirtimeBudgets: """The duty-cycle budgets a node meters against: one per channel. @@ -164,22 +260,14 @@ class AirtimeBudgets: def __init__(self, config: dict): self.config = config - # radio_id -> manager, in configured order. - self._managers: dict = {} - self._order: list = [] - # Channel key -> manager, and radio_id -> channel key. Both are how a - # rebuild works out what carries over to what. - self._by_channel: dict = {} - self._channel_of: dict = {} - self._profile_backed = False - self._default_manager: Optional[AirtimeManager] = None - self._build() + self._lock = threading.RLock() + self._state = self._build_state() # ------------------------------------------------------------------ # Construction # ------------------------------------------------------------------ - def _build(self) -> None: + def _build_state(self) -> _BudgetState: from .config import build_metering_profiles try: @@ -188,49 +276,61 @@ class AirtimeBudgets: logger.warning("Could not read radio profiles for duty cycle: %s", exc) profiles = [] - self._managers = {} - self._order = [] - self._by_channel = {} - self._channel_of = {} - self._profile_backed = bool(profiles) - if not profiles: - # Nothing to read: meter exactly as before, keyed on None so a packet - # with no radio id still finds it. - self._order = [None] - self._channel_of = {None: None} - manager = AirtimeManager(self.config) - self._managers = {None: manager} - self._by_channel = {None: manager} - self._default_manager = manager - return + ledger = AirtimeManager(self.config) + view = _RadioAirtimeBudget(ledger, ledger, self._lock) + return _BudgetState( + by_radio={None: view}, + order=[None], + by_channel={None: ledger}, + channel_of={None: None}, + profile_backed=False, + default_radio_id=None, + default=view, + ) shared = bool(self.config.get("duty_cycle", {}).get("shared_budget", False)) - # One budget for every radio when asked for, otherwise one per channel: - # same frequency and bandwidth is the same channel. + order = [] + channel_of = {} for profile in profiles: radio_id = str(profile["radio_id"]) - self._order.append(radio_id) - self._channel_of[radio_id] = ( + order.append(radio_id) + channel_of[radio_id] = ( "shared" if shared else (profile.get("frequency_hz"), profile.get("bandwidth_hz")) ) + by_channel = {} + by_radio = {} for profile in profiles: radio_id = str(profile["radio_id"]) - key = self._channel_of[radio_id] - manager = self._by_channel.get(key) - if manager is None: - manager = AirtimeManager( + key = channel_of[radio_id] + ledger = by_channel.get(key) + if ledger is None: + ledger = AirtimeManager( self.config, radio_config=self._air_settings(profile), max_airtime_per_minute=self._channel_budget( - [rid for rid, rid_key in self._channel_of.items() if rid_key == key] + [rid for rid, rid_key in channel_of.items() if rid_key == key] ), ) - self._by_channel[key] = manager - self._managers[radio_id] = manager + by_channel[key] = ledger + calculator = AirtimeManager( + self.config, + radio_config=self._air_settings(profile), + max_airtime_per_minute=ledger.max_airtime_per_minute, + ) + by_radio[radio_id] = _RadioAirtimeBudget(ledger, calculator, self._lock) - self._default_manager = self._managers[self._default_radio_id()] + default_radio_id = self._default_radio_id(by_radio, order) + return _BudgetState( + by_radio=by_radio, + order=order, + by_channel=by_channel, + channel_of=channel_of, + profile_backed=True, + default_radio_id=default_radio_id, + default=by_radio[default_radio_id], + ) def _air_settings(self, profile: dict) -> dict: """A profile in the shape AirtimeManager reads air settings from. @@ -309,93 +409,68 @@ class AirtimeBudgets: prove it was the quiet one; a second refresh then finds each side's own key and stops there rather than doubling again. """ - previous_channel_of = dict(self._channel_of) - previous_default = self._default_manager - snapshots = { - key: ( - list(manager.tx_history), - manager.total_airtime_ms, - manager.total_rx_airtime_ms, - ) - for key, manager in self._by_channel.items() - } - - self._build() - - for key, manager in self._by_channel.items(): - sources = { - previous_channel_of[radio_id] - for radio_id, radio_key in self._channel_of.items() - if radio_key == key and radio_id in previous_channel_of - } - if not sources: - # A channel nothing was metering: a radio added, or one retuned - # onto a band the node was not using. An empty window on a node - # that has been transmitting would let the next minute's budget - # be spent twice, so it starts from the busiest channel the node - # was already keeping. The busiest single one, not the sum of - # them: summing is what turns repeated rebuilds into 100 -> 400 - # -> 1600 ms and wedges transmission for the rest of the window. - busiest = max( - snapshots.values(), - key=lambda carried: sum(at for _, at in carried[0]), - default=None, + with self._lock: + previous = self._state + snapshots = { + key: ( + list(manager.tx_history), + manager.total_airtime_ms, + manager.total_rx_airtime_ms, ) - if busiest is None: - continue - carried_all = [busiest] + for key, manager in previous.by_channel.items() + } + rebuilt = self._build_state() + + for key, manager in rebuilt.by_channel.items(): + sources = { + previous.channel_of[radio_id] + for radio_id, radio_key in rebuilt.channel_of.items() + if radio_key == key and radio_id in previous.channel_of + } + if not sources: + busiest = max( + snapshots.values(), + key=lambda carried: sum(at for _, at in carried[0]), + default=None, + ) + if busiest is None: + continue + carried_all = [busiest] + else: + carried_all = [snapshots[source] for source in sources] + + history: list = [] + total_tx = 0.0 + total_rx = 0.0 + for carried in carried_all: + history.extend(carried[0]) + total_tx += carried[1] + total_rx += carried[2] + manager.tx_history = sorted(history, key=lambda entry: entry[0]) + manager.total_airtime_ms = total_tx + manager.total_rx_airtime_ms = total_rx + + self._reuse_views(previous, rebuilt) + self._state = rebuilt + + @staticmethod + def _reuse_views(previous: _BudgetState, rebuilt: _BudgetState) -> None: + """Keep radio handles valid across the atomic state swap.""" + default_id = rebuilt.default_radio_id + for radio_id, new_view in list(rebuilt.by_radio.items()): + if radio_id == default_id: + old_view = previous.default else: - carried_all = [snapshots[source] for source in sources] + old_view = previous.by_radio.get(radio_id) + if old_view is previous.default: + old_view = None + if old_view is None or old_view is new_view: + continue + old_view._replace(new_view._ledger, new_view._calculator) + rebuilt.by_radio[radio_id] = old_view + rebuilt.default = rebuilt.by_radio[default_id] - history: list = [] - total_tx = 0.0 - total_rx = 0.0 - for carried in carried_all: - history.extend(carried[0]) - total_tx += carried[1] - total_rx += carried[2] - manager.tx_history = sorted(history, key=lambda entry: entry[0]) - manager.total_airtime_ms = total_tx - manager.total_rx_airtime_ms = total_rx - - self._keep_default_identity(previous_default) - - def _keep_default_identity(self, was_default: Optional[AirtimeManager]) -> None: - """Let the object that was the default manager go on being it. - - Callers hold onto it: main.py hands it to NeighborScopeHelper at boot, - which keeps the reference for the life of the process. Replacing it with - a new object would leave that helper reading a manager nothing charges - any more, so it would see an empty window and stop throttling itself. - Adopt the rebuilt settings into the original object instead. - - Takes the previous default as an argument rather than working it out - again: ``config`` is mutated in place by the daemon, so by the time this - runs ``fabric.default_radio`` may already name a different radio, and - recomputing would preserve the wrong object -- orphaning the one the - helper is holding, which is the failure this exists to prevent. - """ - if was_default is None or self._default_manager is None: - return - rebuilt = self._default_manager - if rebuilt is was_default: - return - - was_default.radio_config = rebuilt.radio_config - was_default.refresh_radio_params(rebuilt.radio_config) - was_default.max_airtime_per_minute = rebuilt.max_airtime_per_minute - was_default.tx_history = rebuilt.tx_history - was_default.total_airtime_ms = rebuilt.total_airtime_ms - was_default.total_rx_airtime_ms = rebuilt.total_rx_airtime_ms - for radio_id, manager in self._managers.items(): - if manager is rebuilt: - self._managers[radio_id] = was_default - for key, manager in self._by_channel.items(): - if manager is rebuilt: - self._by_channel[key] = was_default - self._default_manager = was_default - - def _default_radio_id(self) -> Optional[str]: + def _default_radio_id(self, by_radio: dict, order: list) -> Optional[str]: """The radio Fabric transmits on by default. Mirrors build_radio_stack's rule -- ``fabric.default_radio`` when set, @@ -406,22 +481,24 @@ class AirtimeBudgets: fabric = self.config.get("fabric") fabric = fabric if isinstance(fabric, dict) else {} configured = fabric.get("default_radio") or fabric.get("default_radio_id") - if configured and str(configured) in self._managers: + if configured and str(configured) in by_radio: return str(configured) - return self._order[0] if self._order else None + return order[0] if order else None # ------------------------------------------------------------------ # Reading # ------------------------------------------------------------------ @property - def default(self) -> AirtimeManager: + def default(self) -> _RadioAirtimeBudget: """The manager everything that reports one channel's figures reads.""" - return self._default_manager + with self._lock: + return self._state.default @property def multi(self) -> bool: - return len(self._order) > 1 + with self._lock: + return len(self._state.order) > 1 @property def profile_backed(self) -> bool: @@ -433,26 +510,36 @@ class AirtimeBudgets: what happens if a caller tests ``multi`` and takes the legacy path -- undoes the whole point on every radio save from the web UI. """ - return self._profile_backed + with self._lock: + return self._state.profile_backed def radio_ids(self) -> list: """Configured radio ids, in order. ``[None]`` when nothing was profiled.""" - return list(self._order) + with self._lock: + return list(self._state.order) - def for_radio(self, radio_id: Optional[str]) -> AirtimeManager: + def shares_budget(self, first_radio_id: Optional[str], second_radio_id: Optional[str]) -> bool: + """Whether two radios debit the same channel ledger.""" + with self._lock: + first = self.for_radio(first_radio_id) + second = self.for_radio(second_radio_id) + return first._ledger is second._ledger + + def for_radio(self, radio_id: Optional[str]) -> _RadioAirtimeBudget: """The budget a send on this radio is charged to. An unknown id falls back to the default radio rather than going unmetered: an unrecognised label is a reason to be careful, not a reason to transmit freely. """ - if radio_id is None: - return self.default - manager = self._managers.get(str(radio_id)) - if manager is not None: - return manager - logger.debug("No duty-cycle budget for radio %s; metering on the default", radio_id) - return self.default + with self._lock: + if radio_id is None: + return self._state.default + manager = self._state.by_radio.get(str(radio_id)) + if manager is not None: + return manager + logger.debug("No duty-cycle budget for radio %s; metering on the default", radio_id) + return self._state.default def per_radio_stats(self) -> list: """``[{radio_id, ...stats}]`` on a multi-radio node, else an empty list. @@ -460,12 +547,14 @@ class AirtimeBudgets: Radios sharing a channel report the same figures, because they are the same budget: that is the statement, not a duplication. """ - if not self.multi: - return [] - return [ - {"radio_id": radio_id, **self._managers[radio_id].get_stats()} - for radio_id in self._order - ] + with self._lock: + state = self._state + if len(state.order) <= 1: + return [] + return [ + {"radio_id": radio_id, **state.by_radio[radio_id].get_stats()} + for radio_id in state.order + ] def node_stats(self) -> dict: """One set of figures for the whole node, in AirtimeManager's shape. @@ -485,15 +574,16 @@ class AirtimeBudgets: On a single-radio node every figure is that one manager's, unchanged. """ - managers = list(self._by_channel.values()) - if len(managers) == 1: - return managers[0].get_stats() + with self._lock: + managers = list(self._state.by_channel.values()) + if len(managers) == 1: + return managers[0].get_stats() - stats = [manager.get_stats() for manager in managers] - return { - "current_airtime_ms": sum(s["current_airtime_ms"] for s in stats), - "max_airtime_ms": sum(s["max_airtime_ms"] for s in stats), - "utilization_percent": max(s["utilization_percent"] for s in stats), - "total_airtime_ms": sum(s["total_airtime_ms"] for s in stats), - "total_rx_airtime_ms": sum(s["total_rx_airtime_ms"] for s in stats), - } + stats = [manager.get_stats() for manager in managers] + return { + "current_airtime_ms": sum(s["current_airtime_ms"] for s in stats), + "max_airtime_ms": sum(s["max_airtime_ms"] for s in stats), + "utilization_percent": max(s["utilization_percent"] for s in stats), + "total_airtime_ms": sum(s["total_airtime_ms"] for s in stats), + "total_rx_airtime_ms": sum(s["total_rx_airtime_ms"] for s in stats), + } diff --git a/repeater/config_manager.py b/repeater/config_manager.py index da2de1f..5708d5e 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -163,17 +163,9 @@ class ConfigManager: return budgets = getattr(repeater_handler, "airtime_budgets", None) - if budgets is not None and getattr(budgets, "profile_backed", False): - # Each radio meters on its own modulation, so rebuild them all from - # the config rather than overwriting the default radio's profile - # with the top-level block it does not use. - # - # Tested on profile_backed, not on how many radios there are: a node - # with one radios[] entry has that entry's own bandwidth and its own - # band's limit, and the legacy branch below would overwrite them with - # the top-level block on every radio save from the web UI -- a - # 62.5 kHz radio silently metered at 500 kHz from the first save - # until the next restart. + if budgets is not None: + # Rebuild the container even on a legacy single-radio node because + # it owns both the cached modulation and the cached duty-cycle limit. budgets.refresh() repeater_handler.airtime_mgr = budgets.default return @@ -430,11 +422,17 @@ class ConfigManager: logger.info("radio_type change detected; service restart required") live_update_ok = False + if "radios" in sections: + logger.info("Non-default radio change detected; service restart required") + live_update_ok = False + if "kiss" in sections and self._kiss_transport_restart_required(): live_update_ok = False if "radio" in sections: live_update_ok = self._apply_live_radio_config() and live_update_ok + elif "duty_cycle" in sections: + self._refresh_airtime_radio_params() if "http" in sections: live_update_ok = self._apply_live_http_config() and live_update_ok diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 57c16da..f8fe311 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -4905,26 +4905,41 @@ class APIEndpoints: if "mesh" not in self.config: self.config["mesh"] = {} - # Optional multi-radio target. When radios[] exists and radio_id is - # provided, LoRa air settings are written into that entry's radio{} - # instead of (or in addition to) the legacy top-level radio{}. + # A Fabric edit targets the named radio, or its default when the old + # single-radio request shape omits radio_id. target_radio_id = data.get("radio_id") radios_list = self.config.get("radios") target_radio_cfg = self.config["radio"] target_entry = None - if isinstance(radios_list, list) and target_radio_id: + target_is_default = False + effective_target_id = target_radio_id + if isinstance(radios_list, list) and radios_list: + fabric = ( + self.config.get("fabric") if isinstance(self.config.get("fabric"), dict) else {} + ) + default_id = fabric.get("default_radio") or fabric.get("default_radio_id") + if default_id is None: + first = radios_list[0] if isinstance(radios_list[0], dict) else {} + default_id = first.get("id") or first.get("radio_id") + if effective_target_id is None: + effective_target_id = default_id + + if isinstance(radios_list, list) and effective_target_id: for entry in radios_list: if not isinstance(entry, dict): continue rid = entry.get("id") or entry.get("radio_id") - if str(rid) == str(target_radio_id): + if str(rid) == str(effective_target_id): target_entry = entry if not isinstance(entry.get("radio"), dict): entry["radio"] = {} target_radio_cfg = entry["radio"] break if target_entry is None: - return self._error(f"Unknown radio_id={target_radio_id!r}") + return self._error(f"Unknown radio_id={effective_target_id!r}") + target_is_default = default_id is not None and str(default_id) == str( + effective_target_id + ) def _set_radio_param(key, value): target_radio_cfg[key] = value @@ -4932,21 +4947,10 @@ class APIEndpoints: # radio or when no radios[] list is active. if target_entry is None: self.config["radio"][key] = value - else: - default_id = None - fabric = ( - self.config.get("fabric") - if isinstance(self.config.get("fabric"), dict) - else {} - ) - default_id = fabric.get("default_radio") or fabric.get("default_radio_id") - if default_id is None and radios_list: - first = radios_list[0] if isinstance(radios_list[0], dict) else {} - default_id = first.get("id") or first.get("radio_id") - if default_id is not None and str(default_id) == str(target_radio_id): - if "radio" not in self.config or not isinstance(self.config["radio"], dict): - self.config["radio"] = {} - self.config["radio"][key] = value + elif target_is_default: + if "radio" not in self.config or not isinstance(self.config["radio"], dict): + self.config["radio"] = {} + self.config["radio"][key] = value # Update TX power (up to 30 dBm for high-power radios) if "tx_power" in data: @@ -5115,8 +5119,18 @@ class APIEndpoints: if not applied: return self._error("No valid settings provided") - live_sections = ["repeater", "delays", "radio"] - if target_entry is not None: + live_sections = ["repeater", "delays"] + radio_fields = { + "tx_power", + "frequency", + "bandwidth", + "spreading_factor", + "coding_rate", + } + radio_changed = any(field in data for field in radio_fields) + if radio_changed and (target_entry is None or target_is_default): + live_sections.append("radio") + elif radio_changed: live_sections.append("radios") if "mesh" in self.config and any(k in data for k in ("path_hash_mode", "loop_detect")): live_sections.append("mesh") diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index 2384d03..00c8608 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -2634,6 +2634,69 @@ def test_update_radio_config_owner_info_and_mesh_fields(cherrypy_ctx): assert "owner.info" in out["data"].get("applied", []) +def test_update_non_default_radio_requires_restart(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + request.json = {"radio_id": "link", "bandwidth": 500000} + api = _make_api( + { + "radio": {"bandwidth": 62500}, + "radios": [ + {"id": "local", "radio": {"bandwidth": 62500}}, + {"id": "link", "radio": {"bandwidth": 62500}}, + ], + "fabric": {"default_radio": "local"}, + "repeater": {}, + "delays": {}, + } + ) + api.config_manager.update_and_save.return_value = { + "success": True, + "saved": True, + "live_updated": False, + } + + out = api.update_radio_config() + + assert out["success"] is True + assert out["data"]["restart_required"] is True + sections = api.config_manager.update_and_save.call_args.kwargs["live_update_sections"] + assert "radios" in sections + assert "radio" not in sections + + +def test_update_radio_without_id_keeps_the_fabric_default_entry_in_sync(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "POST" + request.json = {"bandwidth": 500000} + api = _make_api( + { + "radio": {"bandwidth": 62500}, + "radios": [ + {"id": "local", "radio": {"bandwidth": 62500}}, + {"id": "link", "radio": {"bandwidth": 62500}}, + ], + "fabric": {"default_radio": "local"}, + "repeater": {}, + "delays": {}, + } + ) + api.config_manager.update_and_save.return_value = { + "success": True, + "saved": True, + "live_updated": True, + } + + out = api.update_radio_config() + + assert out["success"] is True + assert api.config["radio"]["bandwidth"] == 500000 + assert api.config["radios"][0]["radio"]["bandwidth"] == 500000 + sections = api.config_manager.update_and_save.call_args.kwargs["live_update_sections"] + assert "radio" in sections + assert "radios" not in sections + + class _FakeIdentityObj: def __init__(self, first=0x42): self._pk = bytes([first]) + (b"A" * 31) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 065fc68..9df7a24 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -1,6 +1,6 @@ import math -from repeater.airtime import AirtimeManager +from repeater.airtime import AirtimeBudgets, AirtimeManager from repeater.config_manager import ConfigManager @@ -216,3 +216,67 @@ def test_failed_live_radio_apply_leaves_airtime_manager_unchanged(): assert airtime_mgr.spreading_factor == 7 assert math.isclose(airtime_mgr.calculate_airtime(50), 97.536, rel_tol=1e-9) + + +def test_live_duty_cycle_update_refreshes_the_cached_limit(): + config = { + "radio": { + "frequency": 868000000, + "bandwidth": 125000, + "spreading_factor": 7, + "coding_rate": 5, + "preamble_length": 8, + }, + "duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": True}, + } + budgets = AirtimeBudgets(config) + handler = _DummyRepeaterHandler(config) + handler.airtime_budgets = budgets + handler.airtime_mgr = budgets.default + daemon = _DummyDaemon(config, _DummySX1262Radio()) + daemon.repeater_handler = handler + manager = ConfigManager("/tmp/config.yaml", config, daemon) + + config["duty_cycle"]["max_airtime_per_minute"] = 600 + assert manager.live_update_daemon(["duty_cycle"]) + + assert budgets.default.max_airtime_per_minute == 600 + + +def test_non_default_radio_change_requires_restart_and_keeps_runtime_metering(): + config = { + "radio": { + "frequency": 910100000, + "bandwidth": 500000, + "spreading_factor": 7, + "coding_rate": 5, + "preamble_length": 8, + }, + "radios": [ + { + "id": "local", + "radio_type": "sx1262", + "radio": {"frequency": 910100000, "bandwidth": 500000}, + }, + { + "id": "link", + "radio_type": "sx1262", + "radio": {"frequency": 910525000, "bandwidth": 62500}, + }, + ], + "fabric": {"use_fabric": True, "default_radio": "local"}, + "duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": True}, + } + budgets = AirtimeBudgets(config) + old_airtime = budgets.for_radio("link").calculate_airtime(50) + handler = _DummyRepeaterHandler(config) + handler.airtime_budgets = budgets + handler.airtime_mgr = budgets.default + daemon = _DummyDaemon(config, _DummySX1262Radio()) + daemon.repeater_handler = handler + manager = ConfigManager("/tmp/config.yaml", config, daemon) + + config["radios"][1]["radio"]["bandwidth"] = 500000 + assert manager.live_update_daemon(["radios"]) is False + + assert budgets.for_radio("link").calculate_airtime(50) == old_airtime diff --git a/tests/test_per_radio_duty_cycle.py b/tests/test_per_radio_duty_cycle.py index 9013ff9..a6a5eaa 100644 --- a/tests/test_per_radio_duty_cycle.py +++ b/tests/test_per_radio_duty_cycle.py @@ -8,6 +8,7 @@ manager charging both at one modulation is wrong in both directions at once. from __future__ import annotations import copy +import threading from collections import OrderedDict from unittest.mock import AsyncMock, MagicMock, patch @@ -108,16 +109,29 @@ def test_radios_on_one_channel_share_a_budget(): same = [_radio("north", WIDE), _radio("south", WIDE)] budgets = AirtimeBudgets(_config(same)) - assert budgets.for_radio("north") is budgets.for_radio("south") + assert budgets.shares_budget("north", "south") budgets.for_radio("north").record_tx(3600) assert budgets.for_radio("south").can_transmit(10)[0] is False +def test_radios_on_one_channel_keep_their_own_modulation(): + slow = dict(WIDE, spreading_factor=12) + budgets = AirtimeBudgets(_config([_radio("fast", WIDE), _radio("slow", slow)])) + + assert budgets.shares_budget("fast", "slow") + assert budgets.for_radio("slow").calculate_airtime(50) > budgets.for_radio( + "fast" + ).calculate_airtime(50) + + def test_shared_budget_restores_one_allowance_for_the_whole_node(): budgets = AirtimeBudgets(_config(BRIDGE, shared_budget=True)) - assert budgets.for_radio("local") is budgets.for_radio("link") + assert budgets.shares_budget("local", "link") + assert budgets.for_radio("link").calculate_airtime(50) == pytest.approx( + budgets.for_radio("local").calculate_airtime(50) * 8, rel=0.05 + ) budgets.for_radio("link").record_tx(3600) assert budgets.for_radio("local").can_transmit(10)[0] is False @@ -689,7 +703,7 @@ def test_radios_merging_onto_one_channel_sum_what_they_spent(): config["radios"][1]["radio"] = dict(WIDE) # link retuned onto local's channel budgets.refresh() - assert budgets.for_radio("local") is budgets.for_radio("link") + assert budgets.shares_budget("local", "link") assert budgets.for_radio("local").get_stats()["current_airtime_ms"] == 3600 assert budgets.for_radio("local").can_transmit(10)[0] is False @@ -808,7 +822,7 @@ def test_an_unprofilable_radio_is_metered_on_the_top_level_block(): budgets = AirtimeBudgets(config) - assert budgets.for_radio("link") is budgets.for_radio("local") + assert budgets.shares_budget("link", "local") assert budgets.for_radio("link").bandwidth == WIDE["bandwidth"] @@ -844,6 +858,34 @@ def test_a_rebuild_is_idempotent_for_an_unchanged_config(): assert budgets.for_radio("local").get_stats()["current_airtime_ms"] == 0 +def test_readers_cannot_observe_a_partially_rebuilt_state(monkeypatch): + budgets = AirtimeBudgets(_config(BRIDGE)) + build_started = threading.Event() + release_build = threading.Event() + read_finished = threading.Event() + original_build = budgets._build_state + + def paused_build(): + build_started.set() + assert release_build.wait(timeout=2) + return original_build() + + monkeypatch.setattr(budgets, "_build_state", paused_build) + refresh_thread = threading.Thread(target=budgets.refresh) + refresh_thread.start() + assert build_started.wait(timeout=2) + + read_thread = threading.Thread(target=lambda: (budgets.node_stats(), read_finished.set())) + read_thread.start() + assert not read_finished.wait(timeout=0.05) + + release_build.set() + refresh_thread.join(timeout=2) + read_thread.join(timeout=2) + assert not refresh_thread.is_alive() + assert read_finished.is_set() + + def test_a_new_radio_starts_from_the_busiest_channel_not_the_sum_of_them(): config = _config([_radio("local", WIDE), _radio("link", NARROW)]) budgets = AirtimeBudgets(config) @@ -872,6 +914,8 @@ def test_changing_the_default_radio_keeps_the_manager_captured_at_boot(): assert budgets.default is captured assert budgets.for_radio("link") is captured assert captured.bandwidth == NARROW["bandwidth"] + assert not budgets.shares_budget("local", "link") + assert budgets.for_radio("local").bandwidth == WIDE["bandwidth"] def test_the_captured_manager_still_receives_what_is_charged_to_it():