mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-03 20:02:24 +01:00
The three readers of the CRC scalar all answered for one receiver. On a bridge that is half the node: a backhaul going deaf moved no number an operator or an observer could see, while the sum of radios[].errors in the same status message already said so. All three now read RepeaterHandler.get_crc_error_count(), which sums the radios -- stats.errors in the MQTT status message, crc_error_count in /stats, and n_recv_errors in the MeshCore wire response, which previously read self.radio directly and so described the default endpoint. stats.errors and the per-radio entries beside it now reconcile. This matches what node_stats() already does with the airtime counters that sit next to it: a node-level counter describes the node, and which radio contributed what stays available per radio, in radios[] and in the crc_errors table. The sum prefers each radio's live hardware counter and falls back to the last count the sampler read from a radio the fabric cannot hand back. These are lifetime counters, so dropping a radio would walk the node's total backwards and hand an observer reading deltas a negative interval. A single-radio node still reports its one unlabelled counter, read live, exactly as before.
2841 lines
128 KiB
Python
2841 lines
128 KiB
Python
import asyncio
|
|
import copy
|
|
import logging
|
|
import math
|
|
import secrets
|
|
import time
|
|
from collections import OrderedDict, deque
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Dict, Optional, Tuple
|
|
|
|
from openhop_core.node.handlers.base import BaseHandler
|
|
from openhop_core.protocol import Packet
|
|
from openhop_core.protocol.constants import (
|
|
MAX_PATH_SIZE,
|
|
PAYLOAD_TYPE_ACK,
|
|
PAYLOAD_TYPE_ADVERT,
|
|
PAYLOAD_TYPE_ANON_REQ,
|
|
PAYLOAD_TYPE_MULTIPART,
|
|
PAYLOAD_TYPE_TRACE,
|
|
PH_ROUTE_MASK,
|
|
PH_TYPE_SHIFT,
|
|
ROUTE_TYPE_DIRECT,
|
|
ROUTE_TYPE_FLOOD,
|
|
ROUTE_TYPE_TRANSPORT_DIRECT,
|
|
ROUTE_TYPE_TRANSPORT_FLOOD,
|
|
)
|
|
from openhop_core.protocol.packet_utils import (
|
|
PacketHashingUtils,
|
|
PacketHeaderUtils,
|
|
PathUtils,
|
|
flood_rx_metrics,
|
|
packet_score,
|
|
)
|
|
|
|
from repeater.airtime import AirtimeBudgets
|
|
from repeater.config import fabric_origin_tx, fabric_selects_by_ingress_radio
|
|
from repeater.data_acquisition import StorageCollector
|
|
from repeater.modem_config import normalize_modem_config, redact_modem_tokens_in_place
|
|
from repeater.neighbour_links import NeighbourLinkTracker
|
|
from repeater.policy_engine import PolicyDecision, PolicyEngine
|
|
|
|
logger = logging.getLogger("RepeaterHandler")
|
|
|
|
NOISE_FLOOR_INTERVAL = 30.0 # seconds
|
|
|
|
LOOP_DETECT_OFF = "off"
|
|
LOOP_DETECT_MINIMAL = "minimal"
|
|
LOOP_DETECT_MODERATE = "moderate"
|
|
LOOP_DETECT_STRICT = "strict"
|
|
|
|
# Thresholds for flood loop detection, keyed by loop mode and path-hash width.
|
|
# MeshCore treats 4-byte hashes as reserved, so only 1-3 byte widths are valid here.
|
|
LOOP_DETECT_MAX_COUNTERS = {
|
|
LOOP_DETECT_MINIMAL: {1: 4, 2: 2, 3: 1},
|
|
LOOP_DETECT_MODERATE: {1: 2, 2: 1, 3: 1},
|
|
LOOP_DETECT_STRICT: {1: 1, 2: 1, 3: 1},
|
|
}
|
|
|
|
|
|
class DropReason(str, Enum):
|
|
"""Canonical, non-alarming reasons a packet was intentionally not retransmitted.
|
|
|
|
Single source of truth shared with the packet router, replacing the string
|
|
prefixes that used to be duplicated there. Members subclass ``str`` so they
|
|
compare equal to their text, JSON-serialize, and persist to SQLite as plain
|
|
strings unchanged. Where the engine still needs a detail suffix (hop counts,
|
|
loop mode, multipart sub-case) it formats the member into a larger string;
|
|
the router accepts both the bare member and any string that begins with one.
|
|
"""
|
|
|
|
DUPLICATE = "Duplicate"
|
|
MAX_FLOOD_HOPS = "Max flood hops limit reached"
|
|
PATH_HOP_COUNT_MAX = "Path hop count at maximum"
|
|
PATH_EXCEEDS_MAX_SIZE = "Path would exceed MAX_PATH_SIZE"
|
|
DIRECT_NO_PATH = "Direct: no path"
|
|
DIRECT_NOT_FOR_US = "Direct: not for us"
|
|
UNSCOPED_FLOOD_DISABLED = "Unscoped flood policy disabled"
|
|
TRANSPORT_CODE_NOT_ALLOWED = "Transport code not allowed to flood"
|
|
FLOOD_LOOP_DETECTED = "FLOOD loop detected"
|
|
MARKED_DO_NOT_RETRANSMIT = "Marked do not retransmit"
|
|
REPEAT_DISABLED = "Repeat disabled"
|
|
NO_TX_MODE = "No TX mode"
|
|
DUTY_CYCLE_LIMIT = "Duty cycle limit"
|
|
EMPTY_PAYLOAD = "Empty payload"
|
|
PATH_TOO_LONG = "Path too long"
|
|
INVALID_ADVERT = "Invalid advert packet"
|
|
MULTIPART = "Multipart"
|
|
|
|
# Python 3.11+ formats a (str, Enum) member as "DropReason.X" under str()/%s;
|
|
# return the value so log lines and stored records keep the plain reason text.
|
|
def __str__(self) -> str:
|
|
return self.value
|
|
|
|
|
|
class ForwardResult(tuple):
|
|
"""A forwarding decision that unpacks as a plain ``(packet, delay_seconds)`` pair.
|
|
|
|
``extras`` carries additional ``(packet, delay_seconds)`` transmissions that
|
|
accompany the primary packet: MeshCore's routeDirectRecvAcks queues its
|
|
optional multi-ack redundancy copies ahead of the plain ACK at the same
|
|
scheduled time, so the caller must create the extra TX tasks before the
|
|
primary one.
|
|
"""
|
|
|
|
def __new__(cls, packet: Packet, delay_s: float, extras=()):
|
|
result = super().__new__(cls, (packet, delay_s))
|
|
result.extras = tuple(extras)
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class RadioTxResult:
|
|
"""Outcome of one physical transmission through one radio endpoint."""
|
|
|
|
radio_id: Optional[str]
|
|
success: bool
|
|
metadata: Optional[dict] = None
|
|
error: Optional[BaseException] = None
|
|
|
|
|
|
@dataclass
|
|
class FanoutTxResult:
|
|
"""Per-egress outcomes of one logical transmission, in attempt order.
|
|
|
|
One logical packet can go out through several Fabric radios; each egress
|
|
succeeds or fails on its own, and the logical send succeeds when any does.
|
|
"""
|
|
|
|
results: list = field(default_factory=list)
|
|
|
|
@property
|
|
def any_success(self) -> bool:
|
|
return any(result.success for result in self.results)
|
|
|
|
@property
|
|
def all_success(self) -> bool:
|
|
return bool(self.results) and all(result.success for result in self.results)
|
|
|
|
@property
|
|
def successful_radio_ids(self) -> list:
|
|
return [r.radio_id for r in self.results if r.success and r.radio_id]
|
|
|
|
@property
|
|
def failed_radio_ids(self) -> list:
|
|
return [r.radio_id for r in self.results if not r.success and r.radio_id]
|
|
|
|
@property
|
|
def primary(self) -> Optional[RadioTxResult]:
|
|
"""The first successful egress, else the first one attempted."""
|
|
for result in self.results:
|
|
if result.success:
|
|
return result
|
|
return self.results[0] if self.results else None
|
|
|
|
|
|
class RepeaterHandler(BaseHandler):
|
|
@staticmethod
|
|
def payload_type() -> int:
|
|
|
|
return 0xFF # Special marker (not a real payload type)
|
|
|
|
def __init__(
|
|
self,
|
|
config: dict,
|
|
dispatcher,
|
|
local_hash: int,
|
|
*,
|
|
local_hash_bytes=None,
|
|
send_advert_func=None,
|
|
periodic_advert_tick_func=None,
|
|
):
|
|
|
|
self.config = config
|
|
self.dispatcher = dispatcher
|
|
self.local_hash = local_hash
|
|
self.local_hash_bytes = local_hash_bytes or bytes([local_hash])
|
|
self.send_advert_func = send_advert_func
|
|
self.periodic_advert_tick_func = periodic_advert_tick_func
|
|
# One budget per channel; on a single-radio node this is the one manager
|
|
# it always had. airtime_mgr stays the default radio's, because
|
|
# everything that reports a single utilisation figure reads it.
|
|
self.airtime_budgets = AirtimeBudgets(config)
|
|
self.airtime_mgr = self.airtime_budgets.default
|
|
self.policy_engine = PolicyEngine.from_runtime_config(config)
|
|
self.seen_packets = OrderedDict()
|
|
self.cache_ttl = max(
|
|
300, config.get("repeater", {}).get("cache_ttl", 3600)
|
|
) # Min 5 min, default 1 hour
|
|
self.max_cache_size = 1000
|
|
self.max_duplicates_per_packet = 20
|
|
self.tx_delay_factor = config.get("delays", {}).get("tx_delay_factor", 1.0)
|
|
self.direct_tx_delay_factor = config.get("delays", {}).get("direct_tx_delay_factor", 0.5)
|
|
# These are airtime multipliers, not seconds: TX delay is a random value in
|
|
# [0, 5 * airtime * factor] (matching MeshCore getRetransmitDelay). Flood and
|
|
# direct share the formula and differ only in the factor.
|
|
logger.info(
|
|
"TX delay factors (airtime multipliers, not seconds): "
|
|
f"flood random 0-5*airtime*F (F={self.tx_delay_factor}), "
|
|
f"direct random 0-5*airtime*F (F={self.direct_tx_delay_factor})"
|
|
)
|
|
self.use_score_for_tx = config.get("repeater", {}).get("use_score_for_tx", False)
|
|
self.score_threshold = config.get("repeater", {}).get("score_threshold", 0.3)
|
|
self.multi_acks = self._normalize_multi_acks(config)
|
|
self.max_flood_hops = config.get("repeater", {}).get("max_flood_hops", 64)
|
|
self.send_advert_interval_hours = config.get("repeater", {}).get(
|
|
"send_advert_interval_hours", 10
|
|
)
|
|
self.last_advert_time = time.time()
|
|
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
|
config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
|
)
|
|
self.neighbour_link_tracker = NeighbourLinkTracker(config)
|
|
# (packet hash, path) -> recent physical sends, each with its radio and
|
|
# timing. Two radios sharing one frequency hear each other send; that echo
|
|
# ends in our own path hash and must not make this node its own neighbour.
|
|
self._recent_own_tx: OrderedDict = OrderedDict()
|
|
|
|
radio = dispatcher.radio if dispatcher else None
|
|
if radio:
|
|
self.radio_config = {
|
|
"spreading_factor": getattr(radio, "spreading_factor", 8),
|
|
"bandwidth": getattr(radio, "bandwidth", 125000),
|
|
"coding_rate": getattr(radio, "coding_rate", 8),
|
|
"preamble_length": getattr(radio, "preamble_length", 32),
|
|
"frequency": getattr(radio, "frequency", 915000000),
|
|
"tx_power": getattr(radio, "tx_power", 14),
|
|
}
|
|
logger.info(
|
|
f"radio settings: SF={self.radio_config['spreading_factor']}, "
|
|
f"BW={self.radio_config['bandwidth']}Hz, CR={self.radio_config['coding_rate']}"
|
|
)
|
|
else:
|
|
raise RuntimeError("Radio object not available - cannot initialize repeater")
|
|
|
|
# Statistics tracking for dashboard
|
|
self.rx_count = 0
|
|
self.forwarded_count = 0
|
|
self.dropped_count = 0
|
|
self.max_recent_packets = 50
|
|
self.recent_packets = deque(maxlen=self.max_recent_packets)
|
|
self._recent_hash_index = {}
|
|
self.start_time = time.time()
|
|
# Flood/direct and duplicate counters (for GET_STATUS / firmware RepeaterStats)
|
|
self.recv_flood_count = 0
|
|
self.recv_direct_count = 0
|
|
self.sent_flood_count = 0
|
|
self.sent_direct_count = 0
|
|
self.flood_dup_count = 0
|
|
self.direct_dup_count = 0
|
|
|
|
# Storage collector for persistent packet logging
|
|
try:
|
|
local_identity = dispatcher.local_identity if dispatcher else None
|
|
self.storage = StorageCollector(config, local_identity, repeater_handler=self)
|
|
logger.info("StorageCollector initialized successfully")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize StorageCollector: {e}")
|
|
self.storage = None
|
|
|
|
# Initialize background timer tracking
|
|
self.last_noise_measurement = time.time()
|
|
self.last_cache_cleanup = time.time()
|
|
self.last_db_cleanup = time.time()
|
|
self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds
|
|
self._background_task = None
|
|
self._cached_noise_floor = None
|
|
# Last sample per radio id, on a fabric only. /stats reports the default
|
|
# radio's figure as it always has; this carries the others so a bridge's
|
|
# sidebar curves all advance on the same beat.
|
|
self._cached_noise_floor_by_radio: Dict[str, float] = {}
|
|
# Radio hardware CRC counter per radio id (None on a single-radio node),
|
|
# so each radio's delta is measured against its own last reading.
|
|
self._crc_error_baselines: Dict[Optional[str], int] = {}
|
|
|
|
# Cache transport keys for efficient lookup
|
|
self._transport_keys_cache = None
|
|
self._transport_keys_cache_time = 0
|
|
self._transport_keys_cache_ttl = 60 # Cache for 60 seconds
|
|
|
|
# Serialise all radio TX calls.
|
|
#
|
|
# Background: since the queue loop dispatches each packet as an
|
|
# asyncio.create_task, multiple _route_packet coroutines can have their
|
|
# TX delay timers running concurrently — which is the intended behaviour
|
|
# (firmware nodes do the same with a hardware timer). However, the
|
|
# LoRa radio is half-duplex: it can only transmit one packet at a time.
|
|
# Without serialisation, two tasks whose delay timers expire near-
|
|
# simultaneously both call dispatcher.send_packet, interleaving SPI/serial
|
|
# commands to the radio and both passing the LBT check before either has
|
|
# actually transmitted.
|
|
#
|
|
# _tx_lock is acquired after each delay sleep and held for the entire
|
|
# send_packet call. Delays still run concurrently; only the radio
|
|
# access is serialised. This also eliminates the TOCTOU gap in duty-cycle
|
|
# enforcement — see schedule_retransmit / delayed_send for details.
|
|
self._tx_lock = asyncio.Lock()
|
|
|
|
self._start_background_tasks()
|
|
|
|
async def __call__(
|
|
self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False
|
|
) -> bool:
|
|
|
|
if metadata is None:
|
|
metadata = {}
|
|
|
|
# Only count as receive when packet came from the radio (not locally injected)
|
|
if not local_transmission:
|
|
self.rx_count += 1
|
|
route_type = packet.header & PH_ROUTE_MASK
|
|
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
|
|
self.recv_flood_count += 1
|
|
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
|
self.recv_direct_count += 1
|
|
try:
|
|
rx_mgr = self.airtime_budgets.for_radio(self._rx_radio_id_of(packet, metadata))
|
|
rx_airtime_ms = rx_mgr.calculate_airtime(packet.get_raw_length())
|
|
rx_mgr.record_rx(rx_airtime_ms)
|
|
except Exception as exc:
|
|
logger.debug(f"Failed to record RX airtime: {exc}")
|
|
|
|
route_type = packet.header & PH_ROUTE_MASK
|
|
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
|
snr = metadata.get("snr", 0.0)
|
|
rssi = metadata.get("rssi", 0)
|
|
|
|
# TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off)
|
|
mode = self.config.get("repeater", {}).get("mode", "forward")
|
|
if mode not in ("forward", "monitor", "no_tx"):
|
|
mode = "forward"
|
|
allow_forward = mode == "forward"
|
|
allow_local_tx = mode != "no_tx"
|
|
|
|
policy_context = {
|
|
"route_type": route_type,
|
|
"payload_type": packet.get_payload_type()
|
|
if hasattr(packet, "get_payload_type")
|
|
else None,
|
|
"payload_length": len(packet.payload or b""),
|
|
"path_hash_size": packet.get_path_hash_size()
|
|
if hasattr(packet, "get_path_hash_size")
|
|
else None,
|
|
"hop_count": packet.get_path_hash_count()
|
|
if hasattr(packet, "get_path_hash_count")
|
|
else None,
|
|
"rssi": metadata.get("rssi", 0),
|
|
"snr": metadata.get("snr", 0.0),
|
|
"rx_radio_id": metadata.get("rx_radio_id")
|
|
or getattr(packet, "_rx_radio_id", None)
|
|
or getattr(packet, "rx_radio_id", None),
|
|
"local_transmission": local_transmission,
|
|
"mode": mode,
|
|
}
|
|
prechecked_decision = metadata.get("_policy_precheck_decision")
|
|
if isinstance(prechecked_decision, PolicyDecision):
|
|
policy_decision = prechecked_decision
|
|
else:
|
|
policy_decision = self.policy_engine.evaluate(packet, policy_context)
|
|
policy_reason = None
|
|
|
|
if policy_decision.matched:
|
|
logger.info(policy_decision.reason)
|
|
|
|
if policy_decision.action == "drop":
|
|
allow_forward = False
|
|
allow_local_tx = False
|
|
policy_reason = self._policy_drop_reason(policy_decision)
|
|
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, "
|
|
f"path_len={len(packet.path) if packet.path else 0}, "
|
|
f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}"
|
|
)
|
|
|
|
# clone the packet to avoid modifying the original
|
|
processed_packet = copy.deepcopy(packet)
|
|
|
|
transmitted = False
|
|
tx_delay_ms = 0.0
|
|
drop_reason = None
|
|
tx_radio_id = None
|
|
lbt_attempts = 0
|
|
lbt_backoff_delays_ms = None
|
|
lbt_channel_busy = False
|
|
rx_radio_id = (
|
|
metadata.get("rx_radio_id")
|
|
or getattr(packet, "_rx_radio_id", None)
|
|
or getattr(packet, "rx_radio_id", None)
|
|
)
|
|
tx_radio_ids_sent = None
|
|
tx_egress = None
|
|
if local_transmission and not rx_radio_id:
|
|
# Originated here: companion, repeater/room identity, protocol reply.
|
|
tx_radio_ids = self._resolve_origin_tx_radio_ids()
|
|
else:
|
|
# Heard on RF and being repeated. A helper re-emitting a received
|
|
# packet through the local path (TRACE forwarding) still carries its
|
|
# ingress radio, so it resolves as the relay it is.
|
|
tx_radio_ids = self._resolve_relay_tx_radio_ids(rx_radio_id)
|
|
|
|
original_path_hashes = packet.get_path_hashes_hex()
|
|
path_hash_size = packet.get_path_hash_size()
|
|
|
|
if not local_transmission:
|
|
payload_type = (
|
|
packet.get_payload_type() if hasattr(packet, "get_payload_type") else None
|
|
)
|
|
frame_len = packet.get_raw_length() if hasattr(packet, "get_raw_length") else 0
|
|
score = flood_rx_metrics(
|
|
frame_len,
|
|
snr,
|
|
self.radio_config["spreading_factor"],
|
|
self.radio_config["bandwidth"],
|
|
self.radio_config["coding_rate"],
|
|
self.radio_config["preamble_length"],
|
|
).score
|
|
if not self._is_own_echo(pkt_hash_full, original_path_hashes, rx_radio_id):
|
|
self.neighbour_link_tracker.observe(
|
|
packet,
|
|
route_type=route_type,
|
|
payload_type=payload_type,
|
|
rssi=rssi,
|
|
snr=snr,
|
|
score=score,
|
|
is_duplicate=(pkt_hash_full in self.seen_packets),
|
|
rx_radio_id=rx_radio_id,
|
|
)
|
|
|
|
# Process for forwarding (skip if repeat disabled or if this is a local transmission).
|
|
# Pass pkt_hash_full so flood_forward / direct_forward don't recompute SHA-256.
|
|
result = (
|
|
None
|
|
if (not allow_forward or local_transmission)
|
|
else self.process_packet(processed_packet, snr, packet_hash=pkt_hash_full)
|
|
)
|
|
forwarded_path_hashes = None
|
|
|
|
# For local transmissions, create a direct transmission result (if local TX allowed)
|
|
if local_transmission and allow_local_tx:
|
|
# Mark local packet as seen to prevent duplicate processing when received back
|
|
self.mark_seen(packet, packet_hash=pkt_hash_full)
|
|
# Calculate transmission delay for local packets
|
|
delay = self._calculate_tx_delay(packet, snr)
|
|
result = (packet, delay)
|
|
forwarded_path_hashes = packet.get_path_hashes_hex()
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
|
|
|
|
if result:
|
|
fwd_pkt, delay = result
|
|
tx_delay_ms = delay * 1000.0
|
|
|
|
# Capture the forwarded path (after modification)
|
|
forwarded_path_hashes = fwd_pkt.get_path_hashes_hex()
|
|
|
|
# MeshCore queues multi-ack redundancy copies ahead of the primary
|
|
# ACK at the same scheduled time, so create their TX tasks first.
|
|
# Each task re-checks the duty-cycle gate before transmitting, and
|
|
# every packet from one forwarding decision shares its egress set.
|
|
# A fan-out decides send-time normalisation (flood scope, hash mode)
|
|
# up front; do it before metering so every duty-cycle gate sees the
|
|
# bytes that actually go on air.
|
|
if tx_radio_ids is None:
|
|
# Nothing above named an egress, so the fabric picks one inside
|
|
# send(). Ask it now, with this packet's own ingress radio, and
|
|
# keep the answer: on a core that passes the ingress radio to
|
|
# the selector the answer is a function of the packet, so it is
|
|
# the same answer send() would reach and nothing moves on the
|
|
# air. What it buys is that the duty cycle is charged to the
|
|
# channel that carries the packet instead of to whichever radio
|
|
# the node lists first. An older core cannot answer, and
|
|
# tx_radio_ids stays None: the fabric goes on choosing at send
|
|
# time and the gate holds every radio it could pick to its own
|
|
# budget.
|
|
planned = self._planned_egress_radio_id(fwd_pkt, rx_radio_id)
|
|
if planned is not None:
|
|
tx_radio_ids = (planned,)
|
|
|
|
fanout = bool(tx_radio_ids) and len(tx_radio_ids) > 1
|
|
extra_tx_tasks = []
|
|
for extra_pkt, extra_delay in getattr(result, "extras", ()):
|
|
if fanout:
|
|
self._freeze_tx_normalisation(extra_pkt)
|
|
extra_airtime_ms = self.airtime_mgr.calculate_airtime(extra_pkt.get_raw_length())
|
|
extra_tx_tasks.append(
|
|
(
|
|
extra_pkt,
|
|
await self._schedule_egress_tx(
|
|
extra_pkt, extra_delay, extra_airtime_ms, tx_radio_ids
|
|
),
|
|
)
|
|
)
|
|
|
|
# Check duty-cycle before scheduling TX
|
|
if fanout:
|
|
self._freeze_tx_normalisation(fwd_pkt)
|
|
airtime_ms = self.airtime_mgr.calculate_airtime(fwd_pkt.get_raw_length())
|
|
|
|
# Advisory, and asked of the radios this packet is headed for: one
|
|
# radio being over budget is no reason to drop a forward the other
|
|
# can carry. The authoritative gate is per egress, under the TX lock.
|
|
can_tx, wait_time = self._egress_can_transmit(fwd_pkt, tx_radio_ids)
|
|
|
|
# LBT metadata (set after any TX path that awaits send)
|
|
tx_metadata = None
|
|
lbt_attempts = 0
|
|
lbt_backoff_delays_ms = None
|
|
lbt_channel_busy = False
|
|
|
|
if not can_tx and not local_transmission:
|
|
logger.warning(
|
|
f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, "
|
|
f"wait={wait_time:.1f}s before retry"
|
|
)
|
|
self.dropped_count += 1
|
|
drop_reason = DropReason.DUTY_CYCLE_LIMIT
|
|
else:
|
|
if can_tx:
|
|
failure_reason = "TX failed"
|
|
failure_label = "Local TX failed"
|
|
else:
|
|
# Defer local TX until duty cycle allows instead of dropping
|
|
logger.info(
|
|
f"Duty-cycle limit: deferring local TX by {wait_time:.1f}s "
|
|
f"(airtime={airtime_ms:.1f}ms)"
|
|
)
|
|
delay += wait_time
|
|
failure_reason = "TX failed (deferred)"
|
|
failure_label = "Deferred local TX failed"
|
|
tx_task = await self._schedule_egress_tx(
|
|
fwd_pkt,
|
|
delay,
|
|
airtime_ms,
|
|
tx_radio_ids,
|
|
local_transmission=local_transmission,
|
|
)
|
|
try:
|
|
tx_result = await self._await_egress_tx(tx_task, fwd_pkt)
|
|
except Exception as e:
|
|
transmitted = False
|
|
drop_reason = failure_reason
|
|
logger.warning(f"{failure_label}: {e}")
|
|
raise
|
|
# One logical forward, however many radios carried it: the
|
|
# per-radio sent_flood/sent_direct counters track physical sends.
|
|
if tx_result.any_success:
|
|
self.forwarded_count += 1
|
|
transmitted = True
|
|
else:
|
|
transmitted = False
|
|
drop_reason = failure_reason
|
|
self.dropped_count += 1
|
|
|
|
if len(tx_result.results) > 1 and not tx_result.all_success:
|
|
logger.warning(
|
|
"Fan-out %s TX failure: %s successful=%s failed=%s",
|
|
"partial" if tx_result.any_success else "total",
|
|
f"rx_radio={rx_radio_id}" if rx_radio_id else "origin=local",
|
|
tx_result.successful_radio_ids,
|
|
tx_result.failed_radio_ids,
|
|
)
|
|
tx_radio_ids_sent = tx_result.successful_radio_ids or None
|
|
tx_egress = self._egress_records(tx_result)
|
|
|
|
# Scalar TX fields describe the primary egress.
|
|
primary = tx_result.primary
|
|
if primary is not None and primary.success:
|
|
tx_radio_id = primary.radio_id
|
|
tx_metadata = primary.metadata if primary is not None else None
|
|
if tx_metadata:
|
|
lbt_attempts = tx_metadata.get("lbt_attempts", 0)
|
|
lbt_backoff_delays_ms = tx_metadata.get("lbt_backoff_delays_ms", [])
|
|
lbt_channel_busy = tx_metadata.get("lbt_channel_busy", False)
|
|
|
|
if lbt_attempts > 0:
|
|
total_lbt_delay = sum(lbt_backoff_delays_ms)
|
|
logger.info(
|
|
f"LBT: {lbt_attempts} attempts, {total_lbt_delay:.0f}ms delay, "
|
|
f"backoffs={lbt_backoff_delays_ms}"
|
|
)
|
|
|
|
# Redundancy copies ride alongside the primary result: collect them
|
|
# without letting a failed extra change the primary outcome. Their
|
|
# sends are physical transmissions like any other, so they belong in
|
|
# the egress rows -- leaving them out would undercount exactly the
|
|
# traffic a multi-ack burst adds to a busy channel.
|
|
for extra_pkt, extra_task in extra_tx_tasks:
|
|
try:
|
|
extra_result = await self._await_egress_tx(extra_task, extra_pkt)
|
|
if extra_result.any_success:
|
|
self.forwarded_count += 1
|
|
extra_egress = self._egress_records(extra_result)
|
|
if extra_egress:
|
|
tx_egress = (tx_egress or []) + extra_egress
|
|
except Exception as e:
|
|
logger.warning(f"Multi-ack redundancy TX failed: {e}")
|
|
else:
|
|
self.dropped_count += 1
|
|
# Determine drop reason
|
|
if local_transmission and not allow_local_tx:
|
|
drop_reason = policy_reason or DropReason.NO_TX_MODE
|
|
elif not allow_forward:
|
|
drop_reason = policy_reason or DropReason.REPEAT_DISABLED
|
|
else:
|
|
# Check if packet has a specific drop reason set by handlers
|
|
drop_reason = processed_packet.drop_reason or self._get_drop_reason(
|
|
processed_packet, packet_hash=pkt_hash_full
|
|
)
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(f"Packet not forwarded: {drop_reason}")
|
|
|
|
# Extract packet type and route from header
|
|
if not hasattr(packet, "header") or packet.header is None:
|
|
logger.error(f"Packet missing header attribute! Packet: {packet}")
|
|
payload_type = 0
|
|
route_type = 0
|
|
else:
|
|
header_info = PacketHeaderUtils.parse_header(packet.header)
|
|
payload_type = header_info["payload_type"]
|
|
route_type = header_info["route_type"]
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
|
|
)
|
|
|
|
# Check if this is a duplicate
|
|
is_dupe = pkt_hash_full in self.seen_packets and not transmitted
|
|
|
|
# Set drop reason for duplicates and count flood vs direct dups
|
|
if is_dupe and drop_reason is None:
|
|
drop_reason = DropReason.DUPLICATE
|
|
if is_dupe:
|
|
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
|
|
self.flood_dup_count += 1
|
|
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
|
self.direct_dup_count += 1
|
|
|
|
# Expose effective drop reason to PacketRouter.
|
|
# Some Packet implementations are slot-based and cannot accept dynamic attrs.
|
|
metadata["_repeater_drop_reason"] = drop_reason
|
|
try:
|
|
setattr(packet, "_repeater_drop_reason", drop_reason)
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
display_hashes = (
|
|
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
|
|
)
|
|
path_hash = self._path_hash_display(display_hashes)
|
|
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
|
|
|
|
# Record packet for charts
|
|
packet_record = self._build_packet_record(
|
|
packet,
|
|
payload_type,
|
|
route_type,
|
|
rssi,
|
|
snr,
|
|
original_path_hashes,
|
|
path_hash_size,
|
|
path_hash,
|
|
src_hash,
|
|
dst_hash,
|
|
transmitted=transmitted,
|
|
drop_reason=drop_reason,
|
|
is_duplicate=is_dupe,
|
|
forwarded_path=forwarded_path_hashes,
|
|
tx_delay_ms=tx_delay_ms,
|
|
rx_radio_id=rx_radio_id,
|
|
tx_radio_id=tx_radio_id,
|
|
tx_radio_ids=tx_radio_ids_sent,
|
|
lbt_attempts=lbt_attempts,
|
|
lbt_backoff_delays_ms=lbt_backoff_delays_ms,
|
|
lbt_channel_busy=lbt_channel_busy,
|
|
packet_hash=pkt_hash_full,
|
|
)
|
|
|
|
# Store packet record to persistent storage
|
|
# Skip mqtt only for invalid packets (not duplicates or operational drops)
|
|
if self.storage:
|
|
try:
|
|
# Only skip mqtt for actual invalid/bad packets
|
|
invalid_reasons = (
|
|
DropReason.INVALID_ADVERT,
|
|
DropReason.EMPTY_PAYLOAD,
|
|
DropReason.PATH_TOO_LONG,
|
|
)
|
|
skip_mqtt = drop_reason in invalid_reasons if drop_reason else False
|
|
self.storage.record_packet(packet_record, skip_mqtt=skip_mqtt, tx_egress=tx_egress)
|
|
except Exception as e:
|
|
logger.error(f"Failed to store packet record: {e}")
|
|
|
|
# If this is a duplicate, try to attach it to the original packet
|
|
if is_dupe and len(self.recent_packets) > 0:
|
|
prev_pkt = self._recent_hash_index.get(packet_record["packet_hash"])
|
|
if prev_pkt is not None:
|
|
# Add duplicate to original packet's duplicate list
|
|
if "duplicates" not in prev_pkt:
|
|
prev_pkt["duplicates"] = []
|
|
if len(prev_pkt["duplicates"]) < self.max_duplicates_per_packet:
|
|
prev_pkt["duplicates"].append(packet_record)
|
|
# Don't add duplicate to main list, just track in original
|
|
else:
|
|
# Original not found, add as regular packet
|
|
self._append_recent_packet(packet_record)
|
|
else:
|
|
# Not a duplicate or first occurrence
|
|
self._append_recent_packet(packet_record)
|
|
|
|
return transmitted
|
|
|
|
@staticmethod
|
|
def _policy_drop_reason(decision: PolicyDecision) -> str:
|
|
if decision.rule_id is None:
|
|
return "Policy blocked packet"
|
|
return f"Policy blocked packet (rule {decision.rule_id})"
|
|
|
|
def log_trace_record(self, packet_record: dict) -> None:
|
|
"""Manually log a packet trace record (used by external callers)"""
|
|
self._append_recent_packet(packet_record)
|
|
|
|
self.rx_count += 1
|
|
if packet_record.get("transmitted", False):
|
|
self.forwarded_count += 1
|
|
else:
|
|
self.dropped_count += 1
|
|
|
|
# Store to persistent storage (same as __call__ does)
|
|
if self.storage:
|
|
try:
|
|
self.storage.record_packet(packet_record)
|
|
except Exception as e:
|
|
logger.error(f"Failed to store packet record: {e}")
|
|
|
|
def record_packet_only(self, packet: Packet, metadata: dict) -> None:
|
|
"""Record a packet for UI/storage without running forwarding or duplicate logic.
|
|
|
|
Used by the packet router for injection-only types (ANON_REQ, ACK, PATH, etc.)
|
|
so they still appear in the web UI.
|
|
|
|
TRACE packets are excluded: TraceHelper.log_trace_record stores the real trace path;
|
|
packet.path on TRACE holds SNR bytes, not routing hashes.
|
|
"""
|
|
if not self.storage:
|
|
return
|
|
rssi = metadata.get("rssi", 0)
|
|
snr = metadata.get("snr", 0.0)
|
|
if not hasattr(packet, "header") or packet.header is None:
|
|
logger.debug("record_packet_only: packet missing header, skipping")
|
|
return
|
|
header_info = PacketHeaderUtils.parse_header(packet.header)
|
|
payload_type = header_info["payload_type"]
|
|
route_type = header_info["route_type"]
|
|
if payload_type == PAYLOAD_TYPE_TRACE:
|
|
return
|
|
original_path_hashes = packet.get_path_hashes_hex()
|
|
path_hash_size = packet.get_path_hash_size()
|
|
path_hash = self._path_hash_display(original_path_hashes)
|
|
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
|
|
packet_record = self._build_packet_record(
|
|
packet,
|
|
payload_type,
|
|
route_type,
|
|
rssi,
|
|
snr,
|
|
original_path_hashes,
|
|
path_hash_size,
|
|
path_hash,
|
|
src_hash,
|
|
dst_hash,
|
|
rx_radio_id=(
|
|
metadata.get("rx_radio_id")
|
|
or getattr(packet, "_rx_radio_id", None)
|
|
or getattr(packet, "rx_radio_id", None)
|
|
),
|
|
packet_hash=packet.calculate_packet_hash().hex().upper(),
|
|
)
|
|
try:
|
|
self.storage.record_packet(packet_record, skip_mqtt=False)
|
|
except Exception as e:
|
|
logger.error(f"Failed to store packet record (record_packet_only): {e}")
|
|
return
|
|
self._append_recent_packet(packet_record)
|
|
|
|
# Another radio of this node hears a send only while it is on the air or
|
|
# shortly after, allowing for a remote modem's delivery delay.
|
|
OWN_ECHO_MARGIN_SECONDS = 2.0
|
|
# A send that never reports back stops counting after this long.
|
|
OWN_ECHO_INFLIGHT_LIMIT_SECONDS = 60.0
|
|
OWN_ECHO_MAX_ENTRIES = 256
|
|
|
|
def _note_own_send_started(self, packet: Packet, radio_id: Optional[str]):
|
|
"""Record a physical send about to go on air; return a handle for its outcome.
|
|
|
|
Only on a node with two or more radios: a lone radio cannot hear itself.
|
|
"""
|
|
_, radio_ids = self._fabric_endpoints()
|
|
if not radio_ids or len(radio_ids) < 2:
|
|
return None
|
|
path_hashes = packet.get_path_hashes_hex()
|
|
if not path_hashes:
|
|
return None
|
|
key = (packet.calculate_packet_hash().hex().upper(), tuple(path_hashes))
|
|
record = {"radio_id": radio_id, "started": time.monotonic(), "finished": None}
|
|
sends = self._recent_own_tx.pop(key, [])
|
|
sends.append(record)
|
|
self._recent_own_tx[key] = sends
|
|
while len(self._recent_own_tx) > self.OWN_ECHO_MAX_ENTRIES:
|
|
self._recent_own_tx.popitem(last=False)
|
|
return key, record
|
|
|
|
def _note_own_send_finished(self, handle, packet: Packet, *, sent: bool) -> None:
|
|
"""Close a send: keep it for its echo window, or forget it if nothing went on air."""
|
|
if handle is None:
|
|
return
|
|
key, record = handle
|
|
if record["finished"] is not None:
|
|
return
|
|
record["finished"] = time.monotonic()
|
|
if not sent:
|
|
sends = [r for r in self._recent_own_tx.get(key, []) if r is not record]
|
|
if sends:
|
|
self._recent_own_tx[key] = sends
|
|
else:
|
|
self._recent_own_tx.pop(key, None)
|
|
return
|
|
if record["radio_id"] is None:
|
|
metadata = getattr(packet, "_tx_metadata", None)
|
|
if isinstance(metadata, dict):
|
|
record["radio_id"] = metadata.get("radio_id") or metadata.get("tx_radio_id")
|
|
|
|
def _own_send_is_live(self, record: dict, now: float) -> bool:
|
|
if record["finished"] is None:
|
|
return now - record["started"] <= self.OWN_ECHO_INFLIGHT_LIMIT_SECONDS
|
|
return now - record["finished"] <= self.OWN_ECHO_MARGIN_SECONDS
|
|
|
|
def _is_own_echo(self, packet_hash: str, path_hashes, rx_radio_id) -> bool:
|
|
"""True when another radio of this node is hearing our own transmission.
|
|
|
|
The reception must carry exactly the path we sent, arrive on a radio other
|
|
than the one that sent it, and arrive while that send is on the air or
|
|
within OWN_ECHO_MARGIN_SECONDS of it finishing. A neighbour repeating our
|
|
copy appends its own hash; one whose 1-byte hash collides with ours is only
|
|
mistaken for an echo if it repeats the same packet on the same path inside
|
|
that window.
|
|
"""
|
|
if rx_radio_id is None or not path_hashes or not self._recent_own_tx:
|
|
return False
|
|
now = time.monotonic()
|
|
while self._recent_own_tx:
|
|
sends = next(iter(self._recent_own_tx.values()))
|
|
if any(self._own_send_is_live(record, now) for record in sends):
|
|
break
|
|
self._recent_own_tx.popitem(last=False)
|
|
for record in self._recent_own_tx.get((packet_hash, tuple(path_hashes)), ()):
|
|
if not self._own_send_is_live(record, now):
|
|
continue
|
|
sender = record["radio_id"]
|
|
if sender is None or str(sender) != str(rx_radio_id):
|
|
return True
|
|
return False
|
|
|
|
def record_duplicate(self, packet: Packet, rssi: int = 0, snr: float = 0.0) -> None:
|
|
"""Record a known-duplicate packet for UI/storage visibility without forwarding.
|
|
|
|
Called by the raw_packet_subscriber path so that path variants blocked
|
|
by the Dispatcher's payload-based dedup still appear in the UI.
|
|
"""
|
|
self.rx_count += 1
|
|
route_type = packet.header & PH_ROUTE_MASK
|
|
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
|
|
self.recv_flood_count += 1
|
|
self.flood_dup_count += 1
|
|
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
|
self.recv_direct_count += 1
|
|
self.direct_dup_count += 1
|
|
|
|
header_info = PacketHeaderUtils.parse_header(packet.header)
|
|
payload_type = header_info["payload_type"]
|
|
route_type_parsed = header_info["route_type"]
|
|
|
|
original_path_hashes = packet.get_path_hashes_hex()
|
|
path_hash_size = packet.get_path_hash_size()
|
|
path_hash = self._path_hash_display(original_path_hashes)
|
|
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
|
|
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
|
|
|
frame_len = packet.get_raw_length() if hasattr(packet, "get_raw_length") else 0
|
|
score = flood_rx_metrics(
|
|
frame_len,
|
|
snr,
|
|
self.radio_config["spreading_factor"],
|
|
self.radio_config["bandwidth"],
|
|
self.radio_config["coding_rate"],
|
|
self.radio_config["preamble_length"],
|
|
).score
|
|
rx_radio_id = getattr(packet, "_rx_radio_id", None) or getattr(packet, "rx_radio_id", None)
|
|
if not self._is_own_echo(pkt_hash_full, original_path_hashes, rx_radio_id):
|
|
self.neighbour_link_tracker.observe(
|
|
packet,
|
|
route_type=route_type,
|
|
payload_type=payload_type,
|
|
rssi=float(rssi),
|
|
snr=float(snr),
|
|
score=score,
|
|
is_duplicate=True,
|
|
rx_radio_id=rx_radio_id,
|
|
)
|
|
|
|
packet_record = self._build_packet_record(
|
|
packet,
|
|
payload_type,
|
|
route_type_parsed,
|
|
rssi,
|
|
snr,
|
|
original_path_hashes,
|
|
path_hash_size,
|
|
path_hash,
|
|
src_hash,
|
|
dst_hash,
|
|
transmitted=False,
|
|
drop_reason=DropReason.DUPLICATE,
|
|
is_duplicate=True,
|
|
rx_radio_id=(
|
|
getattr(packet, "_rx_radio_id", None) or getattr(packet, "rx_radio_id", None)
|
|
),
|
|
packet_hash=pkt_hash_full,
|
|
)
|
|
|
|
if self.storage:
|
|
try:
|
|
self.storage.record_packet(packet_record, skip_mqtt=False)
|
|
except Exception as e:
|
|
logger.error(f"Failed to store duplicate record: {e}")
|
|
|
|
# Group under original in recent_packets
|
|
if len(self.recent_packets) > 0:
|
|
prev_pkt = self._recent_hash_index.get(packet_record["packet_hash"])
|
|
if prev_pkt is not None:
|
|
if "duplicates" not in prev_pkt:
|
|
prev_pkt["duplicates"] = []
|
|
prev_pkt["duplicates"].append(packet_record)
|
|
else:
|
|
self._append_recent_packet(packet_record)
|
|
else:
|
|
self._append_recent_packet(packet_record)
|
|
|
|
def cleanup_cache(self):
|
|
|
|
now = time.time()
|
|
expired = [k for k, ts in self.seen_packets.items() if now - ts > self.cache_ttl]
|
|
for k in expired:
|
|
del self.seen_packets[k]
|
|
|
|
def _path_hash_display(self, display_hashes) -> Optional[str]:
|
|
"""Build path hash string for packet record from path hashes list."""
|
|
if not display_hashes:
|
|
return None
|
|
display = display_hashes[:8]
|
|
if len(display_hashes) > 8:
|
|
display = list(display) + ["..."]
|
|
return "[" + ", ".join(display) + "]"
|
|
|
|
def _packet_record_src_dst(
|
|
self, packet: Packet, payload_type: int
|
|
) -> Tuple[Optional[str], Optional[str]]:
|
|
"""Return (src_hash, dst_hash) for packet_record from packet and payload_type."""
|
|
src_hash = None
|
|
dst_hash = None
|
|
payload = getattr(packet, "payload", None)
|
|
if payload_type in [0x00, 0x01, 0x02, 0x08]:
|
|
if payload and len(payload) >= 2:
|
|
dst_hash = f"{payload[0]:02X}"
|
|
src_hash = f"{payload[1]:02X}"
|
|
elif payload_type == PAYLOAD_TYPE_ADVERT:
|
|
if payload and len(payload) >= 1:
|
|
src_hash = f"{payload[0]:02X}"
|
|
elif payload_type == PAYLOAD_TYPE_ANON_REQ:
|
|
if payload and len(payload) >= 1:
|
|
dst_hash = f"{payload[0]:02X}"
|
|
return (src_hash, dst_hash)
|
|
|
|
def _build_packet_record(
|
|
self,
|
|
packet: Packet,
|
|
payload_type: int,
|
|
route_type: int,
|
|
rssi: int,
|
|
snr: float,
|
|
original_path_hashes,
|
|
path_hash_size: int,
|
|
path_hash: Optional[str],
|
|
src_hash: Optional[str],
|
|
dst_hash: Optional[str],
|
|
*,
|
|
transmitted: bool = False,
|
|
drop_reason: Optional[str] = None,
|
|
is_duplicate: bool = False,
|
|
forwarded_path=None,
|
|
tx_delay_ms: float = 0.0,
|
|
rx_radio_id: Optional[str] = None,
|
|
tx_radio_id: Optional[str] = None,
|
|
tx_radio_ids=None,
|
|
lbt_attempts: int = 0,
|
|
lbt_backoff_delays_ms=None,
|
|
lbt_channel_busy: bool = False,
|
|
packet_hash: Optional[str] = None,
|
|
) -> dict:
|
|
"""Build a single packet_record dict for storage and recent_packets.
|
|
|
|
``tx_radio_id`` is the primary successful egress; ``tx_radio_ids`` lists
|
|
every radio that successfully transmitted this logical packet.
|
|
"""
|
|
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
|
payload = getattr(packet, "payload", None)
|
|
payload_len = len(payload or b"")
|
|
upstream_hash, upstream_hash_size = self.neighbour_link_tracker.get_upstream_peer_identity(
|
|
packet,
|
|
route_type,
|
|
payload_type,
|
|
path_hashes=original_path_hashes,
|
|
path_hash_size=path_hash_size,
|
|
)
|
|
|
|
# LoRa time-on-air for this packet (Semtech reference formula).
|
|
# Computed once here so every downstream consumer (MQTT, SQLite, Glass,
|
|
# websocket) reads the same value instead of recomputing or shipping
|
|
# zeros. The same calculator is used by the RX accumulator and the
|
|
# duty-cycle gate, keeping reporting and metering aligned.
|
|
airtime_ms = 0.0
|
|
try:
|
|
raw_len = packet.get_raw_length() if hasattr(packet, "get_raw_length") else 0
|
|
if raw_len > 0:
|
|
# The radio that carried it: as heard, else as first sent. On a
|
|
# bridge the same bytes are a different time on air per radio, so
|
|
# a figure computed on another radio's modulation describes a
|
|
# transmission that never happened.
|
|
carrier = rx_radio_id or getattr(packet, "_rx_radio_id", None) or tx_radio_id
|
|
airtime_ms = float(
|
|
self.airtime_budgets.for_radio(carrier).calculate_airtime(raw_len)
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not compute airtime for packet record: {e}")
|
|
|
|
return {
|
|
"timestamp": time.time(),
|
|
"header": (
|
|
f"0x{packet.header:02X}"
|
|
if hasattr(packet, "header") and packet.header is not None
|
|
else None
|
|
),
|
|
"payload": payload.hex() if payload else None,
|
|
"payload_length": len(payload) if payload else 0,
|
|
"type": payload_type,
|
|
"route": route_type,
|
|
"length": payload_len,
|
|
"rssi": rssi,
|
|
"snr": snr,
|
|
"score": flood_rx_metrics(
|
|
packet.get_raw_length() if hasattr(packet, "get_raw_length") else 0,
|
|
snr,
|
|
self.radio_config["spreading_factor"],
|
|
self.radio_config["bandwidth"],
|
|
self.radio_config["coding_rate"],
|
|
self.radio_config["preamble_length"],
|
|
).score,
|
|
"tx_delay_ms": tx_delay_ms,
|
|
"rx_radio_id": rx_radio_id
|
|
or getattr(packet, "_rx_radio_id", None)
|
|
or getattr(packet, "rx_radio_id", None),
|
|
"tx_radio_id": tx_radio_id,
|
|
"tx_radio_ids": list(tx_radio_ids) if tx_radio_ids else None,
|
|
"airtime_ms": airtime_ms,
|
|
"transmitted": transmitted,
|
|
"is_duplicate": is_duplicate,
|
|
"packet_hash": pkt_hash[:16],
|
|
"drop_reason": drop_reason,
|
|
"path_hash": path_hash,
|
|
"src_hash": src_hash,
|
|
"dst_hash": dst_hash,
|
|
"original_path": original_path_hashes or None,
|
|
"forwarded_path": forwarded_path,
|
|
"path_hash_size": path_hash_size,
|
|
"upstream_hash": upstream_hash,
|
|
"upstream_hash_size": upstream_hash_size,
|
|
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
|
|
"lbt_attempts": lbt_attempts,
|
|
"lbt_backoff_delays_ms": lbt_backoff_delays_ms,
|
|
"lbt_channel_busy": lbt_channel_busy,
|
|
}
|
|
|
|
def _append_recent_packet(self, packet_record: dict) -> None:
|
|
"""Append packet to bounded recent list and keep hash index aligned."""
|
|
if len(self.recent_packets) >= self.max_recent_packets:
|
|
oldest = self.recent_packets.popleft()
|
|
oldest_hash = oldest.get("packet_hash") if isinstance(oldest, dict) else None
|
|
if oldest_hash and self._recent_hash_index.get(oldest_hash) is oldest:
|
|
del self._recent_hash_index[oldest_hash]
|
|
|
|
self.recent_packets.append(packet_record)
|
|
pkt_hash = packet_record.get("packet_hash") if isinstance(packet_record, dict) else None
|
|
if pkt_hash:
|
|
self._recent_hash_index[pkt_hash] = packet_record
|
|
|
|
def _get_drop_reason(self, packet: Packet, packet_hash: Optional[str] = None) -> str:
|
|
|
|
if self.is_duplicate(packet, packet_hash=packet_hash):
|
|
return DropReason.DUPLICATE
|
|
|
|
if not packet or not packet.payload:
|
|
return DropReason.EMPTY_PAYLOAD
|
|
|
|
if len(packet.path or []) > MAX_PATH_SIZE:
|
|
return DropReason.PATH_TOO_LONG
|
|
|
|
route_type = packet.header & PH_ROUTE_MASK
|
|
|
|
if route_type == ROUTE_TYPE_FLOOD:
|
|
# Check if unscoped flood policy blocked it
|
|
unscoped_flood_allow = self.config.get("mesh", {}).get(
|
|
"unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)
|
|
)
|
|
if not unscoped_flood_allow:
|
|
return DropReason.UNSCOPED_FLOOD_DISABLED
|
|
|
|
if route_type == ROUTE_TYPE_DIRECT:
|
|
hash_size = packet.get_path_hash_size()
|
|
if not packet.path or len(packet.path) < hash_size:
|
|
return DropReason.DIRECT_NO_PATH
|
|
next_hop = bytes(packet.path[:hash_size])
|
|
if next_hop != self.local_hash_bytes[:hash_size]:
|
|
return DropReason.DIRECT_NOT_FOR_US
|
|
|
|
# Default reason
|
|
return "Unknown"
|
|
|
|
def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool:
|
|
"""Return True if this packet has already been seen.
|
|
|
|
Accepts an optional pre-computed packet_hash to avoid a redundant SHA-256
|
|
when the caller (e.g. __call__ → process_packet → flood/direct_forward)
|
|
has already calculated the hash. Falls back to computing it if not provided.
|
|
|
|
INVARIANT: this method is synchronous with no await points. The caller
|
|
(process_packet / __call__) relies on is_duplicate + mark_seen being
|
|
effectively atomic within the asyncio event loop. Do NOT add any await
|
|
here without revisiting that invariant.
|
|
"""
|
|
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
|
return pkt_hash in self.seen_packets
|
|
|
|
def mark_seen(self, packet: Packet, packet_hash: Optional[str] = None):
|
|
|
|
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
|
self.seen_packets[pkt_hash] = time.time()
|
|
|
|
if len(self.seen_packets) > self.max_cache_size:
|
|
self.seen_packets.popitem(last=False)
|
|
|
|
def validate_packet(self, packet: Packet) -> Tuple[bool, str]:
|
|
|
|
if not packet or not packet.payload:
|
|
return False, DropReason.EMPTY_PAYLOAD
|
|
|
|
if packet.get_path_hash_size() > 3:
|
|
return False, "Reserved path hash size is invalid"
|
|
|
|
if len(packet.path or []) > MAX_PATH_SIZE:
|
|
return (
|
|
False,
|
|
f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})",
|
|
)
|
|
|
|
return True, ""
|
|
|
|
@staticmethod
|
|
def _normalize_multi_acks(config: dict) -> int:
|
|
"""Read ``repeater.multi_acks``, constrained to 0..1 like MeshCore's
|
|
CommonCLI prefs load."""
|
|
try:
|
|
value = int(config.get("repeater", {}).get("multi_acks", 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
return max(0, min(1, value))
|
|
|
|
def _normalize_loop_detect_mode(self, mode) -> str:
|
|
if isinstance(mode, str):
|
|
normalized = mode.strip().lower()
|
|
if normalized in {
|
|
LOOP_DETECT_OFF,
|
|
LOOP_DETECT_MINIMAL,
|
|
LOOP_DETECT_MODERATE,
|
|
LOOP_DETECT_STRICT,
|
|
}:
|
|
return normalized
|
|
return LOOP_DETECT_OFF
|
|
|
|
def _get_loop_detect_mode(self) -> str:
|
|
return self.loop_detect_mode
|
|
|
|
def _is_flood_looped(self, packet: Packet, mode: Optional[str] = None) -> bool:
|
|
mode = mode or self._get_loop_detect_mode()
|
|
if mode == LOOP_DETECT_OFF:
|
|
return False
|
|
|
|
max_counters = LOOP_DETECT_MAX_COUNTERS.get(mode)
|
|
if max_counters is None:
|
|
return False
|
|
|
|
hash_size = packet.get_path_hash_size()
|
|
if hash_size not in max_counters:
|
|
return False
|
|
|
|
max_counter = max_counters[hash_size]
|
|
hop_count = packet.get_path_hash_count()
|
|
path = packet.path or bytearray()
|
|
local_hash = self.local_hash_bytes[:hash_size]
|
|
local_count = sum(
|
|
1
|
|
for i in range(hop_count)
|
|
if bytes(path[i * hash_size : (i + 1) * hash_size]) == local_hash
|
|
)
|
|
return local_count >= max_counter
|
|
|
|
def _check_transport_codes(self, packet: Packet) -> Tuple[bool, str]:
|
|
|
|
if not self.storage:
|
|
logger.warning("Transport code check failed: no storage available")
|
|
return False, "No storage available for transport key validation"
|
|
|
|
try:
|
|
from openhop_core.protocol.transport_keys import calc_transport_code
|
|
|
|
# Check cache validity
|
|
current_time = time.time()
|
|
if (
|
|
self._transport_keys_cache is None
|
|
or current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl
|
|
):
|
|
# Refresh cache
|
|
self._transport_keys_cache = self.storage.get_transport_keys()
|
|
self._transport_keys_cache_time = current_time
|
|
|
|
transport_keys = self._transport_keys_cache
|
|
|
|
if not transport_keys:
|
|
return False, "No transport keys configured"
|
|
|
|
# Check if packet has transport codes
|
|
if not packet.has_transport_codes():
|
|
return False, "No transport codes present"
|
|
|
|
transport_code_0 = packet.transport_codes[0] # First transport code
|
|
|
|
# Check packet against each transport key
|
|
for key_record in transport_keys:
|
|
transport_key_encoded = key_record.get("transport_key")
|
|
key_name = key_record.get("name", "unknown")
|
|
flood_policy = key_record.get("flood_policy", "deny")
|
|
|
|
if not transport_key_encoded:
|
|
continue
|
|
|
|
try:
|
|
import base64
|
|
|
|
transport_key = base64.b64decode(transport_key_encoded)
|
|
expected_code = calc_transport_code(transport_key, packet)
|
|
if transport_code_0 == expected_code:
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"Transport code validated for key '{key_name}' with policy '{flood_policy}'"
|
|
)
|
|
|
|
# Update last_used timestamp for this key
|
|
try:
|
|
key_id = key_record.get("id")
|
|
if key_id:
|
|
self.storage.update_transport_key(
|
|
key_id=key_id, last_used=time.time()
|
|
)
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"Updated last_used timestamp for transport key '{key_name}'"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to update last_used for transport key '{key_name}': {e}"
|
|
)
|
|
|
|
# Check flood policy for this key
|
|
if flood_policy == "allow":
|
|
return True, ""
|
|
else:
|
|
return False, f"Transport key '{key_name}' flood policy denied"
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error checking transport key '{key_name}': {e}")
|
|
continue
|
|
|
|
# No matching transport code found
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)"
|
|
)
|
|
return False, "No matching transport code"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Transport code validation error: {e}")
|
|
return False, f"Transport code validation error: {e}"
|
|
|
|
def flood_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]:
|
|
"""Forward a FLOOD packet, appending our hash to the path.
|
|
|
|
INVARIANT: purely synchronous — no await points. The is_duplicate +
|
|
mark_seen pair is atomic within the asyncio event loop. Do NOT add any
|
|
await here without revisiting that invariant in __call__ / process_packet.
|
|
"""
|
|
# Validate
|
|
valid, reason = self.validate_packet(packet)
|
|
if not valid:
|
|
packet.drop_reason = reason
|
|
return None
|
|
|
|
# Check if packet is marked do-not-retransmit
|
|
if packet.is_marked_do_not_retransmit():
|
|
# Check if packet has custom drop reason
|
|
if not packet.drop_reason:
|
|
packet.drop_reason = DropReason.MARKED_DO_NOT_RETRANSMIT
|
|
return None
|
|
|
|
# Check unscoped flood policy
|
|
unscoped_flood_allow = self.config.get("mesh", {}).get(
|
|
"unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)
|
|
)
|
|
route_type = packet.header & PH_ROUTE_MASK
|
|
if route_type == ROUTE_TYPE_FLOOD:
|
|
if not unscoped_flood_allow:
|
|
packet.drop_reason = DropReason.UNSCOPED_FLOOD_DISABLED
|
|
return None
|
|
|
|
# Check transport scopes flood policy
|
|
if route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
|
allowed, check_reason = self._check_transport_codes(packet)
|
|
if not allowed:
|
|
packet.drop_reason = DropReason.TRANSPORT_CODE_NOT_ALLOWED
|
|
return None
|
|
|
|
mode = self._get_loop_detect_mode()
|
|
if self._is_flood_looped(packet, mode):
|
|
packet.drop_reason = f"{DropReason.FLOOD_LOOP_DETECTED} ({mode})"
|
|
return None
|
|
|
|
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
|
if self.is_duplicate(packet, packet_hash=packet_hash):
|
|
packet.drop_reason = DropReason.DUPLICATE
|
|
return None
|
|
|
|
if packet.path is None:
|
|
packet.path = bytearray()
|
|
elif not isinstance(packet.path, bytearray):
|
|
packet.path = bytearray(packet.path)
|
|
|
|
hash_size = packet.get_path_hash_size()
|
|
hop_count = packet.get_path_hash_count()
|
|
|
|
if self.max_flood_hops > 0 and hop_count >= self.max_flood_hops:
|
|
packet.drop_reason = f"{DropReason.MAX_FLOOD_HOPS} ({hop_count}/{self.max_flood_hops})"
|
|
return None
|
|
|
|
# path_len encodes hop count in 6 bits (0-63); adding ourselves must not exceed 63
|
|
if hop_count >= 63:
|
|
packet.drop_reason = f"{DropReason.PATH_HOP_COUNT_MAX} (63), cannot append"
|
|
return None
|
|
|
|
# Check path won't exceed MAX_PATH_SIZE after append
|
|
if (hop_count + 1) * hash_size > MAX_PATH_SIZE:
|
|
packet.drop_reason = DropReason.PATH_EXCEEDS_MAX_SIZE
|
|
return None
|
|
|
|
self.mark_seen(packet, packet_hash=packet_hash)
|
|
|
|
# Append hash_size bytes from our public key prefix
|
|
packet.path.extend(self.local_hash_bytes[:hash_size])
|
|
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count + 1)
|
|
|
|
# This is a transit packet received over RF. Its existing scope decision
|
|
# is authoritative: a plain flood must remain plain, and a transport
|
|
# flood must retain the originator's codes when the shared dispatcher
|
|
# performs its send-time scope resolution.
|
|
packet._flood_scope_applied = True
|
|
|
|
return packet
|
|
|
|
def direct_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]:
|
|
"""Forward a DIRECT packet, removing the first hop from the path.
|
|
|
|
INVARIANT: purely synchronous — no await points. The is_duplicate +
|
|
mark_seen pair is atomic within the asyncio event loop. Do NOT add any
|
|
await here without revisiting that invariant in __call__ / process_packet.
|
|
"""
|
|
# Validate packet (empty payload, oversized path, etc.)
|
|
valid, reason = self.validate_packet(packet)
|
|
if not valid:
|
|
packet.drop_reason = reason
|
|
return None
|
|
|
|
# Check if packet is marked do-not-retransmit
|
|
if packet.is_marked_do_not_retransmit():
|
|
if not packet.drop_reason:
|
|
packet.drop_reason = DropReason.MARKED_DO_NOT_RETRANSMIT
|
|
return None
|
|
|
|
hash_size = packet.get_path_hash_size()
|
|
hop_count = packet.get_path_hash_count()
|
|
|
|
# Check if we're the next hop
|
|
if not packet.path or len(packet.path) < hash_size:
|
|
packet.drop_reason = DropReason.DIRECT_NO_PATH
|
|
return None
|
|
|
|
next_hop = bytes(packet.path[:hash_size])
|
|
if next_hop != self.local_hash_bytes[:hash_size]:
|
|
packet.drop_reason = DropReason.DIRECT_NOT_FOR_US
|
|
return None
|
|
|
|
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
|
if self.is_duplicate(packet, packet_hash=packet_hash):
|
|
packet.drop_reason = DropReason.DUPLICATE
|
|
return None
|
|
|
|
self.mark_seen(packet, packet_hash=packet_hash)
|
|
|
|
# Remove first hash entry (hash_size bytes)
|
|
packet.path = bytearray(packet.path[hash_size:])
|
|
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count - 1)
|
|
|
|
return packet
|
|
|
|
# Multipart ACKs are spaced ~300ms apart per remaining fragment, matching
|
|
# MeshCore's forwardMultipartDirect (`(remaining + 1) * 300`).
|
|
MULTIPART_ACK_SPACING_MS = 300
|
|
|
|
def forward_multipart_direct(
|
|
self, packet: Packet, snr: float = 0.0, packet_hash: Optional[str] = None
|
|
) -> Optional["ForwardResult"]:
|
|
"""Forward a MULTIPART packet the way MeshCore's forwardMultipartDirect does.
|
|
|
|
MeshCore never relays the multipart wrapper as an ordinary directed
|
|
packet. Only a multipart-ACK at an intermediate direct hop is relayed,
|
|
and it is relayed by regenerating the embedded ACK as a plain DIRECT ACK
|
|
(the wrapper byte stripped) rather than repeating the wrapper. Every
|
|
other multipart case — flood routing, a final hop, or a non-ACK embedded
|
|
type — is dropped. Returns a ``ForwardResult`` — ``(ack_packet,
|
|
delay_seconds)`` plus any multi-ack redundancy ``extras`` — when a
|
|
regenerated ACK should be sent, otherwise ``None``.
|
|
|
|
INVARIANT: purely synchronous, mirroring flood_forward / direct_forward —
|
|
the is_duplicate + mark_seen pair must stay atomic within the event loop.
|
|
"""
|
|
# MeshCore only forwards multipart on the direct-route branch; the flood
|
|
# switch case updates ACK state locally but never re-routes it.
|
|
if not packet.is_route_direct():
|
|
packet.drop_reason = f"{DropReason.MULTIPART}: not direct-routed"
|
|
return None
|
|
|
|
valid, reason = self.validate_packet(packet)
|
|
if not valid:
|
|
packet.drop_reason = reason
|
|
return None
|
|
|
|
if packet.is_marked_do_not_retransmit():
|
|
packet.drop_reason = packet.drop_reason or DropReason.MARKED_DO_NOT_RETRANSMIT
|
|
return None
|
|
|
|
payload = packet.payload or b""
|
|
remaining = payload[0] >> 4
|
|
embedded_type = payload[0] & 0x0F
|
|
# Only multipart-ACKs are relayed; MeshCore leaves other types for future
|
|
# use and does not forward them.
|
|
if embedded_type != PAYLOAD_TYPE_ACK or len(payload) < 5:
|
|
packet.drop_reason = f"{DropReason.MULTIPART}: unsupported embedded type"
|
|
return None
|
|
|
|
hash_size = packet.get_path_hash_size()
|
|
hop_count = packet.get_path_hash_count()
|
|
|
|
# We must be the next hop (a final-hop multipart has no remaining path and
|
|
# is handled locally, not forwarded).
|
|
if not packet.path or len(packet.path) < hash_size:
|
|
packet.drop_reason = DropReason.DIRECT_NO_PATH
|
|
return None
|
|
if bytes(packet.path[:hash_size]) != self.local_hash_bytes[:hash_size]:
|
|
packet.drop_reason = DropReason.DIRECT_NOT_FOR_US
|
|
return None
|
|
|
|
# Dedupe before regenerating, on MeshCore's exact seen key: hasSeen()
|
|
# runs on a copy that keeps the MULTIPART payload type but carries the
|
|
# unwrapped ACK payload (the `remaining` count is stripped). Keeping
|
|
# that key distinct from the plain ACK's is what lets a multi-ack
|
|
# redundancy pair — MULTIPART copy plus plain ACK — survive every hop
|
|
# instead of the second half being swallowed as a duplicate. The
|
|
# pre-computed hash belongs to the wrapper as received, so recompute.
|
|
seen_key = (
|
|
PacketHashingUtils.calculate_packet_hash(
|
|
PAYLOAD_TYPE_MULTIPART, packet.path_len, bytes(payload[1:])
|
|
)
|
|
.hex()
|
|
.upper()
|
|
)
|
|
if self.is_duplicate(packet, packet_hash=seen_key):
|
|
packet.drop_reason = DropReason.DUPLICATE
|
|
return None
|
|
self.mark_seen(packet, packet_hash=seen_key)
|
|
|
|
# Rebuild the received wrapper as the plain DIRECT ACK MeshCore would emit:
|
|
# drop the multipart header byte and force the ACK payload/route type, then
|
|
# remove this node from the path (hasSeen()/removeSelfFromPath() order).
|
|
packet.payload = bytearray(payload[1:])
|
|
packet.payload_len = len(packet.payload)
|
|
packet.header = (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT
|
|
packet.transport_codes = [0, 0]
|
|
packet.path = bytearray(packet.path[hash_size:])
|
|
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count - 1)
|
|
|
|
extras, delay_ms = self._multi_ack_extras(
|
|
packet, float((remaining + 1) * self.MULTIPART_ACK_SPACING_MS), snr
|
|
)
|
|
return ForwardResult(packet, delay_ms / 1000.0, extras)
|
|
|
|
def _multi_ack_extras(
|
|
self, ack_packet: Packet, base_delay_ms: float, snr: float
|
|
) -> Tuple[list, float]:
|
|
"""Build MeshCore's optional multi-ack redundancy copies for a relayed ACK.
|
|
|
|
With ``multi_acks`` enabled, routeDirectRecvAcks precedes the plain ACK
|
|
with a MULTIPART-wrapped copy (``remaining<<4 | ACK`` prefix byte) on the
|
|
already self-removed path, pushing the accumulated delay out by a direct
|
|
retransmit delay + 300 ms per copy; the plain ACK itself slides to the
|
|
final accumulated delay. Returns ``(extras, plain_ack_delay_ms)``.
|
|
"""
|
|
extra = self.multi_acks
|
|
extras = []
|
|
delay_ms = base_delay_ms
|
|
while extra > 0:
|
|
delay_ms += (
|
|
self._calculate_tx_delay(ack_packet, snr) * 1000.0 + self.MULTIPART_ACK_SPACING_MS
|
|
)
|
|
wrapped = Packet()
|
|
wrapped.header = (PAYLOAD_TYPE_MULTIPART << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT
|
|
wrapped.payload = bytearray(
|
|
bytes([(extra << 4) | PAYLOAD_TYPE_ACK]) + bytes(ack_packet.payload)
|
|
)
|
|
wrapped.payload_len = len(wrapped.payload)
|
|
wrapped.path = bytearray(ack_packet.path)
|
|
wrapped.path_len = ack_packet.path_len
|
|
extras.append((wrapped, delay_ms / 1000.0))
|
|
extra -= 1
|
|
return extras, delay_ms
|
|
|
|
def forward_routed_ack(
|
|
self, packet: Packet, snr: float = 0.0, packet_hash: Optional[str] = None
|
|
) -> Optional["ForwardResult"]:
|
|
"""Relay a routed ACK the way MeshCore's routeDirectRecvAcks does.
|
|
|
|
An ACK at an intermediate direct hop is not repeated verbatim: MeshCore
|
|
regenerates it (createAck) as a plain DIRECT ACK — transport codes
|
|
dropped, every other header bit cleared — removes this node from the
|
|
path, and transmits it with zero retransmit delay rather than the
|
|
generic direct forwarding delay. With ``multi_acks`` enabled, the plain
|
|
ACK is preceded by a MULTIPART-wrapped redundancy copy and both slide
|
|
out by a direct retransmit delay + 300 ms.
|
|
|
|
INVARIANT: purely synchronous, mirroring direct_forward — the
|
|
is_duplicate + mark_seen pair must stay atomic within the event loop.
|
|
"""
|
|
valid, reason = self.validate_packet(packet)
|
|
if not valid:
|
|
packet.drop_reason = reason
|
|
return None
|
|
|
|
if packet.is_marked_do_not_retransmit():
|
|
packet.drop_reason = packet.drop_reason or DropReason.MARKED_DO_NOT_RETRANSMIT
|
|
return None
|
|
|
|
hash_size = packet.get_path_hash_size()
|
|
hop_count = packet.get_path_hash_count()
|
|
|
|
# A final-hop ACK (no remaining path) is consumed locally, never relayed.
|
|
if not packet.path or len(packet.path) < hash_size:
|
|
packet.drop_reason = DropReason.DIRECT_NO_PATH
|
|
return None
|
|
if bytes(packet.path[:hash_size]) != self.local_hash_bytes[:hash_size]:
|
|
packet.drop_reason = DropReason.DIRECT_NOT_FOR_US
|
|
return None
|
|
|
|
# The packet hash covers only the payload type and payload, so the
|
|
# received form and the regenerated plain ACK share one key — matching
|
|
# MeshCore's hasSeen() on the incoming ACK before removeSelfFromPath().
|
|
if self.is_duplicate(packet, packet_hash=packet_hash):
|
|
packet.drop_reason = DropReason.DUPLICATE
|
|
return None
|
|
self.mark_seen(packet, packet_hash=packet_hash)
|
|
|
|
packet.header = (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT
|
|
packet.transport_codes = [0, 0]
|
|
packet.path = bytearray(packet.path[hash_size:])
|
|
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count - 1)
|
|
|
|
extras, delay_ms = self._multi_ack_extras(packet, 0.0, snr)
|
|
return ForwardResult(packet, delay_ms / 1000.0, extras)
|
|
|
|
@staticmethod
|
|
def calculate_packet_score(snr: float, packet_len: int, spreading_factor: int = 8) -> float:
|
|
"""Reception-quality score in [0, 1] via the shared core scorer
|
|
(MeshCore RadioLibWrappers packetScoreInt)."""
|
|
return packet_score(snr, spreading_factor, packet_len)
|
|
|
|
def _calculate_tx_delay(self, packet: Packet, snr: float = 0.0) -> float:
|
|
|
|
packet_len = packet.get_raw_length()
|
|
airtime_ms = self.airtime_mgr.calculate_airtime(packet_len)
|
|
|
|
if packet.is_route_flood():
|
|
delay_factor = self.tx_delay_factor
|
|
elif packet.is_route_direct():
|
|
delay_factor = self.direct_tx_delay_factor
|
|
else:
|
|
delay_factor = self.direct_tx_delay_factor
|
|
|
|
# MeshCore uses the same random window for flood and direct variants:
|
|
# choose a value from 0 through 5 * airtime * factor.
|
|
base_delay_ms = airtime_ms * delay_factor
|
|
random_mult = secrets.randbelow(5001) / 1000.0
|
|
delay_s = (base_delay_ms * random_mult) / 1000.0
|
|
|
|
# OpenHop's optional score gate is applied only after the 50 ms threshold.
|
|
if delay_s >= 0.05 and self.use_score_for_tx:
|
|
score = self.calculate_packet_score(snr, packet_len)
|
|
# Higher score = shorter delay: max(0.2, 1.0 - score)
|
|
# score 1.0 → multiplier 0.2 (20% of original)
|
|
# score 0.0 → multiplier 1.0 (100% of original)
|
|
score_multiplier = max(0.2, 1.0 - score)
|
|
delay_s = delay_s * score_multiplier
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"Congestion detected (delay >= 50ms), score={score:.2f}, "
|
|
f"delay multiplier={score_multiplier:.2f}"
|
|
)
|
|
|
|
# Cap at 5 seconds maximum
|
|
delay_s = min(delay_s, 5.0)
|
|
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug(
|
|
f"Route={'FLOOD' if packet.is_route_flood() else 'DIRECT'}, "
|
|
f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s"
|
|
)
|
|
|
|
return delay_s
|
|
|
|
def process_packet(
|
|
self,
|
|
packet: Packet,
|
|
snr: float = 0.0,
|
|
packet_hash: Optional[str] = None,
|
|
) -> Optional[Tuple[Packet, float]]:
|
|
"""Route a received packet to flood_forward or direct_forward.
|
|
|
|
packet_hash is the pre-computed SHA-256 hex string from __call__.
|
|
Passing it here avoids recomputing the hash in flood_forward /
|
|
direct_forward / is_duplicate / mark_seen — reducing SHA-256 calls
|
|
from 3 per forwarded packet to 1.
|
|
"""
|
|
route_type = packet.header & PH_ROUTE_MASK
|
|
|
|
# MeshCore routes multipart traffic through its own branch, never the
|
|
# generic flood/direct path — it regenerates embedded ACKs and drops
|
|
# everything else. Its delay is fixed by the fragment count, so return
|
|
# the (packet, delay) pair directly instead of _calculate_tx_delay.
|
|
payload_type = packet.get_payload_type()
|
|
if payload_type == PAYLOAD_TYPE_MULTIPART:
|
|
return self.forward_multipart_direct(packet, snr, packet_hash=packet_hash)
|
|
|
|
# Routed ACKs also get their own MeshCore branch: an intermediate direct
|
|
# hop re-emits the ACK immediately (routeDirectRecvAcks) instead of the
|
|
# delayed generic direct forward. Flood ACKs stay on the generic path.
|
|
if payload_type == PAYLOAD_TYPE_ACK and route_type in (
|
|
ROUTE_TYPE_DIRECT,
|
|
ROUTE_TYPE_TRANSPORT_DIRECT,
|
|
):
|
|
return self.forward_routed_ack(packet, snr, packet_hash=packet_hash)
|
|
|
|
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
|
fwd_pkt = self.flood_forward(packet, packet_hash=packet_hash)
|
|
if fwd_pkt is None:
|
|
return None
|
|
delay = self._calculate_tx_delay(fwd_pkt, snr)
|
|
return fwd_pkt, delay
|
|
|
|
elif route_type == ROUTE_TYPE_DIRECT or route_type == ROUTE_TYPE_TRANSPORT_DIRECT:
|
|
fwd_pkt = self.direct_forward(packet, packet_hash=packet_hash)
|
|
if fwd_pkt is None:
|
|
return None
|
|
delay = self._calculate_tx_delay(fwd_pkt, snr)
|
|
return fwd_pkt, delay
|
|
|
|
else:
|
|
packet.drop_reason = f"Unknown route type: {route_type}"
|
|
return None
|
|
|
|
# A local transmit whose radio link has dropped waits this long for it to come
|
|
# back before giving up, polling at LOCAL_TX_LINK_POLL_S. Kept well inside the
|
|
# companion client's own command timeout (meshcore_py: 15s), which is already
|
|
# partly spent on the TX delay by the time we get here.
|
|
DEFAULT_LOCAL_TX_LINK_WAIT_S = 5.0
|
|
# Hard ceiling regardless of configuration: the companion client is already
|
|
# waiting on this send, and an unbounded (or infinite) value would hold its
|
|
# command open forever rather than reporting a failure it can act on.
|
|
MAX_LOCAL_TX_LINK_WAIT_S = 30.0
|
|
LOCAL_TX_LINK_POLL_S = 0.1
|
|
|
|
def _local_tx_link_wait_s(self) -> float:
|
|
"""Configured ceiling for waiting out a radio-link outage on a local TX."""
|
|
delays = self.config.get("delays") or {}
|
|
try:
|
|
wait = float(
|
|
delays.get("local_tx_link_wait_seconds", self.DEFAULT_LOCAL_TX_LINK_WAIT_S)
|
|
)
|
|
except (TypeError, ValueError):
|
|
return self.DEFAULT_LOCAL_TX_LINK_WAIT_S
|
|
if not math.isfinite(wait):
|
|
logger.warning(
|
|
"local_tx_link_wait_seconds=%r is not a finite number; using %.1fs",
|
|
wait,
|
|
self.DEFAULT_LOCAL_TX_LINK_WAIT_S,
|
|
)
|
|
return self.DEFAULT_LOCAL_TX_LINK_WAIT_S
|
|
return min(max(0.0, wait), self.MAX_LOCAL_TX_LINK_WAIT_S)
|
|
|
|
def _resolve_tx_radio(self, radio_id: Optional[str] = None):
|
|
"""The radio a send will actually use, or None when that cannot be told.
|
|
|
|
Attribute access on a FabricRadio passes through to its *default* endpoint
|
|
(rf_fabric/fabric.py), so reading link state off it while the send targets
|
|
another radio_id answers about the wrong radio -- suppressing a healthy one,
|
|
or waving through a dead one. Resolve the named endpoint instead, and return
|
|
None rather than guess: no answer leaves the send to proceed as before.
|
|
"""
|
|
radio = getattr(self.dispatcher, "radio", None)
|
|
if radio is None:
|
|
return None
|
|
fabric = getattr(radio, "fabric", None)
|
|
if fabric is None and hasattr(radio, "get_radio"):
|
|
fabric = radio # an RFFabric handed to the dispatcher directly
|
|
radios = getattr(fabric, "radios", None) if fabric is not None else None
|
|
# isinstance, not truthiness: RFFabric.radios is an OrderedDict, so this
|
|
# recognises a real multi-endpoint fabric without mistaking a stand-in
|
|
# attribute on a single-radio wrapper for one.
|
|
if not isinstance(radios, Mapping) or len(radios) <= 1:
|
|
return radio # one radio, and it is the one that transmits
|
|
if not radio_id:
|
|
# The fabric picks its endpoint at send time, so there is nothing here
|
|
# to inspect. Say so rather than answer about whichever radio happens
|
|
# to be the default -- a wrong "down" would suppress a healthy send.
|
|
return None
|
|
getter = getattr(fabric, "get_radio", None)
|
|
if getter is None:
|
|
return None
|
|
try:
|
|
return getter(str(radio_id))
|
|
except Exception:
|
|
return None
|
|
|
|
def _radio_link_down(self, radio_id: Optional[str] = None) -> bool:
|
|
"""True when the radio this send will use reports its link unusable.
|
|
|
|
Only radios that track a link state answer this -- a KISS modem over serial
|
|
does, a directly-driven SX1262 has none to lose -- so it reads False for the
|
|
rest and leaves their timing untouched.
|
|
"""
|
|
radio = self._resolve_tx_radio(radio_id)
|
|
if radio is None:
|
|
return False
|
|
# Identity checks, not truthiness: a radio that does not track link state
|
|
# answers with something that is neither True nor False (or nothing at all),
|
|
# and that has to read as "no opinion" rather than "down".
|
|
if getattr(radio, "is_connected", None) is False:
|
|
return True
|
|
return getattr(radio, "is_degraded", None) is True
|
|
|
|
async def _await_radio_link(self, timeout: float, radio_id: Optional[str] = None) -> bool:
|
|
"""Wait up to *timeout* seconds for the radio link, True if it is usable."""
|
|
if not self._radio_link_down(radio_id):
|
|
return True
|
|
if timeout <= 0:
|
|
return False
|
|
logger.info("Local TX: radio link down, waiting up to %.1fs for it", timeout)
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
await asyncio.sleep(self.LOCAL_TX_LINK_POLL_S)
|
|
if not self._radio_link_down(radio_id):
|
|
logger.info("Local TX: radio link back, transmitting now")
|
|
return True
|
|
logger.warning("Local TX: radio link still down after %.1fs, giving up", timeout)
|
|
return False
|
|
|
|
async def schedule_retransmit(
|
|
self,
|
|
fwd_pkt: Packet,
|
|
delay: float,
|
|
airtime_ms: float = 0.0,
|
|
local_transmission: bool = False,
|
|
preferred_tx_radio_id: Optional[str] = None,
|
|
):
|
|
"""Schedule a packet retransmission with delay and return the task.
|
|
|
|
If local_transmission is True and the first send fails, retry once after
|
|
a short delay (handles transient radio/LBT failures).
|
|
"""
|
|
|
|
async def delayed_send():
|
|
await asyncio.sleep(delay)
|
|
|
|
# Each attempt gets its own lock acquisition so the 1-second retry
|
|
# backoff (local_transmission only) happens OUTSIDE the lock.
|
|
# Holding _tx_lock across asyncio.sleep(1.0) would block every other
|
|
# queued TX task for the full backoff period.
|
|
#
|
|
# Loop runs once for relayed packets, twice for local_transmission:
|
|
# attempt 0 — initial try (no pre-sleep)
|
|
# attempt 1 — retry after 1s backoff outside the lock
|
|
for attempt in range(2 if local_transmission else 1):
|
|
if attempt > 0 and not self._radio_link_down(preferred_tx_radio_id):
|
|
# A transient failure with the link still up: back off OUTSIDE
|
|
# the lock — other tasks can transmit here.
|
|
logger.info("Retrying local TX in 1s (lock released during backoff)...")
|
|
await asyncio.sleep(1.0)
|
|
|
|
# A dropped link is not a transient failure to back off from. A KISS
|
|
# modem that falls off USB re-enumerates and re-handshakes in ~2s, so
|
|
# the flat backoff above would land inside the outage and spend the
|
|
# retry on a link that is still down — which is exactly what turned
|
|
# one modem hiccup into a failed message. Wait for the link instead.
|
|
if local_transmission and not await self._await_radio_link(
|
|
self._local_tx_link_wait_s(), preferred_tx_radio_id
|
|
):
|
|
return False
|
|
|
|
async with self._tx_lock:
|
|
# ── Authoritative duty-cycle gate ──────────────────────────
|
|
# The upfront can_transmit() call in __call__ is advisory: it
|
|
# avoids scheduling packets obviously over budget, but cannot
|
|
# prevent a race between tasks whose delay timers expire nearly
|
|
# simultaneously. Both pass the advisory check before either
|
|
# records airtime, then both attempt to transmit.
|
|
#
|
|
# Inside _tx_lock only one task runs at a time. The check and
|
|
# record_tx() are effectively atomic — no TOCTOU window.
|
|
# Re-checked every attempt because airtime state may change
|
|
# while we wait for the lock or sleep through backoff.
|
|
# Charged to the radio this egress goes out on, at that
|
|
# radio's modulation: the airtime the caller computed is the
|
|
# default radio's, which on a bridge is the wrong channel and
|
|
# can be out by the ratio of their bandwidths.
|
|
#
|
|
# A named radio is gated on its own budget. Without one the
|
|
# fabric picks inside send(), so every radio it could pick
|
|
# has to have the budget -- a gate that passes because some
|
|
# other channel is quiet is a gate that lets this one
|
|
# transmit past its legal limit.
|
|
gated: Dict[Optional[str], float] = {}
|
|
if airtime_ms > 0:
|
|
gate_ids = (
|
|
(preferred_tx_radio_id,)
|
|
if preferred_tx_radio_id
|
|
else self._egress_gate_radio_ids(fwd_pkt)
|
|
)
|
|
for gate_id in gate_ids:
|
|
manager = self.airtime_budgets.for_radio(gate_id)
|
|
try:
|
|
gate_ms = self._egress_radio_airtime(fwd_pkt, gate_id)
|
|
except Exception as exc:
|
|
logger.debug(f"Falling back to the caller's airtime: {exc}")
|
|
gate_ms = airtime_ms
|
|
gated[gate_id] = gate_ms
|
|
can_tx_now, _ = manager.can_transmit(gate_ms)
|
|
if not can_tx_now:
|
|
logger.warning(
|
|
"Packet dropped at TX time: duty-cycle exceeded on radio %s "
|
|
"(airtime=%.1fms)",
|
|
gate_id or "default",
|
|
gate_ms,
|
|
)
|
|
return False
|
|
|
|
# Registered as the send starts: another radio of this node can
|
|
# hear it while it is still on the air.
|
|
own_send = self._note_own_send_started(fwd_pkt, preferred_tx_radio_id)
|
|
try:
|
|
if preferred_tx_radio_id:
|
|
try:
|
|
sent = await self.dispatcher.send_packet(
|
|
fwd_pkt,
|
|
wait_for_ack=False,
|
|
radio_id=preferred_tx_radio_id,
|
|
)
|
|
except TypeError:
|
|
sent = await self.dispatcher.send_packet(
|
|
fwd_pkt, wait_for_ack=False
|
|
)
|
|
else:
|
|
sent = await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
|
self._note_own_send_finished(own_send, fwd_pkt, sent=bool(sent))
|
|
if not sent:
|
|
logger.warning(
|
|
"Retransmit failed (attempt %d): dispatcher returned false",
|
|
attempt + 1,
|
|
)
|
|
if local_transmission and attempt == 0:
|
|
continue
|
|
return False
|
|
self._record_packet_sent(fwd_pkt)
|
|
charge_ms = airtime_ms
|
|
if airtime_ms > 0:
|
|
# Debited under the same lock the gate ran under, so
|
|
# there is no window between deciding and recording.
|
|
# Charged to the radio the send says carried it,
|
|
# which is the only way to meter a fabric that chose
|
|
# for itself -- and still the right answer when the
|
|
# radio was named, since it is the radio confirming.
|
|
try:
|
|
charge_ms = self._charge_egress(
|
|
fwd_pkt, preferred_tx_radio_id, gated
|
|
)
|
|
except Exception as exc:
|
|
logger.debug(f"Falling back to the caller's airtime: {exc}")
|
|
self.airtime_budgets.for_radio(preferred_tx_radio_id).record_tx(
|
|
airtime_ms
|
|
)
|
|
packet_size = fwd_pkt.get_raw_length()
|
|
logger.info(
|
|
f"Retransmitted packet ({packet_size} bytes, {charge_ms:.1f}ms airtime)"
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
self._note_own_send_finished(own_send, fwd_pkt, sent=False)
|
|
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
|
|
if local_transmission and attempt == 0:
|
|
pass # release lock, outer loop sleeps, then retries
|
|
else:
|
|
raise
|
|
return False
|
|
|
|
return asyncio.create_task(delayed_send())
|
|
|
|
async def schedule_retransmit_fanout(
|
|
self,
|
|
fwd_pkt: Packet,
|
|
delay: float,
|
|
airtime_ms: float,
|
|
radio_ids,
|
|
*,
|
|
local_transmission: bool = False,
|
|
):
|
|
"""Schedule one logical packet as one physical TX per radio; return the task.
|
|
|
|
The task resolves to a ``FanoutTxResult``. Each egress runs through
|
|
``schedule_retransmit`` — duty-cycle gate, TX lock, local retry and
|
|
per-radio link wait — so the sends stay serialised and one radio's
|
|
failure never stops another's.
|
|
|
|
The first egress transmits ``fwd_pkt`` itself, so TX-time side effects
|
|
(flood scope, ``_tx_metadata``) land where callers already look. Every
|
|
other egress gets its own copy, so one send's metadata never overwrites
|
|
another's.
|
|
"""
|
|
radio_ids = tuple(radio_ids)
|
|
self._freeze_tx_normalisation(fwd_pkt)
|
|
packets = [fwd_pkt] + [copy.deepcopy(fwd_pkt) for _ in radio_ids[1:]]
|
|
|
|
async def run() -> FanoutTxResult:
|
|
await asyncio.sleep(delay)
|
|
# One shared delay, then the per-egress sends are created in order with
|
|
# no further sleep. When every radio is ready they reach the TX lock in
|
|
# egress order (asyncio runs ready tasks and grants the lock FIFO), so
|
|
# the primary egress gets the first claim on the duty-cycle budget. A
|
|
# local send whose radio link is down waits outside the lock, so a
|
|
# healthy radio later in the order is not held up by it and may go first.
|
|
tasks = [
|
|
await self.schedule_retransmit(
|
|
pkt,
|
|
0.0,
|
|
airtime_ms,
|
|
local_transmission=local_transmission,
|
|
preferred_tx_radio_id=radio_id,
|
|
)
|
|
for pkt, radio_id in zip(packets, radio_ids)
|
|
]
|
|
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
|
|
results = []
|
|
for radio_id, pkt, outcome in zip(radio_ids, packets, outcomes):
|
|
if isinstance(outcome, BaseException):
|
|
logger.warning("Fan-out TX via %s failed: %s", radio_id, outcome)
|
|
results.append(RadioTxResult(radio_id, False, error=outcome))
|
|
continue
|
|
# Read whether or not the send succeeded: a failed egress carries
|
|
# the LBT figures that explain why, and the single-egress path in
|
|
# _await_egress_tx has always read them unconditionally.
|
|
metadata = getattr(pkt, "_tx_metadata", None)
|
|
results.append(
|
|
RadioTxResult(
|
|
radio_id,
|
|
bool(outcome),
|
|
metadata=metadata if isinstance(metadata, dict) else None,
|
|
)
|
|
)
|
|
return FanoutTxResult(results)
|
|
|
|
return asyncio.create_task(run())
|
|
|
|
def _freeze_tx_normalisation(self, pkt: Packet) -> None:
|
|
"""Apply the dispatcher's send-time normalisation once and mark it decided.
|
|
|
|
The dispatcher resolves flood scope and the default path-hash mode at
|
|
send time. Fan-out sends the same logical packet several times, possibly
|
|
seconds apart (link wait, retry backoff), so a live change to the node's
|
|
default region or hash mode in between would put different bytes on each
|
|
radio. Deciding both on the logical packet before it is copied keeps
|
|
every egress, and the packet the router echoes, identical.
|
|
"""
|
|
for hook in ("_apply_flood_scope", "_apply_default_path_hash_mode"):
|
|
apply = getattr(self.dispatcher, hook, None)
|
|
if callable(apply):
|
|
apply(pkt)
|
|
pkt._flood_scope_applied = True
|
|
pkt._path_hash_mode_applied = True
|
|
|
|
async def _schedule_egress_tx(
|
|
self,
|
|
fwd_pkt: Packet,
|
|
delay: float,
|
|
airtime_ms: float,
|
|
tx_radio_ids: Optional[Tuple[str, ...]],
|
|
*,
|
|
local_transmission: bool = False,
|
|
):
|
|
"""Schedule a TX over the resolved egress set.
|
|
|
|
One egress (or none, leaving the choice to the fabric) keeps the plain
|
|
``schedule_retransmit`` path, whose task resolves to a bool and raises on
|
|
a relay send error; more than one fans out.
|
|
"""
|
|
if tx_radio_ids and len(tx_radio_ids) > 1:
|
|
return await self.schedule_retransmit_fanout(
|
|
fwd_pkt,
|
|
delay,
|
|
airtime_ms,
|
|
tx_radio_ids,
|
|
local_transmission=local_transmission,
|
|
)
|
|
return await self.schedule_retransmit(
|
|
fwd_pkt,
|
|
delay,
|
|
airtime_ms,
|
|
local_transmission=local_transmission,
|
|
preferred_tx_radio_id=tx_radio_ids[0] if tx_radio_ids else None,
|
|
)
|
|
|
|
def _egress_records(self, tx_result: FanoutTxResult) -> Optional[list]:
|
|
"""Per-radio egress rows for storage, or None on a node with one radio.
|
|
|
|
With one radio the packet row already says everything these would. A
|
|
bridge is different: one packet goes out on radios whose LBT outcomes
|
|
differ, only the primary egress reaches the packet row, and a send that
|
|
failed leaves no trace there at all.
|
|
|
|
A failed egress carries whatever LBT metadata the driver reported. Where
|
|
the radio raises instead of returning any (an SX1262 that gives up on a
|
|
busy channel), the row records the failure with no attempts rather than
|
|
inventing them: the driver has to report them before anything here can.
|
|
"""
|
|
_, radio_ids = self._fabric_endpoints()
|
|
if not radio_ids or len(radio_ids) < 2:
|
|
return None
|
|
|
|
records = []
|
|
for result in tx_result.results:
|
|
metadata = result.metadata if isinstance(result.metadata, dict) else {}
|
|
backoffs = metadata.get("lbt_backoff_delays_ms") or []
|
|
records.append(
|
|
{
|
|
"radio_id": result.radio_id,
|
|
"success": bool(result.success),
|
|
"lbt_attempts": int(metadata.get("lbt_attempts", 0) or 0),
|
|
"lbt_backoff_ms_total": float(sum(backoffs)),
|
|
"lbt_channel_busy": bool(metadata.get("lbt_channel_busy", False)),
|
|
}
|
|
)
|
|
return records or None
|
|
|
|
@staticmethod
|
|
async def _await_egress_tx(tx_task, fwd_pkt: Packet) -> FanoutTxResult:
|
|
"""Await a task from ``_schedule_egress_tx`` as a ``FanoutTxResult``.
|
|
|
|
A single-egress send error propagates, exactly as it did before fan-out.
|
|
"""
|
|
outcome = await tx_task
|
|
if isinstance(outcome, FanoutTxResult):
|
|
return outcome
|
|
metadata = getattr(fwd_pkt, "_tx_metadata", None)
|
|
metadata = metadata if isinstance(metadata, dict) else None
|
|
radio_id = (metadata.get("radio_id") or metadata.get("tx_radio_id")) if metadata else None
|
|
return FanoutTxResult([RadioTxResult(radio_id, bool(outcome), metadata=metadata)])
|
|
|
|
@staticmethod
|
|
def _rx_radio_id_of(packet: Packet, metadata: Optional[dict]) -> Optional[str]:
|
|
"""The radio that heard this packet, however the caller carried the id."""
|
|
if metadata:
|
|
radio_id = metadata.get("rx_radio_id")
|
|
if radio_id:
|
|
return radio_id
|
|
return getattr(packet, "_rx_radio_id", None) or getattr(packet, "rx_radio_id", None)
|
|
|
|
def _egress_radio_airtime(self, packet: Packet, radio_id: Optional[str]) -> float:
|
|
"""Time this packet will occupy the channel of the radio sending it.
|
|
|
|
On a bridge the same bytes are a different length of transmission on each
|
|
radio -- eight times over between 500 and 62.5 kHz -- so the figure the
|
|
duty cycle is charged has to be the one for the radio that transmits.
|
|
"""
|
|
return self.airtime_budgets.for_radio(radio_id).calculate_airtime(packet.get_raw_length())
|
|
|
|
def airtime_stats(self) -> dict:
|
|
"""Node-wide airtime figures, in the shape AirtimeManager.get_stats returns.
|
|
|
|
The single place for callers that report one set of numbers for the
|
|
node. Reading ``airtime_mgr`` instead describes the default radio's
|
|
channel only, which on a bridge is a quiet halving of figures people
|
|
have been watching for months.
|
|
"""
|
|
return self.airtime_budgets.node_stats()
|
|
|
|
def airtime_stats_by_radio(self) -> list:
|
|
"""``[{radio_id, ...}]`` per radio, empty on a single-radio node.
|
|
|
|
Radios sharing a channel report the same figures because they are the
|
|
same budget, which is the statement rather than a duplication.
|
|
"""
|
|
return self.airtime_budgets.per_radio_stats()
|
|
|
|
def _egress_can_transmit(
|
|
self, packet: Packet, tx_radio_ids: Optional[Tuple[str, ...]]
|
|
) -> Tuple[bool, float]:
|
|
"""Advisory gate before scheduling: is there budget for this forward?
|
|
|
|
With a named egress set, each radio is sent to separately and re-gated
|
|
under the TX lock, so one radio being over budget is no reason to drop a
|
|
forward another can carry -- one radio with budget is enough, and the
|
|
wait reported is the shortest of them.
|
|
|
|
With no named set the fabric picks inside send(), so the fabric is asked
|
|
which radio that will be and only that one has to have the budget. Where
|
|
it will not say, every radio it could pick has to, and the wait is the
|
|
longest of them -- asking whether *any* candidate has budget is how a
|
|
node transmits past a legal limit, since it only has to guess the quiet
|
|
channel, and requiring all of them drops traffic the node could legally
|
|
have carried on another band.
|
|
"""
|
|
if tx_radio_ids:
|
|
waits = []
|
|
for radio_id in tx_radio_ids:
|
|
manager = self.airtime_budgets.for_radio(radio_id)
|
|
can_tx, wait_time = manager.can_transmit(
|
|
self._egress_radio_airtime(packet, radio_id)
|
|
)
|
|
if can_tx:
|
|
return True, 0.0
|
|
waits.append(wait_time)
|
|
return False, min(waits) if waits else 1.0
|
|
|
|
waits = []
|
|
for radio_id in self._egress_gate_radio_ids(packet):
|
|
manager = self.airtime_budgets.for_radio(radio_id)
|
|
can_tx, wait_time = manager.can_transmit(self._egress_radio_airtime(packet, radio_id))
|
|
if not can_tx:
|
|
waits.append(wait_time)
|
|
if waits:
|
|
return False, max(waits)
|
|
return True, 0.0
|
|
|
|
def _charge_egress(
|
|
self,
|
|
packet: Packet,
|
|
preferred_tx_radio_id: Optional[str],
|
|
gated: Optional[Dict[Optional[str], float]] = None,
|
|
) -> float:
|
|
"""Debit the radio that actually carried the packet, and say how much.
|
|
|
|
The metadata a send returns names the endpoint that transmitted, and
|
|
that is the authoritative answer: where the fabric chose inside send()
|
|
it is the only way to find out, and where the radio was named explicitly
|
|
it is the radio confirming it. Falls back to the radio that was asked
|
|
for, and from there to the default budget, so a send always lands
|
|
somewhere rather than going unmetered.
|
|
|
|
The figure is the one the gate computed for that radio, not a fresh
|
|
calculation. A config reload runs on another thread and can retune a
|
|
radio while the packet is still on the air; recomputing afterwards then
|
|
charges the modulation the radio has now for a transmission that went
|
|
out on the modulation it had a moment ago -- on a 500 kHz-for-62.5 kHz
|
|
retune that is an eightfold undercharge, repeatable by holding the
|
|
reload against a busy node. Only a radio the gate never saw is computed
|
|
here, which is the fabric having chosen one nobody named.
|
|
"""
|
|
metadata = getattr(packet, "_tx_metadata", None)
|
|
radio_id = None
|
|
if isinstance(metadata, dict):
|
|
radio_id = metadata.get("radio_id") or metadata.get("tx_radio_id")
|
|
radio_id = str(radio_id) if radio_id else preferred_tx_radio_id
|
|
if radio_id is None and gated and len(gated) == 1:
|
|
# Nobody named a radio and the send reported none, but the gate had
|
|
# a single answer from the fabric -- charge that rather than the
|
|
# default, which on a bridge is the other band entirely.
|
|
radio_id = next(iter(gated))
|
|
manager = self.airtime_budgets.for_radio(radio_id)
|
|
if gated and radio_id in gated:
|
|
charge_ms = gated[radio_id]
|
|
else:
|
|
charge_ms = self._egress_radio_airtime(packet, radio_id)
|
|
manager.record_tx(charge_ms)
|
|
return charge_ms
|
|
|
|
def _planned_egress_radio_id(self, packet: Packet, rx_radio_id: Optional[str]) -> Optional[str]:
|
|
"""Ask the fabric which radio will carry this packet, before it is sent.
|
|
|
|
Answerable only on a core that passes the ingress radio to the TX
|
|
selector. There the choice is a function of the arguments, so the answer
|
|
now is the answer send() reaches, and naming it changes nothing about
|
|
which radio transmits -- it only lets the duty cycle be charged to the
|
|
channel that carries the packet, and closes the window where a busy
|
|
node's next arrival moves a reply onto the other band between the
|
|
retransmit delay and the send.
|
|
|
|
Returns None on a node with fewer than two radios, where there is
|
|
nothing to choose, and against an older core, where the fabric picks
|
|
inside send() and no caller can know in advance what it will pick.
|
|
|
|
The bytes handed to the selector are this packet as it stands, a moment
|
|
before the dispatcher applies flood scope and path-hash normalisation,
|
|
so a selector that routed on payload content could in principle see a
|
|
slightly different frame than the one sent. The tx_mode selectors route
|
|
on the ingress radio and ignore the bytes entirely.
|
|
"""
|
|
fabric, ids = self._fabric_endpoints()
|
|
if fabric is None or not ids or len(ids) < 2:
|
|
return None
|
|
if not fabric_selects_by_ingress_radio(fabric):
|
|
# The answer would be about the node's most recent RX rather than
|
|
# about this packet, and the retransmit delay is long enough for
|
|
# that to change. Asking here would pin the wrong radio.
|
|
return None
|
|
return self._ask_fabric_tx_radio_id(packet, rx_radio_id)
|
|
|
|
def _ask_fabric_tx_radio_id(self, packet: Packet, rx_radio_id: Optional[str]) -> Optional[str]:
|
|
"""Put the question to the fabric: which radio carries this packet?
|
|
|
|
Passes the ingress radio when the core can take it, and asks the plain
|
|
question when it cannot -- an older core answers from its most recent
|
|
RX, which is the same thing its selector will read inside send().
|
|
"""
|
|
fabric, ids = self._fabric_endpoints()
|
|
if fabric is None or not ids or len(ids) < 2:
|
|
return None
|
|
try:
|
|
raw = packet.write_to()
|
|
except Exception as exc:
|
|
logger.debug(f"Could not serialise a packet to resolve its egress radio: {exc}")
|
|
return None
|
|
try:
|
|
if fabric_selects_by_ingress_radio(fabric):
|
|
radio_id = fabric.resolve_tx_radio_id(raw, None, rx_radio_id=rx_radio_id)
|
|
else:
|
|
radio_id = fabric.resolve_tx_radio_id(raw, None)
|
|
except Exception as exc:
|
|
logger.debug(f"Fabric could not name an egress radio: {exc}")
|
|
return None
|
|
return str(radio_id) if radio_id else None
|
|
|
|
def _egress_gate_radio_ids(self, packet: Packet) -> Tuple[Optional[str], ...]:
|
|
"""Which radios the TX-time gate must satisfy when nobody named one.
|
|
|
|
Asked of the fabric first, under the TX lock an instant before the send,
|
|
so an older core's selector is read at almost the moment it will be read
|
|
again inside send(). Holding *every* radio the fabric could pick to its
|
|
own budget is safe against overrun but is not the behaviour these nodes
|
|
had: an exhausted local radio would refuse a transmission the fabric was
|
|
about to put on an idle link radio, black-holing that band for the rest
|
|
of the window, and worse with three radios.
|
|
|
|
The candidate set remains the answer when the fabric will not say -- a
|
|
node with one radio, or a resolver that raised. What the gate cannot
|
|
close is the sliver between asking and sending, where a reception could
|
|
move an older core's choice; the debit still lands on the radio the send
|
|
reports, so that costs at most one packet charged to the wrong channel,
|
|
which is the exposure these nodes already had.
|
|
"""
|
|
resolved = self._ask_fabric_tx_radio_id(packet, getattr(packet, "_rx_radio_id", None))
|
|
if resolved is not None:
|
|
return (resolved,)
|
|
return self._egress_candidates()
|
|
|
|
def _egress_candidates(self) -> Tuple[Optional[str], ...]:
|
|
"""Every radio the fabric could pick when nobody named one.
|
|
|
|
The honest answer on a core whose selector sees only the frame bytes:
|
|
it reads the node's most recent RX inside send(), so sticky and bridge
|
|
can both come out on any radio. tx_mode default always picks
|
|
default_radio, so that one is still exact.
|
|
|
|
``(None,)`` on a single-radio node, which resolves to the one budget it
|
|
has always had.
|
|
"""
|
|
fabric, ids = self._fabric_endpoints()
|
|
if fabric is None or not ids or len(ids) < 2:
|
|
return (None,)
|
|
mode = str(self._fabric_cfg().get("tx_mode", "default")).strip().lower()
|
|
if mode in ("", "default"):
|
|
default_id = getattr(fabric, "default_radio_id", None)
|
|
if isinstance(default_id, str) and default_id in ids:
|
|
return (default_id,)
|
|
return (ids[0],)
|
|
return tuple(ids)
|
|
|
|
def _fabric_cfg(self) -> dict:
|
|
fabric_cfg = self.config.get("fabric")
|
|
return fabric_cfg if isinstance(fabric_cfg, dict) else {}
|
|
|
|
def _fabric_endpoints(self):
|
|
"""Return ``(fabric, radio_ids)`` for a multi-radio fabric, else ``(None, None)``."""
|
|
radio = getattr(self.dispatcher, "radio", None)
|
|
fabric = getattr(radio, "fabric", None)
|
|
radios = getattr(fabric, "radios", None)
|
|
# isinstance, not truthiness: RFFabric.radios is an OrderedDict, and a
|
|
# stand-in attribute on a non-fabric radio must not read as endpoints.
|
|
if not isinstance(radios, Mapping):
|
|
return None, None
|
|
return fabric, [str(rid) for rid in radios.keys()]
|
|
|
|
def _resolve_relay_tx_radio_ids(self, rx_radio_id: Optional[str]) -> Optional[Tuple[str, ...]]:
|
|
"""Egress radios for a packet heard on ``rx_radio_id`` and being repeated.
|
|
|
|
Applies only when fabric.tx_mode=bridge and exactly two radios are
|
|
registered: the bridge target (the other radio) comes first, followed by
|
|
the ingress radio itself when fabric.repeat_on_ingress is true. For all
|
|
other layouts return None and let the dispatcher/fabric choose via
|
|
existing logic.
|
|
"""
|
|
fabric_cfg = self._fabric_cfg()
|
|
if str(fabric_cfg.get("tx_mode", "default")).strip().lower() != "bridge":
|
|
return None
|
|
if not rx_radio_id:
|
|
return None
|
|
|
|
_, ids = self._fabric_endpoints()
|
|
if ids is None or len(ids) != 2:
|
|
return None
|
|
|
|
rx_id = str(rx_radio_id)
|
|
if rx_id not in ids:
|
|
return None
|
|
bridge_id = ids[1] if ids[0] == rx_id else ids[0]
|
|
if fabric_cfg.get("repeat_on_ingress") is True:
|
|
return (bridge_id, rx_id)
|
|
return (bridge_id,)
|
|
|
|
def _resolve_bridge_pair_tx_radio(self, rx_radio_id: Optional[str]) -> Optional[str]:
|
|
"""Return deterministic pair TX radio for bridge mode (A->B, B->A)."""
|
|
radio_ids = self._resolve_relay_tx_radio_ids(rx_radio_id)
|
|
return radio_ids[0] if radio_ids else None
|
|
|
|
def _resolve_origin_tx_radio_ids(self) -> Optional[Tuple[str, ...]]:
|
|
"""Egress radios for a packet this node originates.
|
|
|
|
With fabric.origin_tx=all and exactly two radios, every radio, the
|
|
default radio first. Otherwise None: the existing fabric selection
|
|
(default radio / tx_mode selector) stays authoritative.
|
|
"""
|
|
if fabric_origin_tx(self._fabric_cfg()) != "all":
|
|
return None
|
|
fabric, ids = self._fabric_endpoints()
|
|
if ids is None or len(ids) != 2:
|
|
return None
|
|
default_id = getattr(fabric, "default_radio_id", None)
|
|
if isinstance(default_id, str) and default_id in ids:
|
|
ids.remove(default_id)
|
|
ids.insert(0, default_id)
|
|
return tuple(ids)
|
|
|
|
def _record_packet_sent(self, packet: Packet) -> None:
|
|
"""Record a packet send for flood/direct stats (forwarded and originated)."""
|
|
route = getattr(packet, "header", 0) & PH_ROUTE_MASK
|
|
if route in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
|
|
self.sent_flood_count += 1
|
|
elif route in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
|
self.sent_direct_count += 1
|
|
|
|
@staticmethod
|
|
def _read_noise_floor(radio) -> Optional[float]:
|
|
"""Read one radio's noise floor, or None when it cannot answer."""
|
|
try:
|
|
if radio is not None and hasattr(radio, "get_noise_floor"):
|
|
return radio.get_noise_floor()
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Failed to get noise floor: {e}")
|
|
return None
|
|
|
|
def get_noise_floor(self) -> Optional[float]:
|
|
"""Noise floor of the default radio, which is what /stats reports."""
|
|
return self._read_noise_floor(self.dispatcher.radio if self.dispatcher else None)
|
|
|
|
def _sampling_radios(self) -> list:
|
|
"""``(radio_id, radio)`` for every radio to sample, the default radio first.
|
|
|
|
A single-radio node yields one entry whose id is None: one radio needs no
|
|
label, so its stored rows and published records stay exactly as they
|
|
were. The default radio comes first because callers publish only the
|
|
first entry's sample — /stats, the MQTT status message and companion
|
|
stats all read the default radio's figures.
|
|
"""
|
|
fabric, ids = self._fabric_endpoints()
|
|
default_radio = self.dispatcher.radio if self.dispatcher else None
|
|
if fabric is None or not ids or len(ids) < 2:
|
|
return [(None, default_radio)]
|
|
|
|
default_id = getattr(fabric, "default_radio_id", None)
|
|
if isinstance(default_id, str) and default_id in ids:
|
|
ids.remove(default_id)
|
|
ids.insert(0, default_id)
|
|
|
|
getter = getattr(fabric, "get_radio", None)
|
|
radios = []
|
|
for radio_id in ids:
|
|
radio = None
|
|
if getter is not None:
|
|
try:
|
|
radio = getter(radio_id)
|
|
except Exception as e:
|
|
logger.debug(f"No endpoint for radio {radio_id}: {e}")
|
|
radios.append((radio_id, radio))
|
|
return radios
|
|
|
|
def get_cached_noise_floor(self) -> Optional[float]:
|
|
"""Return the last asynchronously-sampled noise floor value."""
|
|
return self._cached_noise_floor
|
|
|
|
def get_cached_noise_floor_by_radio(self) -> Dict[str, float]:
|
|
"""Last sample per radio id. Empty on a single-radio node."""
|
|
return dict(self._cached_noise_floor_by_radio)
|
|
|
|
def get_crc_error_count(self) -> int:
|
|
"""The node's cumulative CRC error count, summed over its radios.
|
|
|
|
The node's, not the default radio's: on a bridge the default radio is
|
|
one of two receivers, and reporting only its errors leaves a deaf
|
|
backhaul invisible in every scalar the node publishes. Summing matches
|
|
what AirtimeBudgets.node_stats does with the airtime counters beside it.
|
|
|
|
Every reader of this figure sees the same number -- the MQTT status
|
|
message's ``stats.errors``, /stats' ``crc_error_count`` and the MeshCore
|
|
wire response's ``n_recv_errors`` -- so the sum of ``radios[].errors``
|
|
in a status message reconciles with the scalar. Which radio contributed
|
|
what stays available per radio, both there and in the crc_errors table.
|
|
"""
|
|
# Live counts win; a radio the fabric cannot hand back right now falls
|
|
# back to the last count the sampler read from it. These are lifetime
|
|
# counters, so dropping a radio from the sum would walk the node's total
|
|
# backwards and hand an observer reading deltas a negative interval --
|
|
# the same reason AirtimeBudgets keeps its retired channels' totals.
|
|
counts = {
|
|
radio_id: count
|
|
for radio_id, count in self._crc_error_baselines.items()
|
|
if radio_id is not None
|
|
}
|
|
counts.update(self.get_crc_error_count_by_radio())
|
|
if counts:
|
|
return sum(counts.values())
|
|
|
|
# Single-radio node: one unlabelled counter, read live.
|
|
radio = self.dispatcher.radio if self.dispatcher else None
|
|
return int(getattr(radio, "crc_error_count", 0) or 0) if radio else 0
|
|
|
|
def get_crc_error_count_by_radio(self) -> Dict[str, int]:
|
|
"""Each radio's cumulative CRC error count. Empty on a single-radio node.
|
|
|
|
Read from the hardware counters rather than the sampler's baselines, so
|
|
a status message is not pinned to whenever the CRC tick last ran. A
|
|
radio the fabric cannot hand back is omitted rather than reported as
|
|
zero: absent and error-free are not the same claim.
|
|
"""
|
|
counts = {}
|
|
for radio_id, radio in self._sampling_radios():
|
|
if radio_id is None or radio is None:
|
|
continue
|
|
counts[radio_id] = int(getattr(radio, "crc_error_count", 0) or 0)
|
|
return counts
|
|
|
|
def get_stats(self) -> dict:
|
|
runtime_config = normalize_modem_config(self.config, warn=False)
|
|
redact_modem_tokens_in_place(runtime_config)
|
|
|
|
uptime_seconds = time.time() - self.start_time
|
|
|
|
# Get config sections
|
|
repeater_config = self.config.get("repeater", {})
|
|
duty_cycle_config = self.config.get("duty_cycle", {})
|
|
delays_config = self.config.get("delays", {})
|
|
|
|
max_airtime_ms = duty_cycle_config.get("max_airtime_per_minute", 3600)
|
|
max_duty_cycle_percent = (max_airtime_ms / 60000) * 100 # 60000ms = 1 minute
|
|
|
|
# Calculate actual hourly rates (packets in last 3600 seconds)
|
|
now = time.time()
|
|
packets_last_hour = [p for p in self.recent_packets if now - p["timestamp"] < 3600]
|
|
rx_per_hour = len(packets_last_hour)
|
|
forwarded_per_hour = sum(1 for p in packets_last_hour if p.get("transmitted", False))
|
|
|
|
# Use cached value sampled by the background timer to avoid serial I/O on stats requests.
|
|
noise_floor_dbm = self.get_cached_noise_floor()
|
|
|
|
# Get CRC error count from radio hardware
|
|
crc_error_count = self.get_crc_error_count()
|
|
|
|
# Get neighbors from database
|
|
neighbors = self.storage.get_neighbors() if self.storage else {}
|
|
|
|
# Format local_hash respecting path_hash_mode
|
|
phm = self.config.get("mesh", {}).get("path_hash_mode", 0)
|
|
_bc = {0: 1, 1: 2, 2: 3}.get(phm, 1)
|
|
_hc = _bc * 2
|
|
_val = int.from_bytes(bytes(self.local_hash_bytes[:_bc]), "big")
|
|
local_hash_str = f"0x{_val:0{_hc}x}"
|
|
|
|
stats = {
|
|
"local_hash": local_hash_str,
|
|
"duplicate_cache_size": len(self.seen_packets),
|
|
"cache_ttl": self.cache_ttl,
|
|
"rx_count": self.rx_count,
|
|
"forwarded_count": self.forwarded_count,
|
|
"dropped_count": self.dropped_count,
|
|
"recv_flood_count": self.recv_flood_count,
|
|
"recv_direct_count": self.recv_direct_count,
|
|
"sent_flood_count": self.sent_flood_count,
|
|
"sent_direct_count": self.sent_direct_count,
|
|
"flood_dup_count": self.flood_dup_count,
|
|
"direct_dup_count": self.direct_dup_count,
|
|
"rx_per_hour": rx_per_hour,
|
|
"forwarded_per_hour": forwarded_per_hour,
|
|
"recent_packets": list(self.recent_packets),
|
|
"neighbors": neighbors,
|
|
"uptime_seconds": uptime_seconds,
|
|
"noise_floor_dbm": noise_floor_dbm,
|
|
"crc_error_count": crc_error_count,
|
|
# Add configuration data
|
|
"config": {
|
|
"node_name": repeater_config.get("node_name", "Unknown"),
|
|
"repeater": {
|
|
"mode": repeater_config.get("mode", "forward"),
|
|
"use_score_for_tx": repeater_config.get("use_score_for_tx", False),
|
|
"score_threshold": repeater_config.get("score_threshold", 0.3),
|
|
"send_advert_interval_hours": repeater_config.get(
|
|
"send_advert_interval_hours", 10
|
|
),
|
|
"direct_advert_interval_hours": repeater_config.get(
|
|
"direct_advert_interval_hours", 0
|
|
),
|
|
"latitude": repeater_config.get("latitude", 0.0),
|
|
"longitude": repeater_config.get("longitude", 0.0),
|
|
"max_flood_hops": repeater_config.get("max_flood_hops", 64),
|
|
"advert_interval_minutes": repeater_config.get("advert_interval_minutes", 120),
|
|
"advert_rate_limit": repeater_config.get("advert_rate_limit", {}),
|
|
"advert_penalty_box": repeater_config.get("advert_penalty_box", {}),
|
|
"advert_adaptive": repeater_config.get("advert_adaptive", {}),
|
|
},
|
|
"radio": self.config.get(
|
|
"radio", {}
|
|
), # Read from live config, not cached radio_config
|
|
"radios": runtime_config.get("radios") or [],
|
|
"fabric": self.config.get("fabric") or {},
|
|
"radio_type": runtime_config.get("radio_type"),
|
|
"sx1262": self.config.get("sx1262", {}),
|
|
"ch341": self.config.get("ch341", {}),
|
|
"kiss": self.config.get("kiss", {}),
|
|
"modem_usb": runtime_config.get("modem_usb", {}),
|
|
"modem_tcp": runtime_config.get("modem_tcp", {}),
|
|
"duty_cycle": {
|
|
"max_airtime_percent": max_duty_cycle_percent,
|
|
"enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True),
|
|
},
|
|
"delays": {
|
|
"tx_delay_factor": delays_config.get("tx_delay_factor", 1.0),
|
|
"direct_tx_delay_factor": delays_config.get("direct_tx_delay_factor", 0.5),
|
|
"rx_delay_base": delays_config.get("rx_delay_base", 0.0),
|
|
},
|
|
"web": self.config.get("web", {}), # Include web configuration
|
|
"mesh": {
|
|
"loop_detect": self.config.get("mesh", {}).get("loop_detect", "off"),
|
|
"unscoped_flood_allow": self.config.get("mesh", {}).get(
|
|
"unscoped_flood_allow",
|
|
self.config.get("mesh", {}).get("global_flood_allow", True),
|
|
),
|
|
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
|
|
},
|
|
"mqtt_brokers": self.config.get("mqtt_brokers", {}),
|
|
},
|
|
"public_key": None,
|
|
}
|
|
# Add airtime stats
|
|
# The node-wide figures describe the whole node: totals summed across
|
|
# channels, utilisation the highest of them. Each channel's own budget
|
|
# is reported beside them, because on a bridge they are different
|
|
# limits, differently spent. On a single-radio node every figure is the
|
|
# one manager's, unchanged.
|
|
stats.update(self.airtime_budgets.node_stats())
|
|
radios = self.airtime_budgets.per_radio_stats()
|
|
if radios:
|
|
stats["airtime_radios"] = radios
|
|
# Only a fabric has these, and only beside the unchanged scalar above:
|
|
# a single-radio node's response keeps exactly the shape it had.
|
|
noise_by_radio = self.get_cached_noise_floor_by_radio()
|
|
if noise_by_radio:
|
|
stats["noise_floor_radios"] = [
|
|
{"radio_id": radio_id, "noise_floor_dbm": dbm}
|
|
for radio_id, dbm in noise_by_radio.items()
|
|
]
|
|
return stats
|
|
|
|
def _start_background_tasks(self):
|
|
if self._background_task is None:
|
|
self._background_task = asyncio.create_task(self._background_timer_loop())
|
|
logger.info("Background timer started for noise floor and adverts")
|
|
|
|
async def _background_timer_loop(self):
|
|
try:
|
|
while True:
|
|
current_time = time.time()
|
|
|
|
# Check noise floor recording (every 30 seconds)
|
|
if current_time - self.last_noise_measurement >= self.noise_floor_interval:
|
|
await self._record_noise_floor_async()
|
|
await self._record_crc_errors_async()
|
|
self.last_noise_measurement = current_time
|
|
|
|
# Check advert scheduling.
|
|
# Prefer daemon-managed scheduling so repeater + room-server
|
|
# intervals can share one loop and stagger transmissions.
|
|
if self.periodic_advert_tick_func:
|
|
await self.periodic_advert_tick_func()
|
|
elif self.send_advert_interval_hours > 0 and self.send_advert_func:
|
|
interval_seconds = self.send_advert_interval_hours * 3600
|
|
if current_time - self.last_advert_time >= interval_seconds:
|
|
await self._send_periodic_advert_async()
|
|
self.last_advert_time = current_time
|
|
|
|
# Prune expired entries from duplicate detection cache (every 60s)
|
|
if current_time - self.last_cache_cleanup >= 60.0:
|
|
self.cleanup_cache()
|
|
self.last_cache_cleanup = current_time
|
|
|
|
# Prune old SQLite data (check every 6 hours)
|
|
if current_time - self.last_db_cleanup >= 21600:
|
|
if self.storage:
|
|
try:
|
|
retention_cfg = self.config.get("storage", {}).get("retention", {})
|
|
retention_days = retention_cfg.get("sqlite_cleanup_days", 31)
|
|
companion_events_days = retention_cfg.get("companion_events_days", 31)
|
|
self.storage.cleanup_old_data(
|
|
days=retention_days,
|
|
companion_events_days=companion_events_days,
|
|
)
|
|
logger.info("Cleaned up SQLite data older than %d days", retention_days)
|
|
except Exception as e:
|
|
logger.warning(f"SQLite cleanup failed: {e}")
|
|
self.last_db_cleanup = current_time
|
|
|
|
# Sleep for 5 seconds before next check
|
|
await asyncio.sleep(5.0)
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info("Background timer loop cancelled")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in background timer loop: {e}")
|
|
# Restart the timer after a delay
|
|
await asyncio.sleep(30)
|
|
self._background_task = asyncio.create_task(self._background_timer_loop())
|
|
|
|
async def _record_noise_floor_async(self):
|
|
"""Sample every radio's noise floor and persist it against that radio.
|
|
|
|
One radio at a time: a KISS modem's read blocks for up to its response
|
|
timeout, and reading both at once would put two of those in flight while
|
|
the radios are also carrying traffic.
|
|
"""
|
|
if not self.storage:
|
|
return
|
|
|
|
loop = asyncio.get_running_loop()
|
|
sampled = self._sampling_radios()
|
|
# A radio that has left the fabric keeps no stale figure in /stats.
|
|
live_ids = {radio_id for radio_id, _ in sampled if radio_id is not None}
|
|
for retired in set(self._cached_noise_floor_by_radio) - live_ids:
|
|
self._cached_noise_floor_by_radio.pop(retired, None)
|
|
for index, (radio_id, radio) in enumerate(sampled):
|
|
is_default = index == 0
|
|
try:
|
|
# Run in executor so KISS modem's blocking _send_command (up to 5s timeout)
|
|
# does not block the event loop and hang the process / delay Ctrl+C.
|
|
noise_floor = await loop.run_in_executor(
|
|
None, self._read_noise_floor_for, radio_id, radio
|
|
)
|
|
if noise_floor is None:
|
|
logger.debug("Unable to read noise floor from radio %s", radio_id or "default")
|
|
continue
|
|
if is_default:
|
|
self._cached_noise_floor = noise_floor
|
|
if radio_id is not None:
|
|
self._cached_noise_floor_by_radio[radio_id] = noise_floor
|
|
self.storage.record_noise_floor(noise_floor, radio_id, publish=is_default)
|
|
logger.debug(f"Recorded noise floor: {noise_floor} dBm ({radio_id or 'default'})")
|
|
except Exception as e:
|
|
logger.error(f"Error recording noise floor: {e}")
|
|
|
|
def _read_noise_floor_for(self, radio_id: Optional[str], radio) -> Optional[float]:
|
|
"""Read the noise floor of one sampled radio.
|
|
|
|
The default radio goes through ``get_noise_floor`` so everything that
|
|
reports it reads the same path.
|
|
"""
|
|
if radio_id is None:
|
|
return self.get_noise_floor()
|
|
return self._read_noise_floor(radio)
|
|
|
|
async def _record_crc_errors_async(self):
|
|
"""Persist each radio's CRC error delta from its hardware counter.
|
|
|
|
Baselines are per radio: a shared one would charge every radio's errors
|
|
to whichever was sampled last and report swings that never happened.
|
|
"""
|
|
if not self.storage:
|
|
return
|
|
|
|
for index, (radio_id, radio) in enumerate(self._sampling_radios()):
|
|
try:
|
|
current = int(getattr(radio, "crc_error_count", 0) or 0) if radio else 0
|
|
delta = current - self._crc_error_baselines.get(radio_id, 0)
|
|
if delta > 0:
|
|
self.storage.record_crc_errors(delta, radio_id, publish=index == 0)
|
|
logger.debug(
|
|
f"Recorded {delta} CRC errors (total: {current}, "
|
|
f"radio {radio_id or 'default'})"
|
|
)
|
|
self._crc_error_baselines[radio_id] = current
|
|
except Exception as e:
|
|
logger.error(f"Error recording CRC errors: {e}")
|
|
|
|
async def _send_periodic_advert_async(self):
|
|
logger.info(
|
|
f"Periodic advert timer triggered (interval: {self.send_advert_interval_hours}h)"
|
|
)
|
|
try:
|
|
if self.send_advert_func:
|
|
success = await self.send_advert_func()
|
|
if success:
|
|
logger.info("Periodic advert sent successfully")
|
|
else:
|
|
logger.warning("Failed to send periodic advert")
|
|
else:
|
|
logger.debug("No send_advert_func configured")
|
|
except Exception as e:
|
|
logger.error(f"Error sending periodic advert: {e}")
|
|
|
|
def reload_runtime_config(self):
|
|
"""Reload runtime configuration from self.config (called after live config updates)."""
|
|
try:
|
|
# Refresh delay factors
|
|
self.tx_delay_factor = self.config.get("delays", {}).get("tx_delay_factor", 1.0)
|
|
self.direct_tx_delay_factor = self.config.get("delays", {}).get(
|
|
"direct_tx_delay_factor", 0.5
|
|
)
|
|
|
|
# Refresh repeater settings
|
|
repeater_config = self.config.get("repeater", {})
|
|
self.use_score_for_tx = repeater_config.get("use_score_for_tx", False)
|
|
self.score_threshold = repeater_config.get("score_threshold", 0.3)
|
|
self.multi_acks = self._normalize_multi_acks(self.config)
|
|
self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10)
|
|
self.cache_ttl = repeater_config.get("cache_ttl", 60)
|
|
self.max_flood_hops = repeater_config.get("max_flood_hops", 64)
|
|
self.neighbour_link_tracker.refresh_config(self.config)
|
|
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
|
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
|
)
|
|
|
|
with self.neighbour_link_tracker.lock:
|
|
now_monotonic = time.monotonic()
|
|
self.neighbour_link_tracker.purge_expired_locked(now_monotonic)
|
|
while (
|
|
len(self.neighbour_link_tracker.links) > self.neighbour_link_tracker.max_entries
|
|
):
|
|
self.neighbour_link_tracker.evict_stalest_locked()
|
|
|
|
# Radio hardware apply and AirtimeManager modulation refresh are
|
|
# handled by ConfigManager after a successful live radio update.
|
|
|
|
logger.info("Runtime configuration reloaded successfully")
|
|
except Exception as e:
|
|
logger.error(f"Error reloading runtime config: {e}")
|
|
|
|
def cleanup(self):
|
|
if self._background_task and not self._background_task.done():
|
|
self._background_task.cancel()
|
|
logger.info("Background timer task cancelled")
|
|
|
|
if self.storage:
|
|
try:
|
|
self.storage.close()
|
|
logger.info("StorageCollector closed successfully")
|
|
except Exception as e:
|
|
logger.error(f"Error closing StorageCollector: {e}")
|
|
|
|
def __del__(self):
|
|
try:
|
|
self.cleanup()
|
|
except Exception as exc:
|
|
logger.debug(f"Engine cleanup during __del__ failed: {exc}")
|