diff --git a/repeater/companion/bridge.py b/repeater/companion/bridge.py index 406bd34..0b1751d 100644 --- a/repeater/companion/bridge.py +++ b/repeater/companion/bridge.py @@ -10,6 +10,7 @@ from __future__ import annotations import dataclasses import logging +from collections.abc import Mapping from enum import Enum from typing import Any, Callable, Optional @@ -69,6 +70,8 @@ class RepeaterCompanionBridge(CompanionBridge): authenticate_callback: Optional[Callable[..., tuple[bool, int]]] = None, initial_contacts: Optional[Any] = None, *, + radio_settings_getter: Optional[Callable[[], Mapping[str, Any]]] = None, + max_tx_power_getter: Optional[Callable[[], Optional[int]]] = None, sqlite_handler=None, companion_hash: str = "", on_prefs_saved: Optional[Callable[[str], None]] = None, @@ -87,6 +90,8 @@ class RepeaterCompanionBridge(CompanionBridge): radio_config=radio_config, authenticate_callback=authenticate_callback, initial_contacts=initial_contacts, + radio_settings_getter=radio_settings_getter, + max_tx_power_getter=max_tx_power_getter, ) def _save_prefs(self) -> None: diff --git a/repeater/main.py b/repeater/main.py index e12245e..d64d04a 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -559,6 +559,75 @@ class RepeaterDaemon: total_identities = len(self.identity_manager.list_identities()) logger.info(f"Identity manager loaded {total_identities} total identities") + def _get_companion_radio_settings(self) -> dict: + """Return the current repeater radio settings for virtual companions. + + The values are read-only to companion sessions. Prefer attributes of + the active backend, then retain the configured value when a backend + cannot expose that field. + """ + config = ( + self.repeater_handler.radio_config + if self.repeater_handler + else self.config.get("radio", {}) + ) + settings = dict(config) if isinstance(config, dict) else {} + radio = self.radio + if radio is None: + return settings + + for config_key, attr in ( + ("frequency", "frequency"), + ("bandwidth", "bandwidth"), + ("spreading_factor", "spreading_factor"), + ("coding_rate", "coding_rate"), + ("tx_power", "tx_power"), + ): + value = getattr(radio, attr, None) + if value is not None: + settings[config_key] = value + return settings + + def _get_companion_max_tx_power_dbm(self): + """Return the active backend's TX limit when it declares one. + + SX1262 backends have an enforced 22 dBm driver limit. Other backends + can expose a ``get_max_tx_power_dbm`` method, a + ``max_tx_power_dbm`` attribute, or a validated deployment setting. + Returning ``None`` lets Core use its generic protocol fallback. + """ + radio = self.radio + getter = getattr(radio, "get_max_tx_power_dbm", None) + if callable(getter): + try: + value = getter() + if value is not None: + return int(value) + except (TypeError, ValueError): + logger.warning("Radio reported an invalid maximum TX power") + except Exception as e: + logger.warning("Could not get radio maximum TX power: %s", e) + + value = getattr(radio, "max_tx_power_dbm", None) + if value is not None: + try: + return int(value) + except (TypeError, ValueError): + logger.warning("Radio reported an invalid maximum TX power: %r", value) + + settings = self._get_companion_radio_settings() + value = settings.get("max_tx_power_dbm", settings.get("max_tx_power")) + if value is not None: + try: + return int(value) + except (TypeError, ValueError): + logger.warning("Configured maximum TX power is invalid: %r", value) + + radio_type = str(self.config.get("radio_type", "")).lower().strip() + if radio_type in {"sx1262", "sx1262_ch341"}: + return 22 + return None + async def _load_companion_identities(self) -> None: """Load companion identities from config and create CompanionBridge + frame server for each.""" from openhop_core import LocalIdentity @@ -674,6 +743,8 @@ class RepeaterDaemon: ), node_name=node_name, radio_config=radio_config, + radio_settings_getter=self._get_companion_radio_settings, + max_tx_power_getter=self._get_companion_max_tx_power_dbm, sqlite_handler=sqlite_handler, companion_hash=companion_hash_str, on_prefs_saved=_make_sync_node_name_to_config(name), @@ -893,6 +964,8 @@ class RepeaterDaemon: ), node_name=node_name, radio_config=radio_config, + radio_settings_getter=self._get_companion_radio_settings, + max_tx_power_getter=self._get_companion_max_tx_power_dbm, sqlite_handler=sqlite_handler, companion_hash=companion_hash_str, **bridge_kwargs, diff --git a/tests/test_companion_bridge_prefs.py b/tests/test_companion_bridge_prefs.py index e2a9787..8d76308 100644 --- a/tests/test_companion_bridge_prefs.py +++ b/tests/test_companion_bridge_prefs.py @@ -52,3 +52,35 @@ def test_load_prefs_restores_default_scope_key_as_bytes(identity): assert scope is not None assert scope[0] == "region1" assert scope[1] == bytes(range(16)) + + +def test_bridge_accepts_host_radio_callbacks(identity): + """Repeater must forward host-radio callbacks required by CompanionBridge.""" + + async def inject(pkt, wait_for_ack=False): + return True + + bridge = RepeaterCompanionBridge( + identity, + inject, + radio_settings_getter=lambda: { + "frequency": 915_000_000, + "bandwidth": 250_000, + "spreading_factor": 10, + "coding_rate": 5, + "tx_power": 19, + }, + max_tx_power_getter=lambda: 20, + ) + + radio = bridge.get_radio_params() + assert radio == { + "frequency_hz": 915_000_000, + "bandwidth_hz": 250_000, + "spreading_factor": 10, + "coding_rate": 5, + "tx_power_dbm": 19, + "rx_delay_base": 0, + "airtime_factor": 0, + } + assert bridge.get_max_tx_power_dbm() == 20 diff --git a/tests/test_companion_settings.py b/tests/test_companion_settings.py index 070248f..e0dd3b3 100644 --- a/tests/test_companion_settings.py +++ b/tests/test_companion_settings.py @@ -21,6 +21,7 @@ from repeater.companion.utils import ( trim_companion_contacts_to_fit, validate_companion_config_capacity, ) +from repeater.main import RepeaterDaemon # openhop_core defaults (CompanionBridge / ContactStore) _DEFAULT_MAX_CONTACTS = 1000 @@ -62,6 +63,46 @@ class TestParseCompanionBridgeKwargs: parse_companion_bridge_kwargs({"max_contacts": -1}) +class TestCompanionRadioCapabilities: + def test_reads_active_radio_state_and_known_sx1262_limit(self): + radio = SimpleNamespace( + frequency=868_000_000, + bandwidth=125_000, + spreading_factor=7, + coding_rate=8, + tx_power=14, + ) + daemon = RepeaterDaemon.__new__(RepeaterDaemon) + daemon.config = {"radio_type": "sx1262", "radio": {"frequency": 915_000_000}} + daemon.repeater_handler = SimpleNamespace(radio_config={"frequency": 915_000_000}) + daemon.radio = radio + + assert RepeaterDaemon._get_companion_radio_settings(daemon) == { + "frequency": 868_000_000, + "bandwidth": 125_000, + "spreading_factor": 7, + "coding_rate": 8, + "tx_power": 14, + } + assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 22 + + def test_prefers_backend_declared_maximum(self): + daemon = RepeaterDaemon.__new__(RepeaterDaemon) + daemon.config = {"radio_type": "sx1262"} + daemon.repeater_handler = SimpleNamespace(radio_config={}) + daemon.radio = SimpleNamespace(max_tx_power_dbm=19) + + assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 19 + + def test_uses_configured_limit_when_backend_cannot_declare_one(self): + daemon = RepeaterDaemon.__new__(RepeaterDaemon) + daemon.config = {"radio_type": "kiss"} + daemon.repeater_handler = SimpleNamespace(radio_config={"max_tx_power_dbm": 15}) + daemon.radio = SimpleNamespace() + + assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 15 + + class TestEffectiveMaxContacts: def test_default(self): assert effective_max_contacts({}) == _DEFAULT_MAX_CONTACTS