fix(companion): expose repeater radio state

This commit is contained in:
agessaman
2026-07-14 21:44:27 -07:00
parent b5a327b925
commit 74528af3ae
4 changed files with 151 additions and 0 deletions
+5
View File
@@ -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:
+73
View File
@@ -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,
+32
View File
@@ -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
+41
View File
@@ -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