Files
Daniel Duran 0251304407 fix(mqtt): publish Semtech-derived packet duration instead of hard-coded "0"
Every MQTT-published packet has shipped with duration="0" since the
PacketRecord factory was introduced. The repeater already computes LoRa
time-on-air via AirtimeManager.calculate_airtime() (the canonical
Semtech reference formula) for duty-cycle gating and TX delay, but the
result was thrown away after each packet - never stored on the
packet_record dict that flows to MQTT/SQLite/Glass/websocket.

What changes
- engine.py: RepeaterHandler._build_packet_record() now computes
  airtime_ms once per packet (Semtech formula via AirtimeManager) and
  stores it as packet_record['airtime_ms']. Single source of truth for
  every downstream consumer.
- storage_utils.py: PacketRecord.from_packet_record() reads the new
  airtime_ms field and serializes it as a rounded integer in the
  'duration' field of the published JSON. Falls back to 0 if the field
  is missing (backward compatibility for any older code path).
- storage_collector.py: _publish_packet_to_mqtt() simplified - no
  recomputation, no helper. The publish path is now a passthrough.

Why
MQTT consumers (firmware-compatible analyzers, dashboards, the upstream
meshcoretomqtt project) expect the same time-on-air value the firmware
emits. Hard-coded "0" makes airtime/utilization charts derived from the
mqtt stream useless and silently diverges from firmware behavior.

Plumbing the value through packet_record (instead of recomputing in the
publish path) means any future consumer - SQLite schema, web UI charts,
Glass telemetry - reads the same number without separate calculations.

Tests
tests/test_packet_duration.py - 5 tests covering:
- backward compat (legacy packet_record without airtime_ms => '0')
- airtime_ms field flows through to duration as rounded integer string
- explicit zero stays '0'
- AirtimeManager output matches an independently-implemented Semtech
  reference for typical MeshCore EU settings (SF8/62.5kHz/CR4-8)
- low-data-rate optimization branch (SF12/125kHz triggers DE=1)

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-03 12:48:02 -07:00

91 lines
2.8 KiB
Python

"""Storage utility classes and functions for data acquisition."""
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Optional
@dataclass
class PacketRecord:
"""
Data class for packet record format.
Converts internal packet_record format to standardized publish format.
Reusable across MQTT and other handlers.
"""
origin: str
origin_id: str
timestamp: str
type: str
direction: str
time: str
date: str
len: str
packet_type: str
route: str
payload_len: str
raw: str
SNR: str
RSSI: str
score: str
duration: str
hash: str
@classmethod
def from_packet_record(
cls, packet_record: dict, origin: str, origin_id: str
) -> Optional["PacketRecord"]:
"""
Create PacketRecord from internal packet_record format.
The ``duration`` field is sourced from ``packet_record['airtime_ms']``,
which RepeaterHandler._build_packet_record populates using the
Semtech-reference time-on-air formula on the active radio settings.
Records produced by older code paths that pre-date that field fall
back to 0 to preserve legacy behavior.
Args:
packet_record: Internal packet record dictionary
origin: Node name
origin_id: Public key of the node
Returns:
PacketRecord instance or None if raw_packet is missing
"""
if "raw_packet" not in packet_record or not packet_record["raw_packet"]:
return None
# Extract timestamp and format date/time
timestamp = packet_record.get("timestamp", 0)
dt = datetime.fromtimestamp(timestamp)
# Format route type (1=Flood->F, 2=Direct->D, etc)
route_map = {1: "F", 2: "D"}
route = route_map.get(packet_record.get("route", 0), str(packet_record.get("route", 0)))
airtime_ms = float(packet_record.get("airtime_ms", 0.0) or 0.0)
return cls(
origin=origin,
origin_id=origin_id,
timestamp=dt.isoformat(),
type="PACKET",
direction="rx",
time=dt.strftime("%H:%M:%S"),
date=dt.strftime("%-d/%-m/%Y"),
len=str(len(packet_record["raw_packet"]) // 2),
packet_type=str(packet_record.get("type", 0)),
route=route,
payload_len=str(packet_record.get("payload_length", 0)),
raw=packet_record["raw_packet"],
SNR=str(packet_record.get("snr", 0)),
RSSI=str(packet_record.get("rssi", 0)),
score=str(int(packet_record.get("score", 0) * 1000)),
duration=str(int(round(airtime_ms))),
hash=packet_record.get("packet_hash", ""),
)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization"""
return asdict(self)