mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-27 20:12:41 +02:00
2432332f87
Added a new method to the AirtimeManager class to refresh modulation parameters without clearing transmission history. Updated ConfigManager to call this method after applying live radio configurations. Enhanced tests to verify that modulation updates occur correctly during live updates.
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
import logging
|
|
import time
|
|
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):
|
|
self.config = config
|
|
self.radio_config = config.get("radio", {})
|
|
self.max_airtime_per_minute = 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,
|
|
}
|