mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-03 11:52:22 +01:00
590 lines
23 KiB
Python
590 lines
23 KiB
Python
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
|
|
|
|
logger = logging.getLogger("AirtimeManager")
|
|
|
|
|
|
class AirtimeManager:
|
|
def __init__(
|
|
self,
|
|
config: dict,
|
|
radio_config: Optional[dict] = None,
|
|
max_airtime_per_minute: Optional[float] = None,
|
|
):
|
|
"""Meter one channel's duty cycle.
|
|
|
|
``radio_config`` and ``max_airtime_per_minute`` override the top-level
|
|
``radio`` and ``duty_cycle`` sections, for a Fabric node that meters each
|
|
radio against its own modulation and its own band's limit. Omitted, both
|
|
come from the config as they always have.
|
|
"""
|
|
self.config = config
|
|
self.radio_config = radio_config if radio_config is not None else config.get("radio", {})
|
|
self.max_airtime_per_minute = (
|
|
max_airtime_per_minute
|
|
if max_airtime_per_minute is not None
|
|
else config.get("duty_cycle", {}).get("max_airtime_per_minute", 3600)
|
|
)
|
|
|
|
# Store radio settings for airtime calculations
|
|
self.refresh_radio_params(self.radio_config)
|
|
|
|
# Track airtime in rolling window
|
|
self.tx_history = [] # [(timestamp, airtime_ms), ...]
|
|
self.window_size = 60 # seconds
|
|
self.total_airtime_ms = 0
|
|
self.total_rx_airtime_ms = 0
|
|
|
|
def refresh_radio_params(self, radio_config: Optional[dict] = None) -> None:
|
|
"""Reload cached modulation params used by airtime estimation.
|
|
|
|
Call after a successful live radio reconfiguration. Does not reset
|
|
TX/RX history or duty-cycle totals.
|
|
"""
|
|
if radio_config is None:
|
|
radio_config = self.config.get("radio", {}) or {}
|
|
self.radio_config = radio_config
|
|
self.spreading_factor = self.radio_config.get("spreading_factor", 7)
|
|
self.bandwidth = self.radio_config.get("bandwidth", 125000)
|
|
self.coding_rate = self.radio_config.get("coding_rate", 5)
|
|
self.preamble_length = self.radio_config.get("preamble_length", 8)
|
|
|
|
def calculate_airtime(
|
|
self,
|
|
payload_len: int,
|
|
spreading_factor: int = None,
|
|
bandwidth_hz: int = None,
|
|
coding_rate: int = None,
|
|
preamble_len: int = None,
|
|
crc_enabled: bool = True,
|
|
explicit_header: bool = True,
|
|
) -> float:
|
|
"""
|
|
Calculate LoRa packet airtime via the shared core estimator.
|
|
|
|
Delegates to ``calculate_lora_airtime_ms``, which matches RadioLib's
|
|
``getTimeOnAir`` (the firmware reference), including its symbol-time
|
|
low-data-rate-optimization auto rule. Coding rate accepts either the
|
|
denominator form (5..8) or the legacy index form (1..4).
|
|
|
|
Args:
|
|
payload_len: Payload length in bytes
|
|
spreading_factor: SF7-SF12 (uses config value if None)
|
|
bandwidth_hz: Bandwidth in Hz (uses config value if None)
|
|
coding_rate: CR denominator, 5=4/5, 6=4/6, 7=4/7, 8=4/8 (uses config value if None)
|
|
preamble_len: Preamble symbols (uses config value if None)
|
|
crc_enabled: Whether CRC is enabled (default: True)
|
|
explicit_header: Whether explicit header mode is used (default: True)
|
|
|
|
Returns:
|
|
Airtime in milliseconds
|
|
"""
|
|
return calculate_lora_airtime_ms(
|
|
payload_len,
|
|
spreading_factor or self.spreading_factor,
|
|
bandwidth_hz or self.bandwidth,
|
|
coding_rate or self.coding_rate,
|
|
preamble_len or self.preamble_length,
|
|
crc_enabled=crc_enabled,
|
|
explicit_header=explicit_header,
|
|
)
|
|
|
|
def can_transmit(self, airtime_ms: float) -> Tuple[bool, float]:
|
|
enforcement_enabled = self.config.get("duty_cycle", {}).get("enforcement_enabled", True)
|
|
if not enforcement_enabled:
|
|
# Duty cycle enforcement disabled - always allow
|
|
return True, 0.0
|
|
|
|
now = time.time()
|
|
|
|
# Remove old entries outside window
|
|
self.tx_history = [(ts, at) for ts, at in self.tx_history if now - ts < self.window_size]
|
|
|
|
# Calculate current airtime in window
|
|
current_airtime = sum(at for _, at in self.tx_history)
|
|
|
|
if current_airtime + airtime_ms <= self.max_airtime_per_minute:
|
|
return True, 0.0
|
|
|
|
# Calculate wait time until oldest entry expires
|
|
if self.tx_history:
|
|
oldest_ts, oldest_at = self.tx_history[0]
|
|
wait_time = (oldest_ts + self.window_size) - now
|
|
return False, max(0, wait_time)
|
|
|
|
return False, 1.0
|
|
|
|
def record_tx(self, airtime_ms: float):
|
|
self.tx_history.append((time.time(), airtime_ms))
|
|
self.total_airtime_ms += airtime_ms
|
|
logger.debug(f"TX recorded: {airtime_ms: .1f}ms (total: {self.total_airtime_ms: .0f}ms)")
|
|
|
|
def record_rx(self, airtime_ms: float):
|
|
"""Record received packet airtime (for total RX airtime stats)."""
|
|
self.total_rx_airtime_ms += airtime_ms
|
|
|
|
def get_stats(self) -> dict:
|
|
now = time.time()
|
|
self.tx_history = [(ts, at) for ts, at in self.tx_history if now - ts < self.window_size]
|
|
|
|
current_airtime = sum(at for _, at in self.tx_history)
|
|
utilization = (current_airtime / self.max_airtime_per_minute) * 100
|
|
|
|
return {
|
|
"current_airtime_ms": current_airtime,
|
|
"max_airtime_ms": self.max_airtime_per_minute,
|
|
"utilization_percent": utilization,
|
|
"total_airtime_ms": self.total_airtime_ms,
|
|
"total_rx_airtime_ms": self.total_rx_airtime_ms,
|
|
}
|
|
|
|
|
|
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.
|
|
|
|
Duty cycle is a limit on a channel, not on a node. A single manager charging
|
|
every radio at one modulation is wrong in both directions on a dual-frequency
|
|
bridge: the same packet occupies a 62.5 kHz channel about eight times longer
|
|
than a 500 kHz one, so whichever bandwidth the top-level ``radio`` block
|
|
names, the other radio is mis-charged by that factor. Sharing one budget
|
|
compounds it -- a busy local radio spends the backhaul's allowance, on a band
|
|
it never transmits on.
|
|
|
|
So each radio is metered on its own, with its own modulation and its own
|
|
band's limit. Two radios on the same channel share one manager, because they
|
|
do contend for the same spectrum: two transmitters on 869.618 MHz are one
|
|
channel's worth of traffic however the node labels them.
|
|
|
|
A single-radio node gets exactly one manager built from the top-level
|
|
sections, which is what it had before this existed.
|
|
"""
|
|
|
|
def __init__(self, config: dict):
|
|
self.config = config
|
|
self._lock = threading.RLock()
|
|
self._state = self._build_state()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Construction
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_state(self) -> _BudgetState:
|
|
from .config import build_metering_profiles
|
|
|
|
try:
|
|
profiles = build_metering_profiles(self.config)
|
|
except Exception as exc: # pragma: no cover - metering must not fail to start
|
|
logger.warning("Could not read radio profiles for duty cycle: %s", exc)
|
|
profiles = []
|
|
|
|
if not profiles:
|
|
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))
|
|
order = []
|
|
channel_of = {}
|
|
for profile in profiles:
|
|
radio_id = str(profile["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 = 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 channel_of.items() if rid_key == key]
|
|
),
|
|
)
|
|
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)
|
|
|
|
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.
|
|
|
|
Laid over the top-level ``radio`` block rather than replacing it, so a
|
|
field this radio could not report inherits the value the node would have
|
|
metered with anyway instead of an unrelated built-in default.
|
|
"""
|
|
settings = dict(self.config.get("radio", {}) or {})
|
|
for key, value in (
|
|
("spreading_factor", profile.get("spreading_factor")),
|
|
("bandwidth", profile.get("bandwidth_hz")),
|
|
("coding_rate", profile.get("coding_rate")),
|
|
("preamble_length", profile.get("preamble_length")),
|
|
):
|
|
if value is not None:
|
|
settings[key] = value
|
|
return settings
|
|
|
|
def _channel_budget(self, radio_ids: list) -> Optional[float]:
|
|
"""The limit for a channel several radios may sit on: the strictest one.
|
|
|
|
Taking the first radio's would let a second radio configured for a 1%
|
|
band transmit against a 10% allowance, which is the wrong direction to
|
|
be wrong in about a legal limit.
|
|
"""
|
|
budgets = [self._budget_for(radio_id) for radio_id in radio_ids]
|
|
stated = [budget for budget in budgets if budget is not None]
|
|
if not stated:
|
|
return None
|
|
node_wide = self.config.get("duty_cycle", {}).get("max_airtime_per_minute", 3600)
|
|
# A radio that states nothing is held to the node-wide limit, so it
|
|
# counts towards the minimum rather than being ignored.
|
|
if len(stated) != len(budgets):
|
|
stated.append(node_wide)
|
|
return min(stated)
|
|
|
|
def _budget_for(self, radio_id: str) -> Optional[float]:
|
|
"""Per-radio ``duty_cycle.max_airtime_per_minute``, else the node's.
|
|
|
|
Bands differ: 868.0-868.6 MHz allows 1% where 869.4-869.65 allows 10%,
|
|
so a bridge spanning two sub-bands has two different legal limits and
|
|
one number cannot describe both.
|
|
"""
|
|
radios = self.config.get("radios")
|
|
if not isinstance(radios, list):
|
|
return None
|
|
for entry in radios:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if str(entry.get("id") or entry.get("radio_id") or "") != radio_id:
|
|
continue
|
|
duty_cycle = entry.get("duty_cycle")
|
|
if isinstance(duty_cycle, dict) and "max_airtime_per_minute" in duty_cycle:
|
|
return duty_cycle["max_airtime_per_minute"]
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Rebuilding on a live config change
|
|
# ------------------------------------------------------------------
|
|
|
|
def refresh(self) -> None:
|
|
"""Rebuild the budgets from the current config, keeping what was spent.
|
|
|
|
A live radio change moves a channel, and the new modulation has to meter
|
|
the next send. What is already on the air is carried over: a retune is
|
|
not a fresh minute, and forgetting it would let a node transmit its whole
|
|
budget twice in one window.
|
|
|
|
Carried between *channels* rather than between radios, because a channel
|
|
is what a duty cycle applies to, and because it makes a rebuild
|
|
idempotent: refreshing an unchanged config hands every channel back its
|
|
own history exactly, however many times it is called. Two channels
|
|
merging into one sum, because that channel really did carry both. One
|
|
channel splitting hands each side the full history, since neither can
|
|
prove it was the quiet one; a second refresh then finds each side's own
|
|
key and stops there rather than doubling again.
|
|
"""
|
|
with self._lock:
|
|
previous = self._state
|
|
snapshots = {
|
|
key: (
|
|
list(manager.tx_history),
|
|
manager.total_airtime_ms,
|
|
manager.total_rx_airtime_ms,
|
|
)
|
|
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:
|
|
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]
|
|
|
|
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,
|
|
otherwise the first configured radio. Reading ``radios[0]`` instead
|
|
silently meters a node whose default_radio is its second entry against
|
|
the wrong channel entirely.
|
|
"""
|
|
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 by_radio:
|
|
return str(configured)
|
|
return order[0] if order else None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Reading
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def default(self) -> _RadioAirtimeBudget:
|
|
"""The manager everything that reports one channel's figures reads."""
|
|
with self._lock:
|
|
return self._state.default
|
|
|
|
@property
|
|
def multi(self) -> bool:
|
|
with self._lock:
|
|
return len(self._state.order) > 1
|
|
|
|
@property
|
|
def profile_backed(self) -> bool:
|
|
"""Whether these budgets were built from per-radio profiles.
|
|
|
|
True for one configured ``radios[]`` entry as much as for five. A node
|
|
with a single entry still has that entry's own modulation and its own
|
|
band's limit, and overwriting them with the top-level block -- which is
|
|
what happens if a caller tests ``multi`` and takes the legacy path --
|
|
undoes the whole point on every radio save from the web UI.
|
|
"""
|
|
with self._lock:
|
|
return self._state.profile_backed
|
|
|
|
def radio_ids(self) -> list:
|
|
"""Configured radio ids, in order. ``[None]`` when nothing was profiled."""
|
|
with self._lock:
|
|
return list(self._state.order)
|
|
|
|
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.
|
|
"""
|
|
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.
|
|
|
|
Radios sharing a channel report the same figures, because they are the
|
|
same budget: that is the statement, not a duplication.
|
|
"""
|
|
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.
|
|
|
|
For the callers that have always reported a single number: the MeshCore
|
|
wire field ``total_air_time_secs``, companion stats, the RRD and SQLite
|
|
history, and ``/stats``. Reading the default radio's manager instead
|
|
would have each of them describe one channel of a node that transmits on
|
|
two, which is a quiet regression in figures people have been watching
|
|
for months.
|
|
|
|
Totals and the current window are summed across the distinct channels,
|
|
because the node really did spend all of it. Utilisation is the highest
|
|
of them, not the ratio of the sums: a node whose narrow channel is at its
|
|
legal limit is at a limit, and averaging that against an idle wide
|
|
channel reports 14% for a radio that must stop transmitting.
|
|
|
|
On a single-radio node every figure is that one manager's, unchanged.
|
|
"""
|
|
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),
|
|
}
|