From 2afeafc92ee293f3bec2f76e86ce48226768e241 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 08:04:15 -0400 Subject: [PATCH] feat(reticulum-daemon): airtime-limit args + radio_state RPC for live read-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the .126 LoRa settings panel (operator top priority): - --airtime-limit-short/--airtime-limit-long (percent duty-cycle locks, e.g. EU868 25/10) written into the RNode interface config when set; default None writes nothing — identical to older daemons. - New socket RPC {"cmd":"radio_state"} returns the live RNodeInterface state: requested config values AND the radio-confirmed r_* values (r_frequency/r_bandwidth/r_txpower/r_sf/r_cr/r_st_alock/r_lt_alock, online, port, airtime utilisation). The r_* values are what the RADIO reported after detect/configure — the settings panel's proof that the device is actually using what was applied. Co-Authored-By: Claude Fable 5 --- reticulum-daemon/reticulum_daemon.py | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/reticulum-daemon/reticulum_daemon.py b/reticulum-daemon/reticulum_daemon.py index 0b932743..55c2806b 100644 --- a/reticulum-daemon/reticulum_daemon.py +++ b/reticulum-daemon/reticulum_daemon.py @@ -108,6 +108,13 @@ def _write_rns_config( f" spreadingfactor = {lora['spreadingfactor']}\n" f" codingrate = {lora['codingrate']}\n" ) + # Regulatory duty-cycle limits (percent). Only written when set — + # absent keys keep RNS's own default (no software airtime lock), + # matching every daemon built before these args existed. + if lora.get("airtime_limit_short") is not None: + interfaces += f" airtime_limit_short = {lora['airtime_limit_short']}\n" + if lora.get("airtime_limit_long") is not None: + interfaces += f" airtime_limit_long = {lora['airtime_limit_long']}\n" elif tcp_listen or tcp_connect: parts = [] if tcp_listen: @@ -188,6 +195,8 @@ class ReticulumDaemon: "txpower": self.args.txpower, "spreadingfactor": self.args.spreadingfactor, "codingrate": self.args.codingrate, + "airtime_limit_short": self.args.airtime_limit_short, + "airtime_limit_long": self.args.airtime_limit_long, }, no_radio=self.args.no_radio, tcp_listen=self.args.tcp_listen, @@ -358,6 +367,8 @@ class ReticulumDaemon: self.announce() elif cmd == "status": self._broadcast(self._status()) + elif cmd == "radio_state": + self._broadcast(self._radio_state()) elif cmd == "send_resource": self._send_resource(req) elif cmd == "shutdown": @@ -374,6 +385,48 @@ class ReticulumDaemon: return {"event": "status", "connected": self.router is not None, "dest_hash": self.dest_hash_hex, "interfaces": ifaces} + def _radio_state(self) -> dict: + """Radio-confirmed RNode parameters, straight from the live + RNodeInterface object. The r_* attributes are what the RADIO reported + after detect/configure (RNS/Interfaces/RNodeInterface.py) — this is + the read-back the settings panel shows as proof the device is + actually using the applied values, as opposed to what the config + asked for. Absent radio (TCP/no-radio builds) → configured=False.""" + state = {"event": "radio_state", "configured": False, "online": False} + try: + import RNS + for iface in list(RNS.Transport.interfaces): + if type(iface).__name__ != "RNodeInterface": + continue + state.update({ + "configured": True, + "online": bool(getattr(iface, "online", False)), + "port": getattr(iface, "port", None), + # Requested (config) values… + "frequency": getattr(iface, "frequency", None), + "bandwidth": getattr(iface, "bandwidth", None), + "txpower": getattr(iface, "txpower", None), + "spreadingfactor": getattr(iface, "sf", None), + "codingrate": getattr(iface, "cr", None), + "airtime_limit_short": getattr(iface, "st_alock", None), + "airtime_limit_long": getattr(iface, "lt_alock", None), + # …and what the radio itself confirmed it is running. + "r_frequency": getattr(iface, "r_frequency", None), + "r_bandwidth": getattr(iface, "r_bandwidth", None), + "r_txpower": getattr(iface, "r_txpower", None), + "r_spreadingfactor": getattr(iface, "r_sf", None), + "r_codingrate": getattr(iface, "r_cr", None), + "r_airtime_limit_short": getattr(iface, "r_st_alock", None), + "r_airtime_limit_long": getattr(iface, "r_lt_alock", None), + # Live utilisation, when the interface tracks it. + "airtime_short": getattr(iface, "airtime_short", None), + "airtime_long": getattr(iface, "airtime_long", None), + }) + break + except Exception: + pass + return state + def _send(self, req: dict): import RNS import LXMF @@ -643,6 +696,11 @@ def _parse_args(argv): p.add_argument("--txpower", type=int, default=17) p.add_argument("--spreadingfactor", type=int, default=8) p.add_argument("--codingrate", type=int, default=5) + # Regulatory duty-cycle locks (percent of airtime, e.g. EU868 short=25 + # long=10). None (the default) writes no config line, so RNS applies no + # software airtime lock — identical to daemons built before these existed. + p.add_argument("--airtime-limit-short", type=float, default=None) + p.add_argument("--airtime-limit-long", type=float, default=None) p.add_argument("--enable-transport", action="store_true", help="run as an RNS transport node: relay traffic and rebroadcast " "announces so nodes beyond direct RF range discover each other "