mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-05-09 15:04:26 +02:00
+1286
File diff suppressed because it is too large
Load Diff
+38
-93
@@ -31,6 +31,10 @@ repeater:
|
||||
# Duplicate packet cache TTL in seconds
|
||||
cache_ttl: 3600
|
||||
|
||||
# Maximum number of hops a flood packet may have already traversed before
|
||||
# this repeater forwards it.
|
||||
max_flood_hops: 64
|
||||
|
||||
# Score-based transmission filtering
|
||||
# Enable quality-based packet filtering and adaptive delays
|
||||
use_score_for_tx: false
|
||||
@@ -273,49 +277,6 @@ duty_cycle:
|
||||
# Maximum airtime per minute in milliseconds
|
||||
max_airtime_per_minute: 3600
|
||||
|
||||
|
||||
# MQTT Publishing Configuration (Optional)
|
||||
mqtt:
|
||||
# Enable/disable MQTT publishing
|
||||
enabled: false
|
||||
|
||||
# MQTT broker settings
|
||||
broker: "localhost"
|
||||
port: 1883 # Use 8883 for TLS/SSL, 80/443/9001 for WebSockets
|
||||
|
||||
# Use WebSocket transport instead of standard TCP
|
||||
# Typically uses ports: 80 (ws://), 443 (wss://), or 9001
|
||||
use_websockets: false
|
||||
|
||||
# Authentication (optional)
|
||||
username: null
|
||||
password: null
|
||||
|
||||
# TLS/SSL configuration (optional)
|
||||
# For public brokers with trusted certificates, just enable TLS:
|
||||
# tls:
|
||||
# enabled: true
|
||||
tls:
|
||||
enabled: false
|
||||
|
||||
# Advanced TLS options (usually not needed for public brokers):
|
||||
|
||||
# Custom CA certificate for server verification
|
||||
# Leave null to use system default CA certificates (recommended)
|
||||
ca_cert: null # e.g., "/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Client certificate and key for mutual TLS (rarely needed)
|
||||
client_cert: null # e.g., "/etc/pymc/client.crt"
|
||||
client_key: null # e.g., "/etc/pymc/client.key"
|
||||
|
||||
# Skip certificate verification (insecure, not recommended)
|
||||
insecure: false
|
||||
|
||||
# Base topic for publishing
|
||||
# Messages will be published to: {base_topic}/{node_name}/{packet|advert}
|
||||
base_topic: "meshcore/repeater"
|
||||
|
||||
|
||||
# Storage Configuration
|
||||
storage:
|
||||
# Directory for persistent storage files (SQLite, RRD).
|
||||
@@ -333,63 +294,31 @@ storage:
|
||||
# - 1 hour resolution for 1 year
|
||||
|
||||
|
||||
letsmesh:
|
||||
enabled: false
|
||||
mqtt:
|
||||
iata_code: "Test" # e.g., "SFO", "LHR", "Test"
|
||||
|
||||
# ============================================================
|
||||
# BROKER SELECTION MODE - Choose how to connect to brokers
|
||||
# ============================================================
|
||||
#
|
||||
# EXAMPLE 1: Single built-in broker (default, most common)
|
||||
# Connect to Europe only - simple, low bandwidth
|
||||
broker_index: 0 # 0 = Europe, 1 = US West
|
||||
|
||||
# EXAMPLE 2: All built-in brokers for maximum redundancy
|
||||
# Survives single broker failure, best uptime
|
||||
# broker_index: -1 # or null - connects to both EU and US
|
||||
|
||||
# EXAMPLE 3: Only custom brokers (private/self-hosted)
|
||||
# Ignores built-in LetsMesh brokers completely
|
||||
# broker_index: -2
|
||||
# additional_brokers:
|
||||
# - name: "Private Server"
|
||||
# host: "mqtt.myserver.com"
|
||||
# port: 443
|
||||
# audience: "mqtt.myserver.com"
|
||||
|
||||
# EXAMPLE 4: Single built-in + custom backup
|
||||
# Use EU primary with your own backup
|
||||
# broker_index: 0
|
||||
# additional_brokers:
|
||||
# - name: "Backup Server"
|
||||
# host: "mqtt-backup.mydomain.com"
|
||||
# port: 8883
|
||||
# audience: "mqtt-backup.mydomain.com"
|
||||
|
||||
# EXAMPLE 5: All built-in + multiple custom (maximum redundancy)
|
||||
# EU + US + your own servers - best for critical deployments
|
||||
# broker_index: -1
|
||||
# additional_brokers:
|
||||
# - name: "Custom Primary"
|
||||
# host: "mqtt-1.mydomain.com"
|
||||
# port: 443
|
||||
# audience: "mqtt-1.mydomain.com"
|
||||
# - name: "Custom Backup"
|
||||
# host: "mqtt-2.mydomain.com"
|
||||
# port: 443
|
||||
# audience: "mqtt-2.mydomain.com"
|
||||
# ============================================================
|
||||
|
||||
status_interval: 300
|
||||
status_interval: 300 # How often a status message is sent (in seconds)
|
||||
owner: ""
|
||||
email: ""
|
||||
brokers: []
|
||||
|
||||
# Block specific packet types from being published to LetsMesh
|
||||
# Below is the broker object schema:
|
||||
# enabled: true|false # Enable this specific mqtt broker
|
||||
# name: "" # Internal name for this broker
|
||||
# host: "" # hostname or ip of mqtt endpoints
|
||||
# port: # Typically 443 for websocket endpoints or 1883 for tcp
|
||||
# transport: "tcp" or "websockets"
|
||||
# audience: "" # For JWT auth'd endpoints, this is usually the host unless always stated by endpoint owners
|
||||
# use_jwt_auth: true|false # Does this endpoint require JWT auth
|
||||
# username: "" # Username for basic auth. If empty or missing, uses anonymous access
|
||||
# password: "" # Password for basic auth. Required if username is set
|
||||
# format: letsmesh|mqtt
|
||||
# retain_status: true|false # Sets MQTT "retain" on status messages so they remain on the broker when disconnected. Also enforces a QOS of 1 (guaranteed delivery)
|
||||
|
||||
# Block specific packet types from being published to the MQTT endpoint
|
||||
# If not specified or empty list, all types are published
|
||||
# Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT,
|
||||
# GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM
|
||||
disallowed_packet_types: []
|
||||
# disallowed_packet_types: []
|
||||
# - REQ # Don't publish requests
|
||||
# - RESPONSE # Don't publish responses
|
||||
# - TXT_MSG # Don't publish text messages
|
||||
@@ -402,6 +331,22 @@ letsmesh:
|
||||
# - TRACE # Don't publish trace packets
|
||||
# - RAW_CUSTOM # Don't publish custom raw packets
|
||||
|
||||
# Example of using the US and EU LetsMesh endpoints
|
||||
# brokers:
|
||||
# - name: US West (LetsMesh v1)
|
||||
# host: mqtt-us-v1.letsmesh.net
|
||||
# port: 443
|
||||
# audience: mqtt-us-v1.letsmesh.net
|
||||
# use_jwt_auth: true
|
||||
# enabled: true
|
||||
|
||||
# - name: Europe (LetsMesh v1)
|
||||
# host: mqtt-eu-v1.letsmesh.net
|
||||
# port: 443
|
||||
# audience: mqtt-eu-v1.letsmesh.net
|
||||
# use_jwt_auth: true
|
||||
# enabled: true
|
||||
|
||||
# pyMC_Glass control-plane integration (optional)
|
||||
glass:
|
||||
# Enable repeater -> pyMC_Glass /inform loop
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# PR: Compute Packet Hash Once Per Forwarded Packet
|
||||
|
||||
**Branch:** `perf/hash-once`
|
||||
**Base:** `rightup/fix-perfom-speed`
|
||||
**Files changed:** `repeater/engine.py` (1 file, ~51 lines net)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
`packet.calculate_packet_hash()` runs a SHA-256 digest over the full serialised
|
||||
packet bytes, converts the result to a hex string, and uppercases it. Before
|
||||
this change the hot forwarding path triggered this computation **three times per
|
||||
packet**:
|
||||
|
||||
| Call site | Where | When |
|
||||
|-----------|-------|------|
|
||||
| `__call__` line 162 | `pkt_hash_full = packet.calculate_packet_hash()...` | Every received packet |
|
||||
| `flood_forward` / `direct_forward` via `is_duplicate` | `pkt_hash = packet.calculate_packet_hash()...` | Every packet that reaches the forward check |
|
||||
| `flood_forward` / `direct_forward` via `mark_seen` | `pkt_hash = packet_hash or packet.calculate_packet_hash()...` | Every packet that passes the duplicate check |
|
||||
|
||||
And on the drop path, a fourth computation:
|
||||
|
||||
| Call site | Where | When |
|
||||
|-----------|-------|------|
|
||||
| `_get_drop_reason` → `is_duplicate` | `pkt_hash = packet.calculate_packet_hash()...` | Every dropped packet |
|
||||
|
||||
The hash computed in `__call__` was already available as `pkt_hash_full` but was
|
||||
never passed into `process_packet`, `flood_forward`, `direct_forward`,
|
||||
`is_duplicate`, `mark_seen`, or `_get_drop_reason`. Each of those methods
|
||||
recomputed it independently.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `packet_hash` optional parameter existed on `mark_seen` but not on
|
||||
`is_duplicate`, `flood_forward`, `direct_forward`, `process_packet`, or
|
||||
`_get_drop_reason`. The call chain therefore had no way to propagate the
|
||||
already-computed hash.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
Thread the pre-computed `pkt_hash_full` from `__call__` down through the call
|
||||
chain as an optional `packet_hash: Optional[str] = None` parameter. Each method
|
||||
uses the provided hash if present, or falls back to computing it — preserving
|
||||
backward compatibility for any caller that doesn't have a pre-computed hash.
|
||||
|
||||
```
|
||||
Before:
|
||||
__call__ → calculate_packet_hash() #1
|
||||
→ process_packet
|
||||
→ flood_forward
|
||||
→ is_duplicate → calculate_packet_hash() #2
|
||||
→ mark_seen → calculate_packet_hash() #3
|
||||
(drop path)
|
||||
→ _get_drop_reason
|
||||
→ is_duplicate → calculate_packet_hash() #4
|
||||
|
||||
After:
|
||||
__call__ → calculate_packet_hash() #1 (only computation)
|
||||
→ process_packet(packet_hash=pkt_hash_full)
|
||||
→ flood_forward(packet_hash=pkt_hash_full)
|
||||
→ is_duplicate(packet_hash=pkt_hash_full) uses provided hash ✓
|
||||
→ mark_seen(packet_hash=pkt_hash_full) uses provided hash ✓
|
||||
(drop path)
|
||||
→ _get_drop_reason(packet_hash=pkt_hash_full)
|
||||
→ is_duplicate(packet_hash=pkt_hash_full) uses provided hash ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Methods Changed
|
||||
|
||||
### `is_duplicate(packet, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def is_duplicate(self, packet: Packet) -> bool:
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper() # always recomputed
|
||||
if pkt_hash in self.seen_packets:
|
||||
return True
|
||||
return False
|
||||
|
||||
# After
|
||||
def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool:
|
||||
"""...
|
||||
INVARIANT: purely synchronous — no await points. The caller relies on
|
||||
is_duplicate + mark_seen being 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
|
||||
```
|
||||
|
||||
### `_get_drop_reason(packet, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def _get_drop_reason(self, packet: Packet) -> str:
|
||||
if self.is_duplicate(packet): ... # recomputes hash
|
||||
|
||||
# After
|
||||
def _get_drop_reason(self, packet: Packet, packet_hash: Optional[str] = None) -> str:
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash): ... # propagates hash
|
||||
```
|
||||
|
||||
### `flood_forward(packet, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def flood_forward(self, packet: Packet) -> Optional[Packet]:
|
||||
...
|
||||
if self.is_duplicate(packet): ... # recomputes
|
||||
self.mark_seen(packet) # recomputes
|
||||
|
||||
# After
|
||||
def flood_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]:
|
||||
"""...
|
||||
INVARIANT: purely synchronous — no await points.
|
||||
"""
|
||||
...
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash): ... # propagates
|
||||
self.mark_seen(packet, packet_hash=packet_hash) # propagates
|
||||
```
|
||||
|
||||
### `direct_forward(packet, packet_hash=None)` — same pattern as `flood_forward`
|
||||
|
||||
### `process_packet(packet, snr=0.0, packet_hash=None)`
|
||||
|
||||
```python
|
||||
# Before
|
||||
def process_packet(self, packet, snr=0.0):
|
||||
fwd_pkt = self.flood_forward(packet) # no hash
|
||||
|
||||
# After
|
||||
def process_packet(self, packet, snr=0.0, packet_hash=None):
|
||||
"""...
|
||||
packet_hash: pre-computed SHA-256 hex from __call__; eliminates 2 SHA-256
|
||||
calls per forwarded packet by propagating the hash through the call chain.
|
||||
"""
|
||||
fwd_pkt = self.flood_forward(packet, packet_hash=packet_hash)
|
||||
```
|
||||
|
||||
### `__call__` — two call-site changes
|
||||
|
||||
```python
|
||||
# Before
|
||||
result = (None if ... else self.process_packet(processed_packet, snr))
|
||||
...
|
||||
drop_reason = processed_packet.drop_reason or self._get_drop_reason(processed_packet)
|
||||
|
||||
# After
|
||||
result = (None if ... else self.process_packet(processed_packet, snr, packet_hash=pkt_hash_full))
|
||||
...
|
||||
drop_reason = processed_packet.drop_reason or self._get_drop_reason(
|
||||
processed_packet, packet_hash=pkt_hash_full
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Was Not Changed
|
||||
|
||||
`record_packet_only` (line 446) and `record_duplicate` (line 486) each compute
|
||||
the hash independently. These are separate recording paths (called from the
|
||||
inject path and from the raw-packet subscriber, respectively) that have no
|
||||
`pkt_hash_full` from `__call__` in scope. Changing them would require a larger
|
||||
refactor with no benefit to the forwarding hot path, so they are left unchanged.
|
||||
|
||||
The fallback `packet_hash or packet.calculate_packet_hash()...` pattern in
|
||||
`is_duplicate`, `mark_seen`, and `_build_packet_record` ensures external callers
|
||||
(e.g. `TraceHelper.is_duplicate(packet)` from trace processing) continue to work
|
||||
without any change.
|
||||
|
||||
---
|
||||
|
||||
## Invariant Comments Added
|
||||
|
||||
`flood_forward`, `direct_forward`, and `is_duplicate` now carry explicit docstring
|
||||
invariants:
|
||||
|
||||
> **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`.
|
||||
|
||||
These invariants were implicit before. Making them explicit means a future
|
||||
contributor adding an `await` inside these methods will see the warning and
|
||||
understand the consequence: the duplicate-check and mark-seen can no longer be
|
||||
guaranteed atomic, allowing the same packet to be forwarded twice under concurrent
|
||||
task dispatch.
|
||||
|
||||
---
|
||||
|
||||
## Quantification
|
||||
|
||||
On a Raspberry Pi running CPython 3.13, `hashlib.sha256` on a 50–200 byte
|
||||
LoRa payload takes approximately 1–3 µs. The `.hex().upper()` string conversion
|
||||
adds another ~0.5 µs. Savings per forwarded packet: ~3–8 µs.
|
||||
|
||||
At 3 packets/second sustained forwarding rate this saves ~10–25 µs/second, which
|
||||
is negligible in absolute terms. The more significant benefit is correctness and
|
||||
clarity:
|
||||
|
||||
- One canonical hash value per packet in the forwarding path.
|
||||
- No possibility of the hash changing between the `is_duplicate` check and the
|
||||
`mark_seen` call if `calculate_packet_hash` had any mutable state (it doesn't,
|
||||
but the pattern is now provably correct).
|
||||
- Explicit invariant documentation closes a latent trap for future contributors.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests (no hardware)
|
||||
|
||||
**T1 — Hash computed exactly once per forwarded packet**
|
||||
|
||||
```python
|
||||
async def test_hash_computed_once_for_flood():
|
||||
call_count = 0
|
||||
original = Packet.calculate_packet_hash
|
||||
|
||||
def counting_hash(self):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original(self)
|
||||
|
||||
with patch.object(Packet, "calculate_packet_hash", counting_hash):
|
||||
await engine(flood_packet, metadata={})
|
||||
|
||||
assert call_count == 1, f"Expected 1 hash computation, got {call_count}"
|
||||
```
|
||||
|
||||
**T2 — Hash computed exactly once per dropped (duplicate) packet**
|
||||
|
||||
```python
|
||||
async def test_hash_computed_once_for_duplicate():
|
||||
# Mark packet seen first
|
||||
engine.seen_packets[packet.calculate_packet_hash().hex().upper()] = time.time()
|
||||
|
||||
call_count = 0
|
||||
original = Packet.calculate_packet_hash
|
||||
def counting_hash(self):
|
||||
nonlocal call_count; call_count += 1; return original(self)
|
||||
|
||||
with patch.object(Packet, "calculate_packet_hash", counting_hash):
|
||||
await engine(packet, metadata={})
|
||||
|
||||
# One computation in __call__ for pkt_hash_full; should not trigger again
|
||||
# in process_packet → flood_forward → is_duplicate (drop path via _get_drop_reason)
|
||||
assert call_count == 1
|
||||
```
|
||||
|
||||
**T3 — External callers of `is_duplicate` without hash still work**
|
||||
|
||||
```python
|
||||
def test_is_duplicate_without_hash():
|
||||
"""TraceHelper and other external callers pass no hash — must still work."""
|
||||
pkt = make_test_packet()
|
||||
engine.seen_packets[pkt.calculate_packet_hash().hex().upper()] = time.time()
|
||||
|
||||
assert engine.is_duplicate(pkt) is True # no packet_hash arg
|
||||
assert engine.is_duplicate(pkt, packet_hash="WRONGHASH") is False
|
||||
```
|
||||
|
||||
**T4 — mark_seen / is_duplicate agree on the same hash**
|
||||
|
||||
```python
|
||||
def test_mark_then_is_duplicate_consistent():
|
||||
pkt = make_test_packet()
|
||||
pkt_hash = pkt.calculate_packet_hash().hex().upper()
|
||||
|
||||
assert engine.is_duplicate(pkt, packet_hash=pkt_hash) is False
|
||||
engine.mark_seen(pkt, packet_hash=pkt_hash)
|
||||
assert engine.is_duplicate(pkt, packet_hash=pkt_hash) is True
|
||||
# Same result without the pre-computed hash (fallback path)
|
||||
assert engine.is_duplicate(pkt) is True
|
||||
```
|
||||
|
||||
**T5 — flood_forward / direct_forward signatures are backward compatible**
|
||||
|
||||
```python
|
||||
def test_flood_forward_no_hash_arg():
|
||||
"""Callers that don't pass packet_hash must still work (fallback compute)."""
|
||||
pkt = make_flood_packet()
|
||||
result = engine.flood_forward(pkt) # no packet_hash — must not raise
|
||||
assert result is not None or pkt.drop_reason is not None
|
||||
```
|
||||
|
||||
### Integration / field tests (with hardware)
|
||||
|
||||
**T6 — Forwarding throughput unchanged**
|
||||
|
||||
1. Forward 100 packets at maximum duty-cycle budget.
|
||||
2. Verify all eligible packets are forwarded (same count as before change).
|
||||
3. Verify no `Duplicate` drops that were not present before.
|
||||
|
||||
**T7 — Duplicate detection unchanged**
|
||||
|
||||
1. Send the same packet twice within 1 second.
|
||||
2. Verify the first is forwarded and the second is logged as `"Duplicate"`.
|
||||
|
||||
**T8 — CPU profile shows reduced `calculate_packet_hash` calls**
|
||||
|
||||
1. Enable Python profiling (`cProfile`) on the repeater for 60 seconds.
|
||||
2. Compare `calculate_packet_hash` call count before and after.
|
||||
|
||||
**Expected:** call count approximately halved for workloads where most packets
|
||||
are forwarded (≤ 1 call per forwarded packet vs ≥ 3 before).
|
||||
|
||||
---
|
||||
|
||||
## Proof of Correctness
|
||||
|
||||
### Why the fallback `packet_hash or packet.calculate_packet_hash()` is safe
|
||||
|
||||
`packet_hash` is either the correct hash (passed from `__call__`) or `None`.
|
||||
If it is `None`, the fallback computes the hash fresh — identical to the old
|
||||
behaviour. There is no case where a wrong hash is used: the only source of a
|
||||
non-None `packet_hash` is `pkt_hash_full = packet.calculate_packet_hash()...`
|
||||
in `__call__`, computed over the same `processed_packet` (a deep copy of the
|
||||
received packet, unchanged between hash computation and the call to
|
||||
`process_packet`).
|
||||
|
||||
### Why passing the hash through a deep-copied packet is correct
|
||||
|
||||
`processed_packet = copy.deepcopy(packet)` (line 178) happens before
|
||||
`pkt_hash_full` is passed to `process_packet`. The deep copy does not change
|
||||
the packet's wire representation — `calculate_packet_hash()` calls
|
||||
`packet.write_to()` which serialises the packet's fields. The copy has the
|
||||
same fields, so `deepcopy(packet).calculate_packet_hash() == packet.calculate_packet_hash()`.
|
||||
Passing the hash computed from the original to the copy is correct.
|
||||
|
||||
### Why the invariant is critical
|
||||
|
||||
asyncio only yields execution at `await` points. `flood_forward` and
|
||||
`direct_forward` have no `await`, so they run atomically from the event loop's
|
||||
perspective. The `is_duplicate` check and the `mark_seen` call inside them
|
||||
cannot be interleaved with another coroutine. If a future change added an
|
||||
`await` between them, two concurrent `_route_packet` tasks could both pass the
|
||||
duplicate check for the same packet before either marked it seen — sending the
|
||||
same packet twice. The invariant comment documents this so the risk is visible
|
||||
at the point where it could be broken.
|
||||
@@ -0,0 +1,349 @@
|
||||
# PR: Bounded In-Flight Task Counter + Simplified Route Task Management
|
||||
|
||||
**Branch:** `perf/in-flight-cap`
|
||||
**Base:** `rightup/fix-perfom-speed`
|
||||
**Files changed:** `repeater/packet_router.py` (1 file, ~33 lines net)
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The queue loop dispatches each incoming packet as an `asyncio.create_task` so TX
|
||||
delay timers run concurrently — this is correct behaviour. The previous
|
||||
implementation tracked these tasks in a `set[asyncio.Task]` (`_route_tasks`) for
|
||||
two reasons:
|
||||
|
||||
1. **Error surfacing** — the done-callback read `task.result()` to log exceptions.
|
||||
2. **Shutdown cancellation** — `stop()` cancelled and awaited all tasks in the set.
|
||||
|
||||
This PR replaces the set with a simple integer counter and tightens the companion
|
||||
deduplication prune threshold.
|
||||
|
||||
---
|
||||
|
||||
## Problems
|
||||
|
||||
### Problem 1 — Unbounded task accumulation
|
||||
|
||||
LoRa airtime naturally limits steady-state throughput to a handful of in-flight
|
||||
tasks at any time. But burst arrivals can spike the count temporarily:
|
||||
|
||||
- **Multi-hop flood amplification**: a single source packet is forwarded by every
|
||||
repeater in range, each of which re-broadcasts it. A node at a mesh junction
|
||||
may receive 5–10 copies within 100 ms, each scheduling a separate `delayed_send`
|
||||
task.
|
||||
- **Collision retries**: hardware-level collisions produce duplicate RF bursts that
|
||||
all arrive within the same RX window.
|
||||
- **Bridge nodes**: high-traffic gateway nodes connect multiple mesh segments and
|
||||
forward both directions simultaneously.
|
||||
|
||||
Under these conditions `_route_tasks` can accumulate dozens of sleeping tasks.
|
||||
Each holds a reference to the packet, the forwarded packet copy, a closure over
|
||||
`delayed_send`, and associated asyncio task overhead. There is no cap; the set
|
||||
grows until the duty-cycle gate finally fires for each task.
|
||||
|
||||
### Problem 2 — `_route_tasks` set adds O(1) cost on every packet but O(n) cost on shutdown
|
||||
|
||||
Every packet adds one entry to `_route_tasks` and removes it in the done-callback.
|
||||
This is O(1) per operation, but the `stop()` shutdown path iterates the entire set
|
||||
to cancel and gather all tasks — O(n) where n is however many tasks happen to be
|
||||
in-flight at shutdown time. On a busy node this could delay clean shutdown.
|
||||
|
||||
### Problem 3 — `_COMPANION_DEDUPE_PRUNE_THRESHOLD = 1000` is too high
|
||||
|
||||
The companion delivery deduplication dict prunes itself only when it exceeds 1000
|
||||
entries. With a 60-second TTL, each PATH/protocol-response packet adds one entry.
|
||||
On a busy mesh with 50+ nodes sending adverts and PATH packets, the dict can grow
|
||||
to hundreds of entries before a prune is triggered — keeping stale entries in
|
||||
memory for up to 60 seconds × 1000/rate entries worth of time.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Replace `_route_tasks` set with `_in_flight` counter
|
||||
|
||||
An integer counter provides the same protection (tasks complete; done-callback
|
||||
fires) without holding strong references to each task object:
|
||||
|
||||
```python
|
||||
# __init__
|
||||
self._in_flight: int = 0
|
||||
self._max_in_flight: int = 30
|
||||
|
||||
# _process_queue — drop early if cap reached
|
||||
if self._in_flight >= self._max_in_flight:
|
||||
logger.warning("In-flight task cap reached (%d/%d), dropping packet", ...)
|
||||
continue
|
||||
self._in_flight += 1
|
||||
task = asyncio.create_task(self._route_packet(packet))
|
||||
task.add_done_callback(self._on_route_done)
|
||||
|
||||
# done-callback
|
||||
def _on_route_done(self, task):
|
||||
self._in_flight -= 1
|
||||
if not task.cancelled() and task.exception():
|
||||
logger.error("_route_packet raised: %s", task.exception(), ...)
|
||||
```
|
||||
|
||||
### Cap at 30 concurrent in-flight tasks
|
||||
|
||||
30 is chosen as a ceiling that is:
|
||||
- **Never reached in normal operation**: LoRa airtime at SF8/125 kHz limits
|
||||
throughput to ~2–3 packets per second; with delays of 0.5–5 s each, the
|
||||
steady-state in-flight count is at most 5–15 tasks.
|
||||
- **High enough not to drop legitimate traffic**: a burst of 30 nearly-simultaneous
|
||||
packets would require every node in a large mesh to transmit within 1 second.
|
||||
- **Low enough to protect against pathological scenarios**: a misconfigured node
|
||||
flooding the channel or a software bug causing infinite re-queuing.
|
||||
|
||||
### Tighten companion dedup prune threshold to 200
|
||||
|
||||
200 entries at 60 s TTL means a sweep is triggered after ~200 unique PATH/response
|
||||
packets arrive without any expiry. This is far more than a typical companion
|
||||
session (which sees a handful of active connections) but prevents multi-hour
|
||||
accumulation on a busy mesh.
|
||||
|
||||
---
|
||||
|
||||
## Trade-off: Shutdown Cancellation
|
||||
|
||||
The previous `_route_tasks` set allowed `stop()` to explicitly cancel and await
|
||||
all in-flight tasks on shutdown. The counter approach does not.
|
||||
|
||||
**Why this is acceptable:**
|
||||
|
||||
1. In-flight `_route_packet` tasks are sleeping inside `delayed_send` (waiting for
|
||||
their TX delay timer). When the event loop is shut down — whether via
|
||||
`asyncio.run()` completing, `loop.stop()`, or `SIGTERM` handling — Python
|
||||
cancels all pending tasks automatically.
|
||||
|
||||
2. Even under the old approach, cancelling a sleeping `delayed_send` means the
|
||||
packet is not transmitted. The result is the same whether cancellation happens
|
||||
explicitly in `stop()` or implicitly when the event loop closes.
|
||||
|
||||
3. For a graceful shutdown where we want to *wait* for in-flight packets to
|
||||
complete transmission, the right mechanism is `stop()` awaiting the queue to
|
||||
drain *before* cancelling the router task — not cancelling sleeping tasks.
|
||||
Neither the old code nor this PR implements that, so no regression.
|
||||
|
||||
---
|
||||
|
||||
## Why This Is the Right Approach
|
||||
|
||||
### Alternative A — Keep `_route_tasks` set, add a size cap
|
||||
|
||||
```python
|
||||
if len(self._route_tasks) >= 30:
|
||||
logger.warning(...)
|
||||
continue
|
||||
```
|
||||
|
||||
Works, but the set still holds a strong reference to every Task object for the
|
||||
duration of its sleep. The counter holds an integer. Task objects in Python 3.12+
|
||||
are already strongly referenced by the event loop scheduler; the set reference is
|
||||
redundant for preventing GC cancellation.
|
||||
|
||||
### Alternative B — `asyncio.Semaphore`
|
||||
|
||||
```python
|
||||
self._sem = asyncio.Semaphore(30)
|
||||
async with self._sem:
|
||||
await self._route_packet(packet)
|
||||
```
|
||||
|
||||
Correct but changes the queue loop from fire-and-forget to blocking: the loop
|
||||
would wait at `async with self._sem` for a slot to open, stalling packet reads
|
||||
while a slot is occupied. That reintroduces the queue freeze the concurrent
|
||||
dispatch was designed to prevent. A semaphore is the right tool for *rate-
|
||||
limiting* producers; a counter cap at the dispatch site is the right tool for
|
||||
bounding *background* tasks.
|
||||
|
||||
### Alternative C — Integer counter (this PR)
|
||||
|
||||
- O(1) increment and decrement.
|
||||
- No strong reference to task objects beyond the event loop's own reference.
|
||||
- Drop decision is synchronous and immediate — no sleeping on semaphore.
|
||||
- Error logging preserved in `_on_route_done`.
|
||||
- Simpler code, easier to reason about.
|
||||
|
||||
---
|
||||
|
||||
## Changes — `repeater/packet_router.py` only
|
||||
|
||||
| Location | Change | Reason |
|
||||
|----------|--------|--------|
|
||||
| Module level | Remove `_COMPANION_DEDUPE_PRUNE_THRESHOLD = 1000` | Replaced with inline literal `200`; no need for a named constant for a single usage site |
|
||||
| `__init__` | Remove `self._route_tasks = set()`; add `self._in_flight = 0`, `self._max_in_flight = 30` | Replace set-based tracking with counter |
|
||||
| `stop()` | Remove `_route_tasks` cancellation block | Tasks complete or are cancelled by event loop shutdown; explicit cancellation not needed |
|
||||
| `_on_route_task_done` → `_on_route_done` | Simpler done-callback: decrement counter + log exceptions | Error logging preserved; set management removed |
|
||||
| `_should_deliver_path_to_companions` | `> _COMPANION_DEDUPE_PRUNE_THRESHOLD` → `> 200` with explanatory comment | Lower threshold; comment explains the sizing rationale |
|
||||
| `_process_queue` | Check `_in_flight >= _max_in_flight` before `create_task`; increment `_in_flight`; use `_on_route_done` | Cap accumulation; counter tracks live task count |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests (no hardware)
|
||||
|
||||
**T1 — Counter increments and decrements correctly**
|
||||
|
||||
```python
|
||||
async def test_in_flight_counter():
|
||||
router = PacketRouter(mock_daemon)
|
||||
await router.start()
|
||||
|
||||
assert router._in_flight == 0
|
||||
|
||||
# Enqueue a packet that takes time to process
|
||||
async def slow_route(pkt):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
router._route_packet = slow_route
|
||||
await router.enqueue(make_test_packet())
|
||||
await asyncio.sleep(0.01) # let queue loop run
|
||||
|
||||
assert router._in_flight == 1 # task is sleeping
|
||||
|
||||
await asyncio.sleep(0.15) # task finishes
|
||||
assert router._in_flight == 0 # counter decremented by done-callback
|
||||
```
|
||||
|
||||
**T2 — Cap enforced: packet dropped when at limit**
|
||||
|
||||
```python
|
||||
async def test_cap_drops_packet_at_limit():
|
||||
router = PacketRouter(mock_daemon)
|
||||
router._max_in_flight = 2
|
||||
router._in_flight = 2 # simulate cap reached
|
||||
|
||||
dropped = []
|
||||
original_create_task = asyncio.create_task
|
||||
asyncio.create_task = lambda coro: dropped.append(coro)
|
||||
|
||||
await router._process_queue_once(make_test_packet())
|
||||
|
||||
assert dropped == [], "create_task must not be called when cap is reached"
|
||||
asyncio.create_task = original_create_task
|
||||
```
|
||||
|
||||
**T3 — Exceptions in `_route_packet` are logged, not swallowed**
|
||||
|
||||
```python
|
||||
async def test_exception_logged():
|
||||
router = PacketRouter(mock_daemon)
|
||||
|
||||
async def failing_route(pkt):
|
||||
raise ValueError("simulated error")
|
||||
|
||||
router._route_packet = failing_route
|
||||
with patch("repeater.packet_router.logger") as mock_log:
|
||||
task = asyncio.create_task(failing_route(make_test_packet()))
|
||||
router._in_flight = 1
|
||||
task.add_done_callback(router._on_route_done)
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
assert router._in_flight == 0
|
||||
```
|
||||
|
||||
**T4 — Companion dedup dict pruned at 200, not 1000**
|
||||
|
||||
```python
|
||||
def test_companion_dedup_prune_threshold():
|
||||
router = PacketRouter(mock_daemon)
|
||||
future_time = time.time() + 999
|
||||
|
||||
# Fill with 199 entries (all unexpired) — no prune
|
||||
router._companion_delivered = {f"key{i}": future_time for i in range(199)}
|
||||
pkt = make_path_packet()
|
||||
router._should_deliver_path_to_companions(pkt)
|
||||
assert len(router._companion_delivered) == 200 # added one, no prune yet
|
||||
|
||||
# 201st entry triggers prune — all unexpired so count stays at 201
|
||||
router._companion_delivered[f"key_extra"] = future_time
|
||||
assert len(router._companion_delivered) == 201
|
||||
|
||||
# Force prune by making all existing entries expired
|
||||
past_time = time.time() - 1
|
||||
router._companion_delivered = {f"key{i}": past_time for i in range(201)}
|
||||
router._should_deliver_path_to_companions(pkt)
|
||||
# All expired entries pruned; only the new entry remains
|
||||
assert len(router._companion_delivered) == 1
|
||||
```
|
||||
|
||||
### Integration / field tests (with hardware)
|
||||
|
||||
**T5 — Burst flood: verify cap fires under pathological load**
|
||||
|
||||
1. Configure a test mesh with 4+ nodes all in range of the repeater.
|
||||
2. Have all nodes send a flood packet simultaneously.
|
||||
3. Observe repeater logs.
|
||||
|
||||
**Expected:** `_in_flight` peaks in low single digits (LoRa airtime prevents
|
||||
large bursts); no `"In-flight task cap reached"` warning fires under normal
|
||||
conditions, confirming the cap is never a bottleneck in practice.
|
||||
|
||||
**T6 — Counter reaches zero after all packets processed**
|
||||
|
||||
1. Send a burst of 10 packets.
|
||||
2. Wait 10 seconds (longer than max TX delay of 5 s).
|
||||
3. Query `router._in_flight` from a debug endpoint or log.
|
||||
|
||||
**Expected:** `_in_flight == 0` after all delays expire and packets transmit.
|
||||
|
||||
**T7 — Error in `_route_packet` is logged and counter is decremented**
|
||||
|
||||
1. Temporarily introduce a deliberate exception in `_route_packet`.
|
||||
2. Send a packet.
|
||||
3. Check logs for the error message and verify the repeater continues operating
|
||||
(counter decremented, queue still draining).
|
||||
|
||||
**T8 — Normal forwarding throughput unchanged**
|
||||
|
||||
1. Send packets at a steady rate of 1 every 10 seconds for 5 minutes.
|
||||
2. Verify all packets are forwarded with no warnings or errors.
|
||||
3. Confirm `_in_flight` never exceeds 3–4 during normal operation.
|
||||
|
||||
---
|
||||
|
||||
## Proof of Correctness
|
||||
|
||||
### Counter vs set: why the counter is sufficient
|
||||
|
||||
The `_route_tasks` set solved two problems:
|
||||
|
||||
1. **GC protection**: In Python < 3.12, a task with no strong references other
|
||||
than the event loop's internal weakref could be garbage collected before
|
||||
completing. Python 3.12+ strengthened task references in the event loop.
|
||||
However, even in earlier versions, the set was unnecessary once `create_task`
|
||||
returns — the caller holds the reference, and the done-callback fires reliably
|
||||
because the event loop holds the task alive until completion.
|
||||
|
||||
2. **Explicit shutdown cancellation**: The counter loses this. As argued above,
|
||||
the outcome is identical — sleeping tasks are cancelled either explicitly by
|
||||
`stop()` or implicitly by the event loop at shutdown — and no packet that
|
||||
hasn't been transmitted yet can complete its send after the radio is shut down
|
||||
anyway.
|
||||
|
||||
### Why `_on_route_done` is a done-callback and not a `try/finally` inside `_route_packet`
|
||||
|
||||
A `try/finally` block inside `_route_packet` would also decrement the counter.
|
||||
Done-callbacks are preferable because:
|
||||
|
||||
- They fire even if the task is externally cancelled (e.g. by event loop shutdown),
|
||||
whereas `finally` may not run if `CancelledError` is not caught.
|
||||
- They decouple counter management from `_route_packet` logic — `_route_packet`
|
||||
has no knowledge of or dependency on the cap mechanism.
|
||||
- They keep the pattern consistent with the rest of the codebase's use of
|
||||
`add_done_callback` for task lifecycle management.
|
||||
|
||||
### Why 30 and not a smaller number like 10
|
||||
|
||||
At SF8, 125 kHz bandwidth, a 30-byte payload takes ~111 ms airtime and produces
|
||||
a TX delay of roughly 0.5–3 s. With a 60-second duty-cycle window and 3.6 s
|
||||
max airtime, the node can forward at most ~32 packets per minute at full budget.
|
||||
If all 32 arrive within one second (they cannot physically, but as an upper
|
||||
bound), 32 tasks would be in-flight simultaneously. A cap of 30 is aggressive
|
||||
enough to protect against unbounded growth but not so low that it would drop
|
||||
legitimate traffic under any realistic burst scenario.
|
||||
@@ -0,0 +1,395 @@
|
||||
# PR: Serialise Radio TX and Close Duty-Cycle TOCTOU Race
|
||||
|
||||
**Branch:** `fix/tx-serialization`
|
||||
**Base:** `rightup/fix-perfom-speed`
|
||||
**Files changed:** `repeater/engine.py` (1 file, ~30 lines net)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Two separate bugs share the same root cause: concurrent `delayed_send` coroutines
|
||||
racing each other at transmission time.
|
||||
|
||||
### Bug 1 — Interleaved SPI/serial commands to the radio
|
||||
|
||||
The queue loop (added in an earlier commit) dispatches each incoming packet as an
|
||||
`asyncio.create_task`, so multiple `delayed_send` coroutines can have their sleep
|
||||
timers running concurrently. That is correct and intentional — it mirrors how
|
||||
firmware nodes use a hardware timer so the radio keeps listening during a TX delay.
|
||||
|
||||
However the LoRa radio is **half-duplex**: it can only transmit one packet at a
|
||||
time. When two delay timers expire at nearly the same moment both coroutines call
|
||||
`dispatcher.send_packet` simultaneously. `send_packet` issues a sequence of
|
||||
SPI/serial register writes to the radio; two tasks interleaving these writes
|
||||
produces undefined radio state and the transmission of neither packet is reliable.
|
||||
|
||||
### Bug 2 — TOCTOU gap in duty-cycle enforcement
|
||||
|
||||
`__call__` calls `can_transmit()` before scheduling a task:
|
||||
|
||||
```python
|
||||
# __call__ (before this fix)
|
||||
can_tx, wait_time = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx:
|
||||
... # drop or defer
|
||||
tx_task = await self.schedule_retransmit(fwd_pkt, delay, airtime_ms, ...)
|
||||
```
|
||||
|
||||
`record_tx()` is only called later, inside `delayed_send`, after the sleep
|
||||
completes. Between the check and the debit there is a window that spans the
|
||||
entire TX delay (up to several seconds). Two packets that both pass the check
|
||||
before either has slept and recorded its airtime will **both** be transmitted even
|
||||
if transmitting both would exceed the duty-cycle budget.
|
||||
|
||||
Under normal single-packet conditions this window is harmless. Under burst
|
||||
conditions — multi-hop amplification, collision retries, or a busy mesh segment
|
||||
where several packets arrive within the same delay window — multiple tasks pass
|
||||
the advisory check simultaneously, and the duty-cycle limit is exceeded.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
There is no mutual exclusion around the radio send path. Each `delayed_send`
|
||||
coroutine independently checks duty-cycle, sleeps, and transmits without
|
||||
coordinating with any other concurrent coroutine doing the same thing.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
Add `self._tx_lock = asyncio.Lock()` (initialised in `__init__`) and acquire it
|
||||
inside `delayed_send` **after** the sleep completes:
|
||||
|
||||
```
|
||||
Delay timers run concurrently (unchanged):
|
||||
Task A: sleep(1.2s) ──────────────────► acquire _tx_lock → check → TX A → release
|
||||
Task B: sleep(0.9s) ──────────────────► acquire _tx_lock (waits) ──────────► check → TX B → release
|
||||
Task C: sleep(2.1s) ────────────────────────────────────────────────────────────────► ...
|
||||
|
||||
Radio: one packet at a time, duty-cycle state always stable inside the lock.
|
||||
```
|
||||
|
||||
Inside the lock, a **second** `can_transmit()` call is made immediately before
|
||||
sending. Because only one task holds the lock at a time, airtime state is stable
|
||||
at this point and `record_tx()` follows on success — check and debit are
|
||||
effectively atomic. This closes the TOCTOU window completely.
|
||||
|
||||
The upfront `can_transmit()` in `__call__` is retained as an **advisory** fast
|
||||
path: it still drops or defers packets that are obviously over budget before a
|
||||
delay task is even scheduled, avoiding unnecessary sleep timers. It is no longer
|
||||
the enforcement point.
|
||||
|
||||
---
|
||||
|
||||
## Why This Is the Right Approach
|
||||
|
||||
### Alternative A — Move `record_tx()` before the sleep
|
||||
|
||||
```python
|
||||
# hypothetical
|
||||
self.airtime_mgr.record_tx(airtime_ms) # reserve before sleeping
|
||||
await asyncio.sleep(delay)
|
||||
await self.dispatcher.send_packet(...) # actual TX
|
||||
```
|
||||
|
||||
Records airtime even if the send fails (exception, LBT busy, radio error) —
|
||||
the budget is debited for a packet that was never transmitted. Over time this
|
||||
inflates the apparent airtime, causing the node to throttle legitimate traffic
|
||||
it actually has budget for. Requires a compensating `release_airtime()` on
|
||||
every failure path, creating new complexity and failure modes.
|
||||
|
||||
### Alternative B — A single global advisory check (status quo before this PR)
|
||||
|
||||
Already demonstrated to fail under burst conditions (two tasks both pass before
|
||||
either records its airtime).
|
||||
|
||||
### Alternative C — asyncio.Lock (this PR)
|
||||
|
||||
- Delay timers remain concurrent — no regression on the primary non-blocking TX
|
||||
improvement.
|
||||
- The check-and-debit pair is atomic within the lock — no TOCTOU window.
|
||||
- No phantom airtime on send failure — `record_tx()` is only called on success.
|
||||
- One `asyncio.Lock` object, no new state machines or compensating paths.
|
||||
- The lock is `async`, so it only blocks other TX tasks, not the event loop or
|
||||
the packet RX queue.
|
||||
|
||||
### Why `asyncio.Lock` rather than `threading.Lock`
|
||||
|
||||
The entire repeater runs on a single asyncio event loop. `asyncio.Lock` only
|
||||
yields at `await` points; it does not involve OS threads or context switches.
|
||||
A `threading.Lock` would work but is semantically wrong here (this is not a
|
||||
thread-safety problem) and would block the event loop thread if held across an
|
||||
`await`.
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### `repeater/engine.py`
|
||||
|
||||
**1. Move `import random` to module level**
|
||||
|
||||
```python
|
||||
# before (inside _calculate_tx_delay):
|
||||
def _calculate_tx_delay(self, packet, snr=0.0):
|
||||
import random
|
||||
...
|
||||
|
||||
# after (top of file, with other stdlib imports):
|
||||
import random
|
||||
```
|
||||
|
||||
This is a housekeeping fix bundled with this PR because `random` is a stdlib
|
||||
module that should never be imported inside a hot-path function — Python caches
|
||||
the import after the first call, but the attribute lookup and cache check still
|
||||
run on every call. Moving it to module level is the standard pattern.
|
||||
|
||||
**2. Add `self._tx_lock` to `__init__`**
|
||||
|
||||
```python
|
||||
# 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()
|
||||
```
|
||||
|
||||
**3. Acquire lock inside `delayed_send`, add authoritative duty-cycle gate**
|
||||
|
||||
```python
|
||||
async def delayed_send():
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Acquire the TX lock *after* the delay so that delay timers for
|
||||
# multiple packets still run concurrently (matching firmware). Only
|
||||
# one coroutine enters the radio send path at a time.
|
||||
async with self._tx_lock:
|
||||
# ── Authoritative duty-cycle gate ─────────────────────────────
|
||||
# The upfront can_transmit() call in __call__ is advisory: it
|
||||
# avoids scheduling packets that are obviously over budget, but
|
||||
# it cannot prevent a race between two tasks whose delay timers
|
||||
# expire at almost the same moment. Both tasks pass the advisory
|
||||
# check before either has recorded its airtime, then both try to
|
||||
# transmit.
|
||||
#
|
||||
# Inside _tx_lock only one task runs at a time, so airtime state
|
||||
# is stable here. The check and the subsequent record_tx() are
|
||||
# effectively atomic — no TOCTOU window.
|
||||
if airtime_ms > 0:
|
||||
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx_now:
|
||||
logger.warning(
|
||||
"Packet dropped at TX time: duty-cycle exceeded "
|
||||
"(airtime=%.1fms)", airtime_ms,
|
||||
)
|
||||
return
|
||||
|
||||
last_error = None
|
||||
for attempt in range(2 if local_transmission else 1):
|
||||
try:
|
||||
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
||||
self._record_packet_sent(fwd_pkt)
|
||||
if airtime_ms > 0:
|
||||
self.airtime_mgr.record_tx(airtime_ms)
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invariants Maintained
|
||||
|
||||
| Property | Before | After |
|
||||
|----------|--------|-------|
|
||||
| Delay timers run concurrently | ✅ | ✅ |
|
||||
| Radio accessed by one task at a time | ❌ | ✅ |
|
||||
| Duty-cycle check and debit atomic | ❌ | ✅ |
|
||||
| Airtime recorded only on TX success | ✅ | ✅ |
|
||||
| Event loop not blocked by lock | ✅ | ✅ (asyncio.Lock) |
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests (can run without hardware)
|
||||
|
||||
**T1 — Serial TX ordering**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
async def test_tx_serialized():
|
||||
"""Two tasks whose delays expire simultaneously must not interleave."""
|
||||
send_order = []
|
||||
send_lock = asyncio.Lock()
|
||||
|
||||
async def mock_send(pkt, **kw):
|
||||
# Confirm the _tx_lock is already held when we enter send_packet
|
||||
assert send_lock.locked(), "send_packet called without _tx_lock held"
|
||||
send_order.append(pkt)
|
||||
await asyncio.sleep(0) # yield; a second task must not enter here
|
||||
|
||||
engine._tx_lock = send_lock # replace with the mock lock reference
|
||||
engine.dispatcher.send_packet = mock_send
|
||||
|
||||
t1 = asyncio.create_task(engine.schedule_retransmit(pkt_a, delay=0.01, airtime_ms=100))
|
||||
t2 = asyncio.create_task(engine.schedule_retransmit(pkt_b, delay=0.01, airtime_ms=100))
|
||||
await asyncio.gather(t1, t2)
|
||||
|
||||
assert len(send_order) == 2 # both transmitted
|
||||
assert send_order[0] is not send_order[1] # different packets
|
||||
```
|
||||
|
||||
**T2 — Authoritative duty-cycle gate blocks over-budget second packet**
|
||||
|
||||
```python
|
||||
async def test_second_packet_dropped_when_over_budget():
|
||||
"""When first TX fills the budget, second task must be dropped inside the lock."""
|
||||
# Set a tiny budget: 50ms per minute
|
||||
engine.airtime_mgr.max_airtime_per_minute = 50
|
||||
|
||||
sent = []
|
||||
async def mock_send(pkt, **kw):
|
||||
sent.append(pkt)
|
||||
|
||||
engine.dispatcher.send_packet = mock_send
|
||||
|
||||
# Each packet costs ~111ms (SF8, BW125, 30-byte payload) — first passes, second must not
|
||||
t1 = asyncio.create_task(engine.schedule_retransmit(pkt_a, delay=0.01, airtime_ms=111))
|
||||
t2 = asyncio.create_task(engine.schedule_retransmit(pkt_b, delay=0.01, airtime_ms=111))
|
||||
await asyncio.gather(t1, t2)
|
||||
|
||||
assert len(sent) == 1, f"Expected 1 TX, got {len(sent)}"
|
||||
```
|
||||
|
||||
**T3 — Airtime not debited on TX failure**
|
||||
|
||||
```python
|
||||
async def test_airtime_not_recorded_on_send_failure():
|
||||
before = engine.airtime_mgr.total_airtime_ms
|
||||
|
||||
async def failing_send(pkt, **kw):
|
||||
raise RuntimeError("radio error")
|
||||
|
||||
engine.dispatcher.send_packet = failing_send
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await engine.schedule_retransmit(pkt, delay=0, airtime_ms=100)
|
||||
|
||||
assert engine.airtime_mgr.total_airtime_ms == before, \
|
||||
"Airtime must not be recorded when send raises"
|
||||
```
|
||||
|
||||
**T4 — Advisory check still drops before scheduling (fast path not regressed)**
|
||||
|
||||
```python
|
||||
async def test_advisory_check_still_drops_obvious_overage():
|
||||
"""__call__ should not even schedule a task when clearly over budget."""
|
||||
engine.airtime_mgr.max_airtime_per_minute = 0 # budget exhausted
|
||||
|
||||
tasks_created = []
|
||||
original = asyncio.create_task
|
||||
asyncio.create_task = lambda coro: tasks_created.append(coro) or original(coro)
|
||||
|
||||
await engine(over_budget_packet, metadata={})
|
||||
|
||||
assert not tasks_created, "No task should be created when advisory check fails"
|
||||
```
|
||||
|
||||
### Integration / field tests (with hardware)
|
||||
|
||||
**T5 — Burst scenario: 5 packets arrive within the same delay window**
|
||||
|
||||
1. Connect the repeater to a radio.
|
||||
2. Using a second node, send 5 FLOOD packets in quick succession (< 100 ms apart)
|
||||
with a low RSSI score so the repeater's delay is ~1–2 s for all of them.
|
||||
3. Monitor the radio with a spectrum analyser or a third node running in monitor
|
||||
mode.
|
||||
|
||||
**Expected (after this fix):**
|
||||
- Transmissions are sequential — no overlapping on-air signals.
|
||||
- `Retransmitted packet` log lines appear one after another, each with a non-zero
|
||||
airtime value.
|
||||
- No `Retransmit failed` errors in the log.
|
||||
- Duty-cycle log shows airtime accumulating correctly.
|
||||
|
||||
**Expected (before this fix, to confirm the bug existed):**
|
||||
- Occasional `Retransmit failed` errors under burst load.
|
||||
- Airtime tracking diverging from actual on-air time (double-counted or missed).
|
||||
|
||||
**T6 — Duty-cycle enforcement under burst**
|
||||
|
||||
1. Set `max_airtime_per_minute` to a low value (e.g. 500 ms) in config.
|
||||
2. Send 10 packets rapidly so the repeater tries to forward all 10.
|
||||
3. Observe logs.
|
||||
|
||||
**Expected:**
|
||||
- First N packets transmitted (total airtime ≤ 500 ms).
|
||||
- Subsequent packets log `"Packet dropped at TX time: duty-cycle exceeded"` from
|
||||
inside `delayed_send` (not just the advisory drop).
|
||||
- `airtime_mgr.get_stats()["utilization_percent"]` reads ≤ 100%.
|
||||
|
||||
**T7 — Normal single-packet forwarding not regressed**
|
||||
|
||||
1. Send one packet every 5 seconds (well within duty-cycle budget).
|
||||
2. Verify each packet is forwarded with correct airtime logged.
|
||||
3. Verify no lock contention warnings in the log.
|
||||
|
||||
**T8 — Local TX retry path (local_transmission=True) still works**
|
||||
|
||||
1. Send a command that triggers a local transmission (e.g. a ping reply).
|
||||
2. Briefly block the radio (simulate with a mock) so the first attempt fails.
|
||||
3. Verify the retry fires after 1 s and the packet is eventually transmitted.
|
||||
|
||||
---
|
||||
|
||||
## Proof of Correctness
|
||||
|
||||
### Why `asyncio.Lock` is sufficient (no OS-level synchronisation needed)
|
||||
|
||||
Python's asyncio event loop is **single-threaded**. All coroutines share one
|
||||
thread and only yield execution at `await` points. Between two consecutive
|
||||
`await` calls in a coroutine, the event loop does not switch to another coroutine.
|
||||
|
||||
`asyncio.Lock.acquire()` suspends the current coroutine if the lock is held,
|
||||
returning control to the event loop. `asyncio.Lock.release()` wakes the next
|
||||
waiter. Because `send_packet` is awaited inside the lock, no other TX task can
|
||||
run until the current one releases the lock and the event loop gets a chance to
|
||||
schedule the next waiter.
|
||||
|
||||
There is no possibility of the race seen with `threading.Lock` where an OS thread
|
||||
can be preempted mid-instruction.
|
||||
|
||||
### Why the advisory check in `__call__` cannot be removed
|
||||
|
||||
The advisory check is still necessary as a fast path. If it were removed, every
|
||||
incoming packet — even when the node is clearly at 100% duty-cycle — would
|
||||
schedule a `delayed_send` task that would sleep for the full TX delay (up to
|
||||
several seconds) before the lock drops it. Under a sustained flood of incoming
|
||||
packets this wastes memory and CPU. The advisory check prunes the queue early at
|
||||
negligible cost.
|
||||
|
||||
### Why `record_tx()` must be inside the lock (not before or after)
|
||||
|
||||
- **Before the send:** records airtime for a packet that may never be transmitted
|
||||
(send could fail, LBT could reject it). Budget is overcounted.
|
||||
- **After releasing the lock:** a second task could pass the authoritative
|
||||
`can_transmit()` check between `send_packet` returning and `record_tx()` being
|
||||
called — the TOCTOU window reopens at a smaller scale.
|
||||
- **Inside the lock, after a successful send:** the budget is debited exactly once
|
||||
for exactly the packets that were actually transmitted. The lock ensures no
|
||||
other task reads airtime state between the check and the debit.
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"default_board": "luckfox-pimesh-v2",
|
||||
"default_radio_preset": "USA/Canada (Recommended)",
|
||||
"buildroot_hardware": {
|
||||
"luckfox-pimesh-v2": {
|
||||
"name": "Luckfox PiMesh V2",
|
||||
"description": "Luckfox Pico Pi with PiMesh-1W V2 / E22P wiring",
|
||||
"hardware_id": "pimesh-1w-v2",
|
||||
"tx_power": 22,
|
||||
"aliases": [
|
||||
"1",
|
||||
"v2",
|
||||
"pimesh-v2",
|
||||
"pimesh-1w-v2"
|
||||
],
|
||||
"sx1262_overrides": {
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 54,
|
||||
"busy_pin": 122,
|
||||
"irq_pin": 121,
|
||||
"en_pin": 0,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8
|
||||
}
|
||||
},
|
||||
"luckfox-pimesh-v1": {
|
||||
"name": "Luckfox PiMesh V1",
|
||||
"description": "Luckfox Pico Pi with PiMesh-1W V1 wiring",
|
||||
"hardware_id": "pimesh-1w-v1",
|
||||
"tx_power": 22,
|
||||
"aliases": [
|
||||
"2",
|
||||
"v1",
|
||||
"pimesh-v1",
|
||||
"pimesh-1w-v1"
|
||||
],
|
||||
"sx1262_overrides": {
|
||||
"cs_pin": 145,
|
||||
"reset_pin": 54,
|
||||
"busy_pin": 123,
|
||||
"irq_pin": 55,
|
||||
"en_pin": -1,
|
||||
"txen_pin": 52,
|
||||
"rxen_pin": 53,
|
||||
"use_dio2_rf": false,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8
|
||||
}
|
||||
},
|
||||
"luckfox-meshadv": {
|
||||
"name": "Luckfox MeshAdv",
|
||||
"description": "Luckfox Pico Pi with MeshAdv wiring",
|
||||
"hardware_id": "meshadv",
|
||||
"tx_power": 22,
|
||||
"aliases": [
|
||||
"3",
|
||||
"meshadv"
|
||||
],
|
||||
"sx1262_overrides": {
|
||||
"cs_pin": 145,
|
||||
"reset_pin": 54,
|
||||
"busy_pin": 123,
|
||||
"irq_pin": 55,
|
||||
"en_pin": -1,
|
||||
"txen_pin": 52,
|
||||
"rxen_pin": 53,
|
||||
"use_dio2_rf": false,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-18
@@ -11,13 +11,13 @@ logger = logging.getLogger("Config")
|
||||
|
||||
def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract node name, radio configuration, and LetsMesh settings from config.
|
||||
Extract node name, radio configuration, and MQTT settings from config.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Dictionary with node_name, radio_config, and LetsMesh configuration
|
||||
Dictionary with node_name, radio_config, and MQTT configuration
|
||||
"""
|
||||
node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater")
|
||||
radio_config = config.get("radio", {})
|
||||
@@ -30,26 +30,17 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
radio_bw_khz = radio_bw / 1_000
|
||||
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"
|
||||
|
||||
letsmesh_config = config.get("letsmesh", {})
|
||||
|
||||
from pymc_core.protocol.utils import PAYLOAD_TYPES
|
||||
|
||||
disallowed_types = letsmesh_config.get("disallowed_packet_types", [])
|
||||
type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
|
||||
|
||||
disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
|
||||
disallowed_hex = [val for val in disallowed_hex if val is not None] # Filter out invalid names
|
||||
# Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist
|
||||
mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {}))
|
||||
|
||||
return {
|
||||
"node_name": node_name,
|
||||
"radio_config": radio_config_str,
|
||||
"iata_code": letsmesh_config.get("iata_code", "TEST"),
|
||||
"broker_index": letsmesh_config.get("broker_index", 0),
|
||||
"status_interval": letsmesh_config.get("status_interval", 60),
|
||||
"model": letsmesh_config.get("model", "PyMC-Repeater"),
|
||||
"disallowed_packet_types": disallowed_hex,
|
||||
"email": letsmesh_config.get("email", ""),
|
||||
"owner": letsmesh_config.get("owner", ""),
|
||||
"iata_code": mqtt_config.get("iata_code", "TEST"),
|
||||
"status_interval": mqtt_config.get("status_interval", 60),
|
||||
"model": mqtt_config.get("model", "PyMC-Repeater"),
|
||||
"email": mqtt_config.get("email", ""),
|
||||
"owner": mqtt_config.get("owner", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .glass_handler import GlassHandler
|
||||
from .mqtt_handler import MQTTHandler
|
||||
from .rrdtool_handler import RRDToolHandler
|
||||
from .sqlite_handler import SQLiteHandler
|
||||
from .storage_collector import StorageCollector
|
||||
__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector", "GlassHandler"]
|
||||
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler"]
|
||||
|
||||
@@ -96,6 +96,7 @@ class _BrokerConnection:
|
||||
self._reconnect_timer = None
|
||||
self._max_reconnect_delay = 300 # 5 minutes max
|
||||
self._jwt_refresh_timer = None
|
||||
self._shutdown_requested = False
|
||||
client_id = f"meshcore_{self.public_key}_{broker['host']}"
|
||||
self.client = mqtt.Client(client_id=client_id, transport="websockets")
|
||||
self.client.on_connect = self._on_connect
|
||||
@@ -163,6 +164,12 @@ class _BrokerConnection:
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
|
||||
if self._shutdown_requested:
|
||||
logger.info(f"Clean disconnect from {self.broker['name']}")
|
||||
if self._on_disconnect_callback:
|
||||
self._on_disconnect_callback(self.broker["name"])
|
||||
return
|
||||
|
||||
if rc != 0: # Unexpected disconnect
|
||||
error_msg = get_mqtt_error_message(rc, is_disconnect=True)
|
||||
logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
|
||||
@@ -176,6 +183,9 @@ class _BrokerConnection:
|
||||
|
||||
def _schedule_reconnect(self, reason: str = "connection lost"):
|
||||
"""Schedule reconnection with exponential backoff"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
if self._reconnect_timer:
|
||||
self._reconnect_timer.cancel()
|
||||
|
||||
@@ -192,13 +202,16 @@ class _BrokerConnection:
|
||||
|
||||
def _attempt_reconnect(self, reason: str = "connection lost"):
|
||||
"""Attempt to reconnect to broker with fresh JWT"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
|
||||
|
||||
# Stop the loop if it's still running (websocket mode requires clean restart)
|
||||
try:
|
||||
self.client.loop_stop()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._set_jwt_credentials()
|
||||
@@ -227,6 +240,8 @@ class _BrokerConnection:
|
||||
|
||||
def connect(self):
|
||||
"""Establish connection to broker"""
|
||||
self._shutdown_requested = False
|
||||
|
||||
# Conditional TLS setup
|
||||
if self.use_tls:
|
||||
import ssl
|
||||
@@ -252,6 +267,7 @@ class _BrokerConnection:
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from broker"""
|
||||
self._shutdown_requested = True
|
||||
self._running = False
|
||||
self._loop_running = False
|
||||
|
||||
@@ -407,7 +423,9 @@ class MeshCoreToMqttJwtPusher:
|
||||
self.stats_provider = stats_provider
|
||||
self._status_task = None
|
||||
self._running = False
|
||||
self._shutdown_requested = False
|
||||
self._lock = threading.Lock()
|
||||
self._connect_timers: List[threading.Timer] = []
|
||||
|
||||
# Create broker connections
|
||||
self.connections: List[_BrokerConnection] = []
|
||||
@@ -431,6 +449,9 @@ class MeshCoreToMqttJwtPusher:
|
||||
|
||||
def _on_broker_connected(self, broker_name: str):
|
||||
"""Callback when a broker connects"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
# Publish initial status on first connection
|
||||
if not self._status_task and self.status_interval > 0:
|
||||
self._running = True
|
||||
@@ -455,6 +476,9 @@ class MeshCoreToMqttJwtPusher:
|
||||
|
||||
def connect(self):
|
||||
"""Establish connections to all configured brokers"""
|
||||
self._shutdown_requested = False
|
||||
self._connect_timers = []
|
||||
|
||||
for idx, conn in enumerate(self.connections):
|
||||
try:
|
||||
if idx == 0:
|
||||
@@ -467,11 +491,15 @@ class MeshCoreToMqttJwtPusher:
|
||||
timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self._connect_timers.append(timer)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
|
||||
|
||||
def _delayed_connect(self, conn):
|
||||
"""Connect a broker after a delay (called by timer)"""
|
||||
if self._shutdown_requested:
|
||||
return
|
||||
|
||||
try:
|
||||
conn.connect()
|
||||
except Exception as e:
|
||||
@@ -479,15 +507,24 @@ class MeshCoreToMqttJwtPusher:
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from all brokers"""
|
||||
self._shutdown_requested = True
|
||||
|
||||
# Cancel any delayed connect timers first.
|
||||
for timer in self._connect_timers:
|
||||
try:
|
||||
timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._connect_timers = []
|
||||
|
||||
# Stop the heartbeat loop
|
||||
self._running = False
|
||||
|
||||
# Publish offline status before disconnecting
|
||||
self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(0.5) # Give time for messages to be sent
|
||||
try:
|
||||
self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Disconnect all brokers
|
||||
for conn in self.connections:
|
||||
@@ -496,6 +533,7 @@ class MeshCoreToMqttJwtPusher:
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting from {conn.broker['name']}: {e}")
|
||||
|
||||
self._status_task = None
|
||||
logger.info("Disconnected from all brokers")
|
||||
|
||||
def _status_heartbeat_loop(self):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,14 @@ class RRDToolHandler:
|
||||
self.rrd_path = self.storage_dir / "metrics.rrd"
|
||||
self.available = RRDTOOL_AVAILABLE
|
||||
self._init_rrd()
|
||||
# Timestamp of the last successful rrdtool.update() call (unix seconds,
|
||||
# aligned to the 60-second RRD step). Used to skip writes whose period
|
||||
# has already been committed — no rrdtool.info() call needed.
|
||||
self._last_rrd_update: int = 0
|
||||
# Read-side cache: rrdtool.fetch() returns 24 h of data and is a
|
||||
# blocking disk read. Cache the result for 60 s — matching the RRD
|
||||
# step size — so repeated dashboard refreshes don't hammer the SD card.
|
||||
self._get_data_cache: tuple = (0.0, None) # (fetched_at, result)
|
||||
|
||||
def _init_rrd(self):
|
||||
if not self.available:
|
||||
@@ -73,20 +81,23 @@ class RRDToolHandler:
|
||||
logger.error(f"Failed to create RRD database: {e}")
|
||||
|
||||
def update_packet_metrics(self, record: dict, cumulative_counts: dict):
|
||||
"""Write packet metrics to RRD, throttled to once per 60-second step.
|
||||
|
||||
RRD enforces a 60-second minimum step between updates. We track the
|
||||
last written timestamp ourselves — no rrdtool.info() call needed, which
|
||||
previously allocated thousands of Python objects per call.
|
||||
"""
|
||||
if not self.available or not self.rrd_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
timestamp = int(record.get("timestamp", time.time()))
|
||||
|
||||
try:
|
||||
info = rrdtool.info(str(self.rrd_path))
|
||||
last_update = int(info.get("last_update", timestamp - 60))
|
||||
if timestamp <= last_update:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get RRD info for packet update: {e}")
|
||||
# Skip if this packet falls in the same 60-second period we already wrote.
|
||||
if timestamp <= self._last_rrd_update:
|
||||
return
|
||||
|
||||
# Build update string from cumulative counts
|
||||
rx_total = cumulative_counts.get("rx_total", 0)
|
||||
tx_total = cumulative_counts.get("tx_total", 0)
|
||||
drop_total = cumulative_counts.get("drop_total", 0)
|
||||
@@ -97,7 +108,6 @@ class RRDToolHandler:
|
||||
type_values.append(str(type_counts.get(f"type_{i}", 0)))
|
||||
type_values.append(str(type_counts.get("type_other", 0)))
|
||||
|
||||
# Handle None values for TX packets - use 'U' (unknown) for RRD
|
||||
rssi = record.get("rssi")
|
||||
snr = record.get("snr")
|
||||
score = record.get("score")
|
||||
@@ -117,6 +127,7 @@ class RRDToolHandler:
|
||||
values = f"{basic_values}:{type_values_str}"
|
||||
|
||||
rrdtool.update(str(self.rrd_path), values)
|
||||
self._last_rrd_update = timestamp
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update RRD packet metrics: {e}")
|
||||
@@ -134,9 +145,20 @@ class RRDToolHandler:
|
||||
)
|
||||
return None
|
||||
|
||||
# Serve from cache if result is still fresh. RRD step is 60 s, so
|
||||
# anything newer than that is guaranteed to be identical to a live fetch.
|
||||
# Only the default (full 24-hour, no explicit bounds) call is cached —
|
||||
# explicit start/end requests always bypass the cache.
|
||||
now = time.time()
|
||||
use_cache = start_time is None and end_time is None
|
||||
if use_cache:
|
||||
cache_fetched_at, cache_result = self._get_data_cache
|
||||
if now - cache_fetched_at < 60.0 and cache_result is not None:
|
||||
return cache_result
|
||||
|
||||
try:
|
||||
if end_time is None:
|
||||
end_time = int(time.time())
|
||||
end_time = int(now)
|
||||
if start_time is None:
|
||||
start_time = end_time - (24 * 3600)
|
||||
|
||||
@@ -192,6 +214,10 @@ class RRDToolHandler:
|
||||
|
||||
result["timestamps"] = timestamps
|
||||
|
||||
# Populate read cache for default (unconstrained) calls only.
|
||||
if use_cache:
|
||||
self._get_data_cache = (now, result)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -14,16 +15,61 @@ class SQLiteHandler:
|
||||
def __init__(self, storage_dir: Path):
|
||||
self.storage_dir = storage_dir
|
||||
self.sqlite_path = self.storage_dir / "repeater.db"
|
||||
self._api_token_last_used_updates = {}
|
||||
self._api_token_last_used_interval_sec = 300
|
||||
self._hot_cache_ttl_sec = 60
|
||||
self._packet_stats_cache = {}
|
||||
self._neighbors_cache = {"timestamp": 0.0, "value": None}
|
||||
# Thread-local storage for persistent SQLite connections.
|
||||
# Opening a new connection on every DB call is expensive on SD-card
|
||||
# storage: each sqlite3.connect() call triggers file-system operations
|
||||
# and each subsequent PRAGMA runs as a round-trip. Thread-local keeps
|
||||
# one long-lived connection per thread (typically one for the write
|
||||
# executor and one for the event-loop / HTTP threads), eliminating
|
||||
# repeated setup overhead while maintaining correct isolation.
|
||||
self._local = threading.local()
|
||||
self._init_database()
|
||||
self._run_migrations()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
"""Create a connection with WAL mode and busy timeout to avoid 'database is locked' errors."""
|
||||
conn = sqlite3.connect(self.sqlite_path, timeout=30)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
"""Return a persistent thread-local SQLite connection.
|
||||
|
||||
The first call from a given thread opens the connection and configures
|
||||
it once. Subsequent calls from the same thread return the cached
|
||||
connection, avoiding per-call connection overhead and repeated PRAGMA
|
||||
round-trips.
|
||||
|
||||
WAL (Write-Ahead Logging) mode:
|
||||
Default journal mode (DELETE) takes an exclusive lock for every write,
|
||||
blocking all readers. WAL allows one writer and multiple readers to
|
||||
operate concurrently — critical on SD-card storage where a single
|
||||
write can take 5–20 ms.
|
||||
|
||||
synchronous=NORMAL:
|
||||
Default FULL flushes WAL frames to disk after every transaction.
|
||||
NORMAL flushes only at WAL checkpoints — safe (no data loss on power
|
||||
failure beyond the current transaction) and significantly faster on
|
||||
SD cards, which have slow fsync.
|
||||
|
||||
busy_timeout=5000:
|
||||
Under concurrent access SQLite would immediately raise
|
||||
'database is locked'. 5 s of automatic retry eliminates transient
|
||||
contention errors when the write executor and the HTTP thread
|
||||
briefly compete for the WAL write lock.
|
||||
"""
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is None:
|
||||
conn = sqlite3.connect(str(self.sqlite_path))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._local.conn = conn
|
||||
return conn
|
||||
|
||||
def _invalidate_hot_caches(self) -> None:
|
||||
self._packet_stats_cache.clear()
|
||||
self._neighbors_cache = {"timestamp": 0.0, "value": None}
|
||||
|
||||
def _init_database(self):
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
@@ -470,6 +516,57 @@ class SQLiteHandler:
|
||||
)
|
||||
logger.info(f"Migration '{migration_name}' applied successfully")
|
||||
|
||||
# Migration 8: UNIQUE index on companion_messages for dedup by
|
||||
# (companion_hash, packet_hash). Enables INSERT OR IGNORE
|
||||
# deduplication in companion_push_message, replacing the
|
||||
# Python-level SELECT + INSERT round-trip.
|
||||
migration_name = "companion_messages_packet_hash_unique"
|
||||
existing = conn.execute(
|
||||
"SELECT migration_name FROM migrations WHERE migration_name = ?",
|
||||
(migration_name,),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_companion_messages_dedup
|
||||
ON companion_messages(companion_hash, packet_hash)
|
||||
WHERE packet_hash IS NOT NULL
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
|
||||
(migration_name, time.time()),
|
||||
)
|
||||
logger.info(f"Migration '{migration_name}' applied successfully")
|
||||
|
||||
# Migration 9: Deduplicate adverts and enforce UNIQUE on pubkey.
|
||||
# Without this index store_advert's ON CONFLICT clause cannot
|
||||
# function and each advert inserts a new row instead of updating
|
||||
# the existing one, causing unbounded table growth on busy meshes.
|
||||
migration_name = "adverts_unique_pubkey"
|
||||
existing = conn.execute(
|
||||
"SELECT migration_name FROM migrations WHERE migration_name = ?",
|
||||
(migration_name,),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
# Keep only the most recently seen row per pubkey
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM adverts WHERE id NOT IN (
|
||||
SELECT MAX(id) FROM adverts GROUP BY pubkey
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP INDEX IF EXISTS idx_adverts_pubkey")
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_adverts_pubkey ON adverts(pubkey)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
|
||||
(migration_name, time.time()),
|
||||
)
|
||||
logger.info(f"Migration '{migration_name}' applied successfully")
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
@@ -494,19 +591,23 @@ class SQLiteHandler:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, name, created_at FROM api_tokens WHERE token_hash = ?",
|
||||
"SELECT id, name, created_at, last_used FROM api_tokens WHERE token_hash = ?",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
token_id, name, created_at = row
|
||||
token_id, name, created_at, _last_used = row
|
||||
now = time.time()
|
||||
|
||||
# Update last_used timestamp
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used = ? WHERE id = ?", (time.time(), token_id)
|
||||
)
|
||||
conn.commit()
|
||||
# Throttle last_used updates to reduce write-lock contention.
|
||||
last_update = self._api_token_last_used_updates.get(token_id, 0.0)
|
||||
if now - last_update >= self._api_token_last_used_interval_sec:
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used = ? WHERE id = ?", (now, token_id)
|
||||
)
|
||||
conn.commit()
|
||||
self._api_token_last_used_updates[token_id] = now
|
||||
|
||||
return {"id": token_id, "name": name, "created_at": created_at}
|
||||
return None
|
||||
@@ -598,6 +699,7 @@ class SQLiteHandler:
|
||||
int(bool(record.get("lbt_channel_busy", False))),
|
||||
),
|
||||
)
|
||||
self._invalidate_hot_caches()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store packet in SQLite: {e}")
|
||||
@@ -687,6 +789,8 @@ class SQLiteHandler:
|
||||
),
|
||||
)
|
||||
|
||||
self._invalidate_hot_caches()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store advert in SQLite: {e}")
|
||||
|
||||
@@ -754,7 +858,12 @@ class SQLiteHandler:
|
||||
|
||||
def get_packet_stats(self, hours: int = 24) -> dict:
|
||||
try:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
now = time.time()
|
||||
cached = self._packet_stats_cache.get(hours)
|
||||
if cached and (now - cached["timestamp"]) < self._hot_cache_ttl_sec:
|
||||
return cached["value"]
|
||||
|
||||
cutoff = now - (hours * 3600)
|
||||
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -798,7 +907,7 @@ class SQLiteHandler:
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
result = {
|
||||
"total_packets": stats["total_packets"],
|
||||
"transmitted_packets": stats["transmitted_packets"],
|
||||
"dropped_packets": stats["dropped_packets"],
|
||||
@@ -814,6 +923,9 @@ class SQLiteHandler:
|
||||
],
|
||||
}
|
||||
|
||||
self._packet_stats_cache[hours] = {"timestamp": now, "value": result}
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get packet stats: {e}")
|
||||
return {}
|
||||
@@ -828,9 +940,9 @@ class SQLiteHandler:
|
||||
SELECT
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
header, transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
|
||||
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
|
||||
transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path,
|
||||
lbt_attempts, lbt_channel_busy
|
||||
FROM packets
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
@@ -880,9 +992,9 @@ class SQLiteHandler:
|
||||
SELECT
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
header, transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
|
||||
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
|
||||
transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path,
|
||||
lbt_attempts, lbt_channel_busy
|
||||
FROM packets
|
||||
"""
|
||||
|
||||
@@ -931,6 +1043,71 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get airtime data: {e}")
|
||||
return []
|
||||
|
||||
def get_airtime_buckets(
|
||||
self,
|
||||
start_timestamp: float,
|
||||
end_timestamp: float,
|
||||
bucket_seconds: int = 60,
|
||||
sf: int = 9,
|
||||
bw_hz: int = 62500,
|
||||
cr: int = 5,
|
||||
preamble: int = 17,
|
||||
) -> list:
|
||||
"""Return pre-aggregated airtime buckets for chart rendering.
|
||||
|
||||
Applies the Semtech LoRa airtime formula server-side and groups results
|
||||
into time buckets, drastically reducing response size vs raw packet rows.
|
||||
"""
|
||||
import math
|
||||
|
||||
bw_khz = bw_hz / 1000
|
||||
t_sym = (2**sf) / bw_khz # ms per symbol
|
||||
t_preamble = (preamble + 4.25) * t_sym
|
||||
de = 1 if sf >= 11 and bw_hz <= 125000 else 0
|
||||
|
||||
def _airtime_ms(length_bytes: int) -> float:
|
||||
length_bytes = max(length_bytes or 32, 1)
|
||||
numerator = max(8 * length_bytes - 4 * sf + 28 + 16, 0) # CRC=1, H=0
|
||||
denominator = 4 * (sf - 2 * de)
|
||||
n_payload = 8 + math.ceil(numerator / denominator) * cr
|
||||
return t_preamble + n_payload * t_sym
|
||||
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT timestamp, length, transmitted FROM packets "
|
||||
"WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
|
||||
(start_timestamp, end_timestamp),
|
||||
).fetchall()
|
||||
|
||||
buckets: dict = {}
|
||||
rx_total = 0
|
||||
tx_total = 0
|
||||
for row in rows:
|
||||
bucket_ts = int(row["timestamp"] / bucket_seconds) * bucket_seconds
|
||||
ms = _airtime_ms(row["length"])
|
||||
if bucket_ts not in buckets:
|
||||
buckets[bucket_ts] = {"timestamp": bucket_ts, "rx_ms": 0.0, "tx_ms": 0.0, "rx_count": 0, "tx_count": 0}
|
||||
if row["transmitted"]:
|
||||
buckets[bucket_ts]["tx_ms"] += ms
|
||||
buckets[bucket_ts]["tx_count"] += 1
|
||||
tx_total += 1
|
||||
else:
|
||||
buckets[bucket_ts]["rx_ms"] += ms
|
||||
buckets[bucket_ts]["rx_count"] += 1
|
||||
rx_total += 1
|
||||
|
||||
return {
|
||||
"buckets": sorted(buckets.values(), key=lambda x: x["timestamp"]),
|
||||
"bucket_seconds": bucket_seconds,
|
||||
"rx_total": rx_total,
|
||||
"tx_total": tx_total,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get airtime buckets: {e}")
|
||||
return {"buckets": [], "bucket_seconds": bucket_seconds, "rx_total": 0, "tx_total": 0}
|
||||
|
||||
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
@@ -1009,20 +1186,27 @@ class SQLiteHandler:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
type_rows = conn.execute(
|
||||
"""
|
||||
SELECT type, COUNT(*) as count
|
||||
FROM packets
|
||||
WHERE timestamp > ?
|
||||
GROUP BY type
|
||||
""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
type_counts = {}
|
||||
for packet_type in range(16):
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE type = ? AND timestamp > ?",
|
||||
(packet_type, cutoff),
|
||||
).fetchone()[0]
|
||||
|
||||
type_name = packet_type_names.get(packet_type, f"Type {packet_type}")
|
||||
if count > 0:
|
||||
other_count = 0
|
||||
for row in type_rows:
|
||||
pkt_type = int(row["type"])
|
||||
count = int(row["count"])
|
||||
if pkt_type <= 15:
|
||||
type_name = packet_type_names.get(pkt_type, f"Type {pkt_type}")
|
||||
type_counts[type_name] = count
|
||||
else:
|
||||
other_count += count
|
||||
|
||||
other_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE type > 15 AND timestamp > ?", (cutoff,)
|
||||
).fetchone()[0]
|
||||
if other_count > 0:
|
||||
type_counts["Other Types (>15)"] = other_count
|
||||
|
||||
@@ -1046,23 +1230,29 @@ class SQLiteHandler:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
route_rows = conn.execute(
|
||||
"""
|
||||
SELECT route, COUNT(*) as count
|
||||
FROM packets
|
||||
WHERE timestamp > ?
|
||||
GROUP BY route
|
||||
""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
|
||||
route_counts = {}
|
||||
route_names = {0: "Transport Flood", 1: "Flood", 2: "Direct", 3: "Transport Direct"}
|
||||
other_count = 0
|
||||
|
||||
for route_type in range(4):
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE route = ? AND timestamp > ?",
|
||||
(route_type, cutoff),
|
||||
).fetchone()[0]
|
||||
|
||||
route_name = route_names.get(route_type, f"Route {route_type}")
|
||||
if count > 0:
|
||||
for row in route_rows:
|
||||
route_type = int(row["route"])
|
||||
count = int(row["count"])
|
||||
if route_type <= 3:
|
||||
route_name = route_names.get(route_type, f"Route {route_type}")
|
||||
route_counts[route_name] = count
|
||||
else:
|
||||
other_count += count
|
||||
|
||||
# Count any other route types > 3
|
||||
other_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE route > 3 AND timestamp > ?", (cutoff,)
|
||||
).fetchone()[0]
|
||||
if other_count > 0:
|
||||
route_counts["Other Routes (>3)"] = other_count
|
||||
|
||||
@@ -1080,6 +1270,12 @@ class SQLiteHandler:
|
||||
|
||||
def get_neighbors(self) -> dict:
|
||||
try:
|
||||
now = time.time()
|
||||
cached = self._neighbors_cache.get("value")
|
||||
cached_ts = float(self._neighbors_cache.get("timestamp", 0.0))
|
||||
if cached is not None and (now - cached_ts) < self._hot_cache_ttl_sec:
|
||||
return cached
|
||||
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
@@ -1087,12 +1283,14 @@ class SQLiteHandler:
|
||||
"""
|
||||
SELECT pubkey, node_name, is_repeater, route_type, contact_type,
|
||||
latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, zero_hop
|
||||
FROM adverts a1
|
||||
WHERE last_seen = (
|
||||
SELECT MAX(last_seen)
|
||||
FROM adverts a2
|
||||
WHERE a2.pubkey = a1.pubkey
|
||||
)
|
||||
FROM (
|
||||
SELECT
|
||||
pubkey, node_name, is_repeater, route_type, contact_type,
|
||||
latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, zero_hop,
|
||||
ROW_NUMBER() OVER (PARTITION BY pubkey ORDER BY last_seen DESC) AS rn
|
||||
FROM adverts
|
||||
) latest
|
||||
WHERE rn = 1
|
||||
ORDER BY last_seen DESC
|
||||
"""
|
||||
).fetchall()
|
||||
@@ -1114,6 +1312,7 @@ class SQLiteHandler:
|
||||
"zero_hop": bool(row["zero_hop"]),
|
||||
}
|
||||
|
||||
self._neighbors_cache = {"timestamp": now, "value": result}
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -1320,30 +1519,36 @@ class SQLiteHandler:
|
||||
def get_cumulative_counts(self) -> dict:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
type_counts = {}
|
||||
for i in range(16):
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE type = ?", (i,)
|
||||
).fetchone()[0]
|
||||
type_counts[f"type_{i}"] = count
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
other_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE type > 15"
|
||||
).fetchone()[0]
|
||||
type_counts["type_other"] = other_count
|
||||
type_rows = conn.execute(
|
||||
"SELECT type, COUNT(*) as count FROM packets GROUP BY type"
|
||||
).fetchall()
|
||||
|
||||
rx_total = conn.execute("SELECT COUNT(*) FROM packets").fetchone()[0]
|
||||
tx_total = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE transmitted = 1"
|
||||
).fetchone()[0]
|
||||
drop_total = conn.execute(
|
||||
"SELECT COUNT(*) FROM packets WHERE transmitted = 0"
|
||||
).fetchone()[0]
|
||||
type_counts = {f"type_{i}": 0 for i in range(16)}
|
||||
type_counts["type_other"] = 0
|
||||
for row in type_rows:
|
||||
pkt_type = int(row["type"])
|
||||
count = int(row["count"])
|
||||
if pkt_type <= 15:
|
||||
type_counts[f"type_{pkt_type}"] = count
|
||||
else:
|
||||
type_counts["type_other"] += count
|
||||
|
||||
totals = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS rx_total,
|
||||
SUM(CASE WHEN transmitted = 1 THEN 1 ELSE 0 END) AS tx_total,
|
||||
SUM(CASE WHEN transmitted = 0 THEN 1 ELSE 0 END) AS drop_total
|
||||
FROM packets
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
return {
|
||||
"rx_total": rx_total,
|
||||
"tx_total": tx_total,
|
||||
"drop_total": drop_total,
|
||||
"rx_total": int(totals["rx_total"] or 0),
|
||||
"tx_total": int(totals["tx_total"] or 0),
|
||||
"drop_total": int(totals["drop_total"] or 0),
|
||||
"type_counts": type_counts,
|
||||
}
|
||||
|
||||
@@ -1785,78 +1990,33 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get unsynced messages: {e}")
|
||||
return []
|
||||
|
||||
def get_unsynced_count(self, room_hash: str, client_pubkey: str, sync_since: float) -> int:
|
||||
"""Count unsynced messages for a client."""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM room_messages
|
||||
WHERE room_hash = ?
|
||||
AND post_timestamp > ?
|
||||
AND author_pubkey != ?
|
||||
""",
|
||||
(room_hash, sync_since, client_pubkey),
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to count unsynced messages: {e}")
|
||||
return 0
|
||||
|
||||
def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool:
|
||||
"""Insert or update client sync state."""
|
||||
"""Insert or update client sync state using single upsert operation."""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
# Check if exists
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT id FROM room_client_sync
|
||||
WHERE room_hash = ? AND client_pubkey = ?
|
||||
now = time.time()
|
||||
kwargs["updated_at"] = now
|
||||
|
||||
# Set defaults for insert path
|
||||
kwargs.setdefault("sync_since", 0)
|
||||
kwargs.setdefault("pending_ack_crc", 0)
|
||||
kwargs.setdefault("push_post_timestamp", 0)
|
||||
kwargs.setdefault("ack_timeout_time", 0)
|
||||
kwargs.setdefault("push_failures", 0)
|
||||
kwargs.setdefault("last_activity", now)
|
||||
|
||||
columns = ["room_hash", "client_pubkey"] + list(kwargs.keys())
|
||||
placeholders = ["?"] * len(columns)
|
||||
values = [room_hash, client_pubkey] + list(kwargs.values())
|
||||
|
||||
# Use INSERT OR REPLACE for single atomic upsert
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT OR REPLACE INTO room_client_sync ({', '.join(columns)})
|
||||
VALUES ({', '.join(placeholders)})
|
||||
""",
|
||||
(room_hash, client_pubkey),
|
||||
values,
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
|
||||
kwargs["updated_at"] = time.time()
|
||||
|
||||
if existing:
|
||||
# Update
|
||||
set_clauses = []
|
||||
values = []
|
||||
for key, value in kwargs.items():
|
||||
set_clauses.append(f"{key} = ?")
|
||||
values.append(value)
|
||||
values.extend([room_hash, client_pubkey])
|
||||
|
||||
conn.execute(
|
||||
f"""
|
||||
UPDATE room_client_sync
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE room_hash = ? AND client_pubkey = ?
|
||||
""",
|
||||
values,
|
||||
)
|
||||
else:
|
||||
# Insert with defaults
|
||||
kwargs.setdefault("sync_since", 0)
|
||||
kwargs.setdefault("pending_ack_crc", 0)
|
||||
kwargs.setdefault("push_post_timestamp", 0)
|
||||
kwargs.setdefault("ack_timeout_time", 0)
|
||||
kwargs.setdefault("push_failures", 0)
|
||||
kwargs.setdefault("last_activity", time.time())
|
||||
|
||||
columns = ["room_hash", "client_pubkey"] + list(kwargs.keys())
|
||||
placeholders = ["?"] * len(columns)
|
||||
values = [room_hash, client_pubkey] + list(kwargs.values())
|
||||
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO room_client_sync ({', '.join(columns)})
|
||||
VALUES ({', '.join(placeholders)})
|
||||
""",
|
||||
values,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -1955,7 +2115,13 @@ class SQLiteHandler:
|
||||
return []
|
||||
|
||||
def get_unsynced_count(self, room_hash: str, client_pubkey: str, sync_since: float) -> int:
|
||||
"""Get count of unsynced messages for a client."""
|
||||
"""Get count of unsynced messages for a client.
|
||||
|
||||
Note: a duplicate definition of this method existed earlier in the file
|
||||
with the same signature but reversed parameter-binding order in the SQL.
|
||||
Python silently uses the last definition; the first was dead code.
|
||||
The dead definition has been removed.
|
||||
"""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -2058,36 +2224,41 @@ class SQLiteHandler:
|
||||
return []
|
||||
|
||||
def companion_save_contacts(self, companion_hash: str, contacts: List[Dict]) -> bool:
|
||||
"""Replace all contacts for a companion in storage."""
|
||||
"""Replace all contacts for a companion in storage using batch insert."""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,)
|
||||
)
|
||||
now = time.time()
|
||||
for c in contacts:
|
||||
conn.execute(
|
||||
# Batch insert all contacts at once instead of loop-based inserts
|
||||
rows = [
|
||||
(
|
||||
companion_hash,
|
||||
c.get("pubkey", b""),
|
||||
c.get("name", ""),
|
||||
c.get("adv_type", 0),
|
||||
c.get("flags", 0),
|
||||
c.get("out_path_len", -1),
|
||||
c.get("out_path", b""),
|
||||
c.get("last_advert_timestamp", 0),
|
||||
c.get("lastmod", 0),
|
||||
c.get("gps_lat", 0.0),
|
||||
c.get("gps_lon", 0.0),
|
||||
c.get("sync_since", 0),
|
||||
now,
|
||||
)
|
||||
for c in contacts
|
||||
]
|
||||
if rows:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO companion_contacts
|
||||
(companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path,
|
||||
last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
companion_hash,
|
||||
c.get("pubkey", b""),
|
||||
c.get("name", ""),
|
||||
c.get("adv_type", 0),
|
||||
c.get("flags", 0),
|
||||
c.get("out_path_len", -1),
|
||||
c.get("out_path", b""),
|
||||
c.get("last_advert_timestamp", 0),
|
||||
c.get("lastmod", 0),
|
||||
c.get("gps_lat", 0.0),
|
||||
c.get("gps_lon", 0.0),
|
||||
c.get("sync_since", 0),
|
||||
now,
|
||||
),
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -2174,23 +2345,53 @@ class SQLiteHandler:
|
||||
params.append(limit)
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
count = 0
|
||||
# Batch insert all contacts at once instead of loop-based upserts
|
||||
now = time.time()
|
||||
contact_rows = []
|
||||
for row in rows:
|
||||
raw_type = row["contact_type"] or ""
|
||||
normalized_type = raw_type.lower().replace(" ", "_").strip()
|
||||
adv_type = type_map.get(normalized_type, 0)
|
||||
contact = {
|
||||
"pubkey": bytes.fromhex(row["pubkey"]),
|
||||
"name": row["node_name"] or "",
|
||||
"adv_type": adv_type,
|
||||
"gps_lat": row["latitude"] or 0.0,
|
||||
"gps_lon": row["longitude"] or 0.0,
|
||||
"last_advert_timestamp": int(row["last_seen"] or 0),
|
||||
"lastmod": int(row["last_seen"] or 0),
|
||||
}
|
||||
if self.companion_upsert_contact(companion_hash, contact):
|
||||
count += 1
|
||||
return count
|
||||
contact_rows.append(
|
||||
(
|
||||
companion_hash,
|
||||
bytes.fromhex(row["pubkey"]),
|
||||
row["node_name"] or "",
|
||||
adv_type,
|
||||
0, # flags
|
||||
-1, # out_path_len
|
||||
b"", # out_path
|
||||
int(row["last_seen"] or 0), # last_advert_timestamp
|
||||
int(row["last_seen"] or 0), # lastmod
|
||||
row["latitude"] or 0.0, # gps_lat
|
||||
row["longitude"] or 0.0, # gps_lon
|
||||
0, # sync_since
|
||||
now, # updated_at
|
||||
)
|
||||
)
|
||||
|
||||
if contact_rows:
|
||||
with self._connect() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO companion_contacts
|
||||
(companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path,
|
||||
last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(companion_hash, pubkey)
|
||||
DO UPDATE SET
|
||||
name=excluded.name, adv_type=excluded.adv_type,
|
||||
flags=excluded.flags, out_path_len=excluded.out_path_len,
|
||||
out_path=excluded.out_path,
|
||||
last_advert_timestamp=excluded.last_advert_timestamp,
|
||||
lastmod=excluded.lastmod, gps_lat=excluded.gps_lat,
|
||||
gps_lon=excluded.gps_lon, sync_since=excluded.sync_since,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
contact_rows,
|
||||
)
|
||||
conn.commit()
|
||||
return len(contact_rows)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import repeater contacts: {e}")
|
||||
return 0
|
||||
@@ -2249,27 +2450,32 @@ class SQLiteHandler:
|
||||
return []
|
||||
|
||||
def companion_save_channels(self, companion_hash: str, channels: List[Dict]) -> bool:
|
||||
"""Replace all channels for a companion in storage."""
|
||||
"""Replace all channels for a companion in storage using batch insert."""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,)
|
||||
)
|
||||
now = time.time()
|
||||
for ch in channels:
|
||||
conn.execute(
|
||||
# Batch insert all channels at once instead of loop-based inserts
|
||||
rows = [
|
||||
(
|
||||
companion_hash,
|
||||
ch.get("channel_idx", 0),
|
||||
ch.get("name", ""),
|
||||
ch.get("secret", b""),
|
||||
now,
|
||||
)
|
||||
for ch in channels
|
||||
]
|
||||
if rows:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO companion_channels
|
||||
(companion_hash, channel_idx, name, secret, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
companion_hash,
|
||||
ch.get("channel_idx", 0),
|
||||
ch.get("name", ""),
|
||||
ch.get("secret", b""),
|
||||
now,
|
||||
),
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -2296,30 +2502,28 @@ class SQLiteHandler:
|
||||
return []
|
||||
|
||||
def companion_push_message(self, companion_hash: str, msg: Dict) -> bool:
|
||||
"""Append a message to the companion's queue. Deduplicates by packet_hash when present. Returns True if inserted, False if duplicate (skipped)."""
|
||||
"""Append a message to the companion's queue.
|
||||
|
||||
Deduplicates by (companion_hash, packet_hash) using INSERT OR IGNORE
|
||||
backed by the UNIQUE index added in migration 8. This replaces the
|
||||
previous SELECT + INSERT round-trip (two statements, two SD-card reads)
|
||||
with a single atomic statement.
|
||||
|
||||
Returns True if inserted, False if the message was a duplicate (skipped).
|
||||
"""
|
||||
try:
|
||||
packet_hash = msg.get("packet_hash") or None
|
||||
if isinstance(packet_hash, bytes):
|
||||
packet_hash = packet_hash.decode("utf-8", errors="replace") if packet_hash else None
|
||||
sender_key = msg.get("sender_key", b"")
|
||||
with self._connect() as conn:
|
||||
if packet_hash:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT id FROM companion_messages
|
||||
WHERE companion_hash = ? AND packet_hash = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(companion_hash, packet_hash),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
return False
|
||||
conn.execute(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO companion_messages
|
||||
(companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, packet_hash, created_at)
|
||||
INSERT OR IGNORE INTO companion_messages
|
||||
(companion_hash, sender_key, txt_type, timestamp, text,
|
||||
is_channel, channel_idx, path_len, packet_hash, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
""",
|
||||
(
|
||||
companion_hash,
|
||||
sender_key,
|
||||
@@ -2334,7 +2538,7 @@ class SQLiteHandler:
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to push companion message: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -5,8 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .letsmesh_handler import MeshCoreToMqttJwtPusher
|
||||
from .mqtt_handler import MQTTHandler
|
||||
from .mqtt_handler import MeshCoreToMqttPusher
|
||||
from .rrdtool_handler import RRDToolHandler
|
||||
from .sqlite_handler import SQLiteHandler
|
||||
from .storage_utils import PacketRecord
|
||||
@@ -19,6 +19,7 @@ class StorageCollector:
|
||||
self.config = config
|
||||
self.repeater_handler = repeater_handler
|
||||
self.glass_publish_callback = None
|
||||
self._pending_tasks = set()
|
||||
|
||||
storage_dir_cfg = (
|
||||
config.get("storage", {}).get("storage_dir")
|
||||
@@ -28,45 +29,28 @@ class StorageCollector:
|
||||
self.storage_dir = Path(storage_dir_cfg)
|
||||
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
node_name = config.get("repeater", {}).get("node_name", "unknown")
|
||||
node_id = local_identity.get_public_key().hex() if local_identity else "unknown"
|
||||
|
||||
self.sqlite_handler = SQLiteHandler(self.storage_dir)
|
||||
self.rrd_handler = RRDToolHandler(self.storage_dir)
|
||||
self.mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
|
||||
|
||||
# Initialize LetsMesh handler if configured
|
||||
self.letsmesh_handler = None
|
||||
if config.get("letsmesh", {}).get("enabled", False) and local_identity:
|
||||
# Initialize MQTT handler if configured
|
||||
self.mqtt_handler = None
|
||||
if (config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})) and local_identity:
|
||||
try:
|
||||
# Pass local_identity directly (supports both standard and firmware keys)
|
||||
self.letsmesh_handler = MeshCoreToMqttJwtPusher(
|
||||
self.mqtt_handler = MeshCoreToMqttPusher(
|
||||
local_identity=local_identity,
|
||||
config=config,
|
||||
stats_provider=self._get_live_stats,
|
||||
)
|
||||
self.letsmesh_handler.connect()
|
||||
|
||||
# Get disallowed packet types from config
|
||||
from ..config import get_node_info
|
||||
|
||||
node_info = get_node_info(config)
|
||||
self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
|
||||
self.mqtt_handler.connect()
|
||||
|
||||
public_key_hex = local_identity.get_public_key().hex()
|
||||
logger.info(
|
||||
f"LetsMesh handler initialized with public key: {public_key_hex[:16]}..."
|
||||
f"MQTT handler initialized with public key: {public_key_hex[:16]}..."
|
||||
)
|
||||
if self.disallowed_packet_types:
|
||||
logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
|
||||
else:
|
||||
logger.info("All packet types allowed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize LetsMesh handler: {e}")
|
||||
self.letsmesh_handler = None
|
||||
self.disallowed_packet_types = set()
|
||||
else:
|
||||
self.disallowed_packet_types = set()
|
||||
logger.error(f"Failed to initialize MQTT handler: {e}")
|
||||
self.mqtt_handler = None
|
||||
|
||||
# Initialize hardware stats collector
|
||||
from .hardware_stats import HardwareStatsCollector
|
||||
@@ -76,16 +60,51 @@ class StorageCollector:
|
||||
|
||||
# Initialize WebSocket handler for real-time updates
|
||||
self.websocket_available = False
|
||||
self.websocket_has_connected_clients = lambda: False
|
||||
self._last_ws_stats_broadcast: float = 0.0
|
||||
self._ws_stats_broadcast_interval_sec: float = 5.0
|
||||
try:
|
||||
from .websocket_handler import broadcast_packet, broadcast_stats
|
||||
from .websocket_handler import (
|
||||
broadcast_packet,
|
||||
broadcast_stats,
|
||||
has_connected_clients,
|
||||
)
|
||||
|
||||
self.websocket_broadcast_packet = broadcast_packet
|
||||
self.websocket_broadcast_stats = broadcast_stats
|
||||
self.websocket_has_connected_clients = has_connected_clients
|
||||
self.websocket_available = True
|
||||
logger.info("WebSocket handler initialized for real-time updates")
|
||||
except ImportError:
|
||||
logger.debug("WebSocket handler not available")
|
||||
|
||||
def _track_task(self, task: asyncio.Task):
|
||||
"""Track background task for lifecycle management and error handling."""
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def on_done(t: asyncio.Task):
|
||||
self._pending_tasks.discard(t)
|
||||
try:
|
||||
t.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background task error: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(on_done)
|
||||
|
||||
def _schedule_background(self, coro_factory, *args, sync_fallback=None):
|
||||
"""Schedule a coroutine if a loop exists; otherwise run sync fallback."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
if sync_fallback is not None:
|
||||
sync_fallback(*args)
|
||||
return
|
||||
|
||||
task = loop.create_task(coro_factory(*args))
|
||||
self._track_task(task)
|
||||
|
||||
def _get_live_stats(self) -> dict:
|
||||
"""Get live stats from RepeaterHandler"""
|
||||
if not self.repeater_handler:
|
||||
@@ -131,100 +150,157 @@ class StorageCollector:
|
||||
|
||||
return stats
|
||||
|
||||
def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True):
|
||||
"""Record packet to storage and publish to MQTT/LetsMesh
|
||||
def record_packet(self, packet_record: dict, skip_mqtt_if_invalid: bool = True):
|
||||
"""Record packet to storage and publish to MQTT
|
||||
|
||||
Args:
|
||||
packet_record: Dictionary containing packet information
|
||||
skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh
|
||||
skip_mqtt_if_invalid: If True, don't publish packets with drop_reason to mqtt
|
||||
"""
|
||||
logger.debug(
|
||||
f"Recording packet: type={packet_record.get('type')}, "
|
||||
f"transmitted={packet_record.get('transmitted')}"
|
||||
)
|
||||
|
||||
# Store to local databases and publish to local MQTT
|
||||
# HOT PATH: Store to local databases only (fast, non-blocking)
|
||||
self.sqlite_handler.store_packet(packet_record)
|
||||
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
|
||||
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
|
||||
self.mqtt_handler.publish(packet_record, "packet")
|
||||
|
||||
# DEFERRED: Publish to network sinks and WebSocket in background tasks
|
||||
# This prevents network latency from blocking packet processing
|
||||
self._schedule_background(
|
||||
self._deferred_publish,
|
||||
packet_record,
|
||||
skip_mqtt_if_invalid,
|
||||
sync_fallback=self._publish_packet_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Deferred background task for all network publishing operations."""
|
||||
try:
|
||||
self._publish_packet_sync(packet_record, skip_mqtt)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_packet_sync(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Publish packet updates synchronously (used when no asyncio loop is active)."""
|
||||
self._publish_to_glass(packet_record, "packet")
|
||||
|
||||
# Broadcast to WebSocket clients for real-time updates
|
||||
if self.websocket_available:
|
||||
try:
|
||||
self.websocket_broadcast_packet(packet_record)
|
||||
|
||||
# Broadcast 24-hour packet stats (same as /api/packet_stats?hours=24)
|
||||
packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24)
|
||||
uptime_seconds = (
|
||||
time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
|
||||
)
|
||||
|
||||
self.websocket_broadcast_stats(
|
||||
{
|
||||
"packet_stats": packet_stats_24h,
|
||||
"system_stats": {
|
||||
"uptime_seconds": uptime_seconds,
|
||||
},
|
||||
}
|
||||
)
|
||||
if self.websocket_has_connected_clients():
|
||||
now_mono = time.monotonic()
|
||||
if (
|
||||
now_mono - self._last_ws_stats_broadcast
|
||||
>= self._ws_stats_broadcast_interval_sec
|
||||
):
|
||||
self._last_ws_stats_broadcast = now_mono
|
||||
packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24)
|
||||
uptime_seconds = (
|
||||
time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
|
||||
)
|
||||
self.websocket_broadcast_stats(
|
||||
{
|
||||
"packet_stats": packet_stats_24h,
|
||||
"system_stats": {"uptime_seconds": uptime_seconds},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket broadcast failed: {e}")
|
||||
|
||||
# Publish to LetsMesh if enabled (skip invalid packets if requested)
|
||||
if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
|
||||
logger.debug(
|
||||
f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
|
||||
)
|
||||
else:
|
||||
self._publish_to_letsmesh(packet_record)
|
||||
|
||||
def _publish_to_letsmesh(self, packet_record: dict):
|
||||
"""Publish packet to LetsMesh broker if enabled and allowed"""
|
||||
if not self.letsmesh_handler:
|
||||
self._publish_packet_to_mqtt(packet_record)
|
||||
|
||||
def _publish_packet_to_mqtt(self, packet_record: dict):
|
||||
"""Publish packet to mqtt broker if enabled and allowed"""
|
||||
if not self.mqtt_handler:
|
||||
return
|
||||
|
||||
try:
|
||||
packet_type = packet_record.get("type")
|
||||
if packet_type is None:
|
||||
logger.error("Cannot publish to LetsMesh: packet_record missing 'type' field")
|
||||
return
|
||||
|
||||
if packet_type in self.disallowed_packet_types:
|
||||
logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
|
||||
logger.error("Cannot publish to mqtt: packet_record missing 'type' field")
|
||||
return
|
||||
|
||||
node_name = self.config.get("repeater", {}).get("node_name", "Unknown")
|
||||
packet = PacketRecord.from_packet_record(
|
||||
packet_record, origin=node_name, origin_id=self.letsmesh_handler.public_key
|
||||
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
|
||||
)
|
||||
|
||||
if packet:
|
||||
self.letsmesh_handler.publish_packet(packet.to_dict())
|
||||
logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
|
||||
self.mqtt_handler.publish_packet(packet.to_dict())
|
||||
logger.debug(f"Published packet type 0x{packet_type:02X} to mqtt")
|
||||
else:
|
||||
logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
|
||||
logger.debug("Skipped mqtt publish: packet missing raw_packet data")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish packet to LetsMesh: {e}", exc_info=True)
|
||||
logger.error(f"Failed to publish packet to mqtt: {e}", exc_info=True)
|
||||
|
||||
def record_advert(self, advert_record: dict):
|
||||
"""Record advert to storage and defer network publishing to background tasks."""
|
||||
self.sqlite_handler.store_advert(advert_record)
|
||||
self.mqtt_handler.publish(advert_record, "advert")
|
||||
self._schedule_background(
|
||||
self._deferred_publish_advert,
|
||||
advert_record,
|
||||
sync_fallback=self._publish_advert_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish_advert(self, advert_record: dict):
|
||||
"""Deferred background task for advert publishing."""
|
||||
try:
|
||||
self._publish_advert_sync(advert_record)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred advert publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_advert_sync(self, advert_record: dict):
|
||||
if self.mqtt_handler:
|
||||
self.mqtt_handler.publish_mqtt(advert_record, "advert")
|
||||
self._publish_to_glass(advert_record, "advert")
|
||||
|
||||
def record_noise_floor(self, noise_floor_dbm: float):
|
||||
"""Record noise floor to storage and defer network publishing to background tasks."""
|
||||
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
|
||||
self.sqlite_handler.store_noise_floor(noise_record)
|
||||
self.mqtt_handler.publish(noise_record, "noise_floor")
|
||||
self._schedule_background(
|
||||
self._deferred_publish_noise_floor,
|
||||
noise_record,
|
||||
sync_fallback=self._publish_noise_floor_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish_noise_floor(self, noise_record: dict):
|
||||
"""Deferred background task for noise floor publishing."""
|
||||
try:
|
||||
self._publish_noise_floor_sync(noise_record)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred noise floor publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_noise_floor_sync(self, noise_record: dict):
|
||||
if self.mqtt_handler:
|
||||
self.mqtt_handler.publish_mqtt(noise_record, "noise_floor")
|
||||
self._publish_to_glass(noise_record, "noise_floor")
|
||||
|
||||
def record_crc_errors(self, count: int):
|
||||
"""Record a batch of CRC errors detected since last poll."""
|
||||
"""Record a batch of CRC errors detected since last poll and defer publishing."""
|
||||
crc_record = {"timestamp": time.time(), "count": count}
|
||||
self.sqlite_handler.store_crc_errors(crc_record)
|
||||
self.mqtt_handler.publish(crc_record, "crc_errors")
|
||||
self._schedule_background(
|
||||
self._deferred_publish_crc_errors,
|
||||
crc_record,
|
||||
sync_fallback=self._publish_crc_errors_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish_crc_errors(self, crc_record: dict):
|
||||
"""Deferred background task for CRC error publishing."""
|
||||
try:
|
||||
self._publish_crc_errors_sync(crc_record)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred CRC errors publish failed: {e}", exc_info=True)
|
||||
|
||||
def _publish_crc_errors_sync(self, crc_record: dict):
|
||||
if self.mqtt_handler:
|
||||
self.mqtt_handler.publish_mqtt(crc_record, "crc_errors")
|
||||
self._publish_to_glass(crc_record, "crc_errors")
|
||||
|
||||
def get_crc_error_count(self, hours: int = 24) -> int:
|
||||
@@ -260,6 +336,20 @@ class StorageCollector:
|
||||
) -> list:
|
||||
return self.sqlite_handler.get_airtime_data(start_timestamp, end_timestamp, limit)
|
||||
|
||||
def get_airtime_buckets(
|
||||
self,
|
||||
start_timestamp: float,
|
||||
end_timestamp: float,
|
||||
bucket_seconds: int = 60,
|
||||
sf: int = 9,
|
||||
bw_hz: int = 62500,
|
||||
cr: int = 5,
|
||||
preamble: int = 17,
|
||||
) -> dict:
|
||||
return self.sqlite_handler.get_airtime_buckets(
|
||||
start_timestamp, end_timestamp, bucket_seconds, sf, bw_hz, cr, preamble
|
||||
)
|
||||
|
||||
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
|
||||
return self.sqlite_handler.get_packet_by_hash(packet_hash)
|
||||
|
||||
@@ -318,13 +408,17 @@ class StorageCollector:
|
||||
return self.sqlite_handler.get_noise_floor_stats(hours)
|
||||
|
||||
def close(self):
|
||||
self.mqtt_handler.close()
|
||||
if self.letsmesh_handler:
|
||||
# Cancel all pending background tasks
|
||||
for task in self._pending_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
if self.mqtt_handler:
|
||||
try:
|
||||
self.letsmesh_handler.disconnect()
|
||||
logger.info("LetsMesh handler disconnected")
|
||||
self.mqtt_handler.disconnect()
|
||||
logger.info("MQTT handler disconnected")
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting LetsMesh handler: {e}")
|
||||
logger.error(f"Error disconnecting MQTT handler: {e}")
|
||||
|
||||
def set_glass_publisher(self, publish_callback):
|
||||
self.glass_publish_callback = publish_callback
|
||||
|
||||
@@ -10,7 +10,7 @@ class PacketRecord:
|
||||
"""
|
||||
Data class for packet record format.
|
||||
Converts internal packet_record format to standardized publish format.
|
||||
Reusable across MQTT, LetsMesh, and other handlers.
|
||||
Reusable across MQTT and other handlers.
|
||||
"""
|
||||
|
||||
origin: str
|
||||
|
||||
@@ -126,6 +126,11 @@ def broadcast_stats(stats_data: dict):
|
||||
_connected_clients.discard(client)
|
||||
|
||||
|
||||
def has_connected_clients() -> bool:
|
||||
"""Return True when at least one authenticated websocket client is connected."""
|
||||
return bool(_connected_clients)
|
||||
|
||||
|
||||
def _heartbeat_loop():
|
||||
"""Background thread to send periodic pings to all connected clients"""
|
||||
global _heartbeat_running
|
||||
|
||||
+225
-125
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pymc_core.node.handlers.base import BaseHandler
|
||||
@@ -70,6 +71,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.direct_tx_delay_factor = config.get("delays", {}).get("direct_tx_delay_factor", 0.5)
|
||||
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.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
|
||||
)
|
||||
@@ -99,8 +101,9 @@ class RepeaterHandler(BaseHandler):
|
||||
self.rx_count = 0
|
||||
self.forwarded_count = 0
|
||||
self.dropped_count = 0
|
||||
self.recent_packets = []
|
||||
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
|
||||
@@ -126,6 +129,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.last_db_cleanup = time.time()
|
||||
self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds
|
||||
self._background_task = None
|
||||
self._cached_noise_floor = None
|
||||
self._last_crc_error_count = 0 # Track radio counter for delta persistence
|
||||
|
||||
# Cache transport keys for efficient lookup
|
||||
@@ -133,6 +137,24 @@ class RepeaterHandler(BaseHandler):
|
||||
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__(
|
||||
@@ -157,6 +179,7 @@ class RepeaterHandler(BaseHandler):
|
||||
pass
|
||||
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
||||
|
||||
# TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off)
|
||||
mode = self.config.get("repeater", {}).get("mode", "forward")
|
||||
@@ -165,11 +188,12 @@ class RepeaterHandler(BaseHandler):
|
||||
allow_forward = mode == "forward"
|
||||
allow_local_tx = mode != "no_tx"
|
||||
|
||||
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}"
|
||||
)
|
||||
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)
|
||||
@@ -186,23 +210,25 @@ class RepeaterHandler(BaseHandler):
|
||||
original_path_hashes = packet.get_path_hashes_hex()
|
||||
path_hash_size = packet.get_path_hash_size()
|
||||
|
||||
# Process for forwarding (skip if repeat disabled or if this is a local transmission)
|
||||
# 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)
|
||||
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)
|
||||
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()
|
||||
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
|
||||
|
||||
if result:
|
||||
fwd_pkt, delay = result
|
||||
@@ -300,9 +326,10 @@ class RepeaterHandler(BaseHandler):
|
||||
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
|
||||
processed_packet, packet_hash=pkt_hash_full
|
||||
)
|
||||
logger.debug(f"Packet not forwarded: {drop_reason}")
|
||||
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:
|
||||
@@ -313,13 +340,13 @@ class RepeaterHandler(BaseHandler):
|
||||
header_info = PacketHeaderUtils.parse_header(packet.header)
|
||||
payload_type = header_info["payload_type"]
|
||||
route_type = header_info["route_type"]
|
||||
logger.debug(
|
||||
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={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
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
is_dupe = pkt_hash in self.seen_packets and not transmitted
|
||||
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:
|
||||
@@ -356,45 +383,40 @@ class RepeaterHandler(BaseHandler):
|
||||
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 LetsMesh only for invalid packets (not duplicates or operational drops)
|
||||
# Skip mqtt only for invalid packets (not duplicates or operational drops)
|
||||
if self.storage:
|
||||
try:
|
||||
# Only skip LetsMesh for actual invalid/bad packets
|
||||
# Only skip mqtt for actual invalid/bad packets
|
||||
invalid_reasons = ["Invalid advert packet", "Empty payload", "Path too long"]
|
||||
skip_letsmesh = drop_reason in invalid_reasons if drop_reason else False
|
||||
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=skip_letsmesh)
|
||||
skip_mqtt = drop_reason in invalid_reasons if drop_reason else False
|
||||
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=skip_mqtt)
|
||||
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:
|
||||
# Find the original packet with same hash
|
||||
for idx in range(len(self.recent_packets) - 1, -1, -1):
|
||||
prev_pkt = self.recent_packets[idx]
|
||||
if prev_pkt.get("packet_hash") == packet_record["packet_hash"]:
|
||||
# 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
|
||||
break
|
||||
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.recent_packets.append(packet_record)
|
||||
self._append_recent_packet(packet_record)
|
||||
else:
|
||||
# Not a duplicate or first occurrence
|
||||
self.recent_packets.append(packet_record)
|
||||
|
||||
if len(self.recent_packets) > self.max_recent_packets:
|
||||
self.recent_packets.pop(0)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
def log_trace_record(self, packet_record: dict) -> None:
|
||||
"""Manually log a packet trace record (used by external callers)"""
|
||||
self.recent_packets.append(packet_record)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
self.rx_count += 1
|
||||
if packet_record.get("transmitted", False):
|
||||
@@ -409,9 +431,6 @@ class RepeaterHandler(BaseHandler):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store packet record: {e}")
|
||||
|
||||
if len(self.recent_packets) > self.max_recent_packets:
|
||||
self.recent_packets.pop(0)
|
||||
|
||||
def record_packet_only(self, packet: Packet, metadata: dict) -> None:
|
||||
"""Record a packet for UI/storage without running forwarding or duplicate logic.
|
||||
|
||||
@@ -448,15 +467,14 @@ class RepeaterHandler(BaseHandler):
|
||||
path_hash,
|
||||
src_hash,
|
||||
dst_hash,
|
||||
packet_hash=packet.calculate_packet_hash().hex().upper(),
|
||||
)
|
||||
try:
|
||||
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
|
||||
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store packet record (record_packet_only): {e}")
|
||||
return
|
||||
self.recent_packets.append(packet_record)
|
||||
if len(self.recent_packets) > self.max_recent_packets:
|
||||
self.recent_packets.pop(0)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
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.
|
||||
@@ -489,30 +507,26 @@ class RepeaterHandler(BaseHandler):
|
||||
transmitted=False,
|
||||
drop_reason="Duplicate",
|
||||
is_duplicate=True,
|
||||
packet_hash=packet.calculate_packet_hash().hex().upper(),
|
||||
)
|
||||
|
||||
if self.storage:
|
||||
try:
|
||||
self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
|
||||
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=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:
|
||||
for idx in range(len(self.recent_packets) - 1, -1, -1):
|
||||
prev_pkt = self.recent_packets[idx]
|
||||
if prev_pkt.get("packet_hash") == packet_record["packet_hash"]:
|
||||
if "duplicates" not in prev_pkt:
|
||||
prev_pkt["duplicates"] = []
|
||||
prev_pkt["duplicates"].append(packet_record)
|
||||
break
|
||||
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.recent_packets.append(packet_record)
|
||||
self._append_recent_packet(packet_record)
|
||||
else:
|
||||
self.recent_packets.append(packet_record)
|
||||
|
||||
if len(self.recent_packets) > self.max_recent_packets:
|
||||
self.recent_packets.pop(0)
|
||||
self._append_recent_packet(packet_record)
|
||||
|
||||
def cleanup_cache(self):
|
||||
|
||||
@@ -570,9 +584,10 @@ class RepeaterHandler(BaseHandler):
|
||||
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."""
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
||||
payload = getattr(packet, "payload", None)
|
||||
payload_len = len(payload or b"")
|
||||
return {
|
||||
@@ -609,9 +624,22 @@ class RepeaterHandler(BaseHandler):
|
||||
"lbt_channel_busy": lbt_channel_busy,
|
||||
}
|
||||
|
||||
def _get_drop_reason(self, packet: Packet) -> str:
|
||||
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]
|
||||
|
||||
if self.is_duplicate(packet):
|
||||
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 "Duplicate"
|
||||
|
||||
if not packet or not packet.payload:
|
||||
@@ -639,16 +667,24 @@ class RepeaterHandler(BaseHandler):
|
||||
# Default reason
|
||||
return "Unknown"
|
||||
|
||||
def is_duplicate(self, packet: Packet) -> bool:
|
||||
def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool:
|
||||
"""Return True if this packet has already been seen.
|
||||
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
if pkt_hash in self.seen_packets:
|
||||
return True
|
||||
return False
|
||||
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.
|
||||
|
||||
def mark_seen(self, packet: Packet):
|
||||
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
|
||||
|
||||
pkt_hash = packet.calculate_packet_hash().hex().upper()
|
||||
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:
|
||||
@@ -747,9 +783,10 @@ class RepeaterHandler(BaseHandler):
|
||||
transport_key = base64.b64decode(transport_key_encoded)
|
||||
expected_code = calc_transport_code(transport_key, packet)
|
||||
if transport_code_0 == expected_code:
|
||||
logger.debug(
|
||||
f"Transport code validated for key '{key_name}' with policy '{flood_policy}'"
|
||||
)
|
||||
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:
|
||||
@@ -758,9 +795,10 @@ class RepeaterHandler(BaseHandler):
|
||||
self.storage.update_transport_key(
|
||||
key_id=key_id, last_used=time.time()
|
||||
)
|
||||
logger.debug(
|
||||
f"Updated last_used timestamp for transport key '{key_name}'"
|
||||
)
|
||||
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}"
|
||||
@@ -777,17 +815,23 @@ class RepeaterHandler(BaseHandler):
|
||||
continue
|
||||
|
||||
# No matching transport code found
|
||||
logger.debug(
|
||||
f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)"
|
||||
)
|
||||
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) -> Optional[Packet]:
|
||||
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:
|
||||
@@ -821,8 +865,8 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.drop_reason = f"FLOOD loop detected ({mode})"
|
||||
return None
|
||||
|
||||
# Suppress duplicates
|
||||
if self.is_duplicate(packet):
|
||||
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash):
|
||||
packet.drop_reason = "Duplicate"
|
||||
return None
|
||||
|
||||
@@ -834,6 +878,10 @@ class RepeaterHandler(BaseHandler):
|
||||
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"Max flood hops limit reached ({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 = "Path hop count at maximum (63), cannot append"
|
||||
@@ -844,7 +892,7 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.drop_reason = "Path would exceed MAX_PATH_SIZE"
|
||||
return None
|
||||
|
||||
self.mark_seen(packet)
|
||||
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])
|
||||
@@ -852,8 +900,13 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
return packet
|
||||
|
||||
def direct_forward(self, packet: Packet) -> Optional[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:
|
||||
@@ -879,12 +932,12 @@ class RepeaterHandler(BaseHandler):
|
||||
packet.drop_reason = "Direct: not for us"
|
||||
return None
|
||||
|
||||
# Suppress duplicates
|
||||
if self.is_duplicate(packet):
|
||||
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash):
|
||||
packet.drop_reason = "Duplicate"
|
||||
return None
|
||||
|
||||
self.mark_seen(packet)
|
||||
self.mark_seen(packet, packet_hash=packet_hash)
|
||||
|
||||
# Remove first hash entry (hash_size bytes)
|
||||
packet.path = bytearray(packet.path[hash_size:])
|
||||
@@ -920,8 +973,6 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
def _calculate_tx_delay(self, packet: Packet, snr: float = 0.0) -> float:
|
||||
|
||||
import random
|
||||
|
||||
packet_len = packet.get_raw_length()
|
||||
airtime_ms = self.airtime_mgr.calculate_airtime(packet_len)
|
||||
|
||||
@@ -951,34 +1002,47 @@ class RepeaterHandler(BaseHandler):
|
||||
# score 0.0 → multiplier 1.0 (100% of original)
|
||||
score_multiplier = max(0.2, 1.0 - score)
|
||||
delay_s = delay_s * score_multiplier
|
||||
logger.debug(
|
||||
f"Congestion detected (delay >= 50ms), score={score:.2f}, "
|
||||
f"delay multiplier={score_multiplier:.2f}"
|
||||
)
|
||||
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)
|
||||
|
||||
logger.debug(
|
||||
f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, "
|
||||
f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Route={'FLOOD' if route_type == ROUTE_TYPE_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) -> Optional[Tuple[Packet, float]]:
|
||||
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
|
||||
|
||||
if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD:
|
||||
fwd_pkt = self.flood_forward(packet)
|
||||
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)
|
||||
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)
|
||||
@@ -1003,29 +1067,59 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
async def delayed_send():
|
||||
await asyncio.sleep(delay)
|
||||
last_error = None
|
||||
|
||||
# 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):
|
||||
try:
|
||||
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
||||
self._record_packet_sent(fwd_pkt)
|
||||
if attempt > 0:
|
||||
# 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)
|
||||
|
||||
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.
|
||||
if airtime_ms > 0:
|
||||
self.airtime_mgr.record_tx(airtime_ms)
|
||||
packet_size = fwd_pkt.get_raw_length()
|
||||
logger.info(
|
||||
f"Retransmitted packet ({packet_size} bytes, "
|
||||
f"{airtime_ms:.1f}ms airtime)"
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.error(f"Retransmit failed: {e}")
|
||||
if local_transmission and attempt == 0:
|
||||
logger.info("Retrying local TX in 1s...")
|
||||
await asyncio.sleep(1.0)
|
||||
else:
|
||||
raise
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx_now:
|
||||
logger.warning(
|
||||
"Packet dropped at TX time: duty-cycle exceeded "
|
||||
"(airtime=%.1fms)", airtime_ms,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
||||
self._record_packet_sent(fwd_pkt)
|
||||
if airtime_ms > 0:
|
||||
self.airtime_mgr.record_tx(airtime_ms)
|
||||
packet_size = fwd_pkt.get_raw_length()
|
||||
logger.info(
|
||||
f"Retransmitted packet ({packet_size} bytes, "
|
||||
f"{airtime_ms:.1f}ms airtime)"
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
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 asyncio.create_task(delayed_send())
|
||||
|
||||
@@ -1047,6 +1141,10 @@ class RepeaterHandler(BaseHandler):
|
||||
logger.debug(f"Failed to get noise floor: {e}")
|
||||
return None
|
||||
|
||||
def get_cached_noise_floor(self) -> Optional[float]:
|
||||
"""Return the last asynchronously-sampled noise floor value."""
|
||||
return self._cached_noise_floor
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
|
||||
uptime_seconds = time.time() - self.start_time
|
||||
@@ -1065,8 +1163,8 @@ class RepeaterHandler(BaseHandler):
|
||||
rx_per_hour = len(packets_last_hour)
|
||||
forwarded_per_hour = sum(1 for p in packets_last_hour if p.get("transmitted", False))
|
||||
|
||||
# Get current noise floor from radio
|
||||
noise_floor_dbm = self.get_noise_floor()
|
||||
# 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
|
||||
radio = self.dispatcher.radio if self.dispatcher else None
|
||||
@@ -1097,7 +1195,7 @@ class RepeaterHandler(BaseHandler):
|
||||
"direct_dup_count": self.direct_dup_count,
|
||||
"rx_per_hour": rx_per_hour,
|
||||
"forwarded_per_hour": forwarded_per_hour,
|
||||
"recent_packets": self.recent_packets,
|
||||
"recent_packets": list(self.recent_packets),
|
||||
"neighbors": neighbors,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"noise_floor_dbm": noise_floor_dbm,
|
||||
@@ -1114,7 +1212,7 @@ class RepeaterHandler(BaseHandler):
|
||||
),
|
||||
"latitude": repeater_config.get("latitude", 0.0),
|
||||
"longitude": repeater_config.get("longitude", 0.0),
|
||||
"max_flood_hops": repeater_config.get("max_flood_hops", 3),
|
||||
"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", {}),
|
||||
@@ -1138,7 +1236,7 @@ class RepeaterHandler(BaseHandler):
|
||||
"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),
|
||||
},
|
||||
"letsmesh": self.config.get("letsmesh", {}),
|
||||
"mqtt_brokers": self.config.get("mqtt_brokers", {}),
|
||||
},
|
||||
"public_key": None,
|
||||
}
|
||||
@@ -1212,6 +1310,7 @@ class RepeaterHandler(BaseHandler):
|
||||
loop = asyncio.get_running_loop()
|
||||
noise_floor = await loop.run_in_executor(None, self.get_noise_floor)
|
||||
if noise_floor is not None:
|
||||
self._cached_noise_floor = noise_floor
|
||||
self.storage.record_noise_floor(noise_floor)
|
||||
logger.debug(f"Recorded noise floor: {noise_floor} dBm")
|
||||
else:
|
||||
@@ -1266,6 +1365,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.score_threshold = repeater_config.get("score_threshold", 0.3)
|
||||
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.loop_detect_mode = self._normalize_loop_detect_mode(
|
||||
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,8 @@ Includes adaptive rate limiting based on mesh activity.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
import itertools
|
||||
from collections import OrderedDict, deque
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
@@ -123,9 +124,9 @@ class AdvertHelper:
|
||||
self._stats_advert_duplicates = 0
|
||||
self._stats_tier_changes = 0
|
||||
|
||||
# Recent drops tracking (keep last 20)
|
||||
self._recent_drops = []
|
||||
self._max_recent_drops = 20
|
||||
# Recent drops tracking — bounded deque so append is O(1) and the
|
||||
# oldest entry is evicted automatically (no pop(0) O(n) shift needed).
|
||||
self._recent_drops: deque = deque(maxlen=20)
|
||||
|
||||
# Memory management
|
||||
self._last_cleanup = time.time()
|
||||
@@ -194,8 +195,8 @@ class AdvertHelper:
|
||||
|
||||
# 5. Limit known neighbors set to prevent unbounded growth
|
||||
if len(self._known_neighbors) > 1000:
|
||||
# Clear the oldest half (simple approach - could be more sophisticated)
|
||||
self._known_neighbors = set(list(self._known_neighbors)[500:])
|
||||
# itertools.islice avoids materialising the full list first (O(n) → O(k))
|
||||
self._known_neighbors = set(itertools.islice(self._known_neighbors, 500))
|
||||
|
||||
if expired_penalties or inactive_pubkeys:
|
||||
logger.debug(
|
||||
@@ -571,10 +572,13 @@ class AdvertHelper:
|
||||
# Track recent drop (deduplicate by pubkey)
|
||||
pubkey_short = pubkey[:16]
|
||||
|
||||
# Remove any existing entry for this pubkey
|
||||
self._recent_drops = [d for d in self._recent_drops if d["pubkey"] != pubkey_short]
|
||||
|
||||
# Add the new drop entry
|
||||
# Remove any existing entry for this pubkey, then append the
|
||||
# updated record. Rebuilding as a deque preserves maxlen so
|
||||
# the oldest entry is evicted automatically — no pop(0) needed.
|
||||
self._recent_drops = deque(
|
||||
(d for d in self._recent_drops if d["pubkey"] != pubkey_short),
|
||||
maxlen=20,
|
||||
)
|
||||
self._recent_drops.append({
|
||||
"pubkey": pubkey_short,
|
||||
"name": node_name,
|
||||
@@ -582,10 +586,6 @@ class AdvertHelper:
|
||||
"timestamp": now
|
||||
})
|
||||
|
||||
# Keep only last N drops
|
||||
if len(self._recent_drops) > self._max_recent_drops:
|
||||
self._recent_drops.pop(0)
|
||||
|
||||
return
|
||||
|
||||
# Skip our own adverts
|
||||
|
||||
@@ -44,11 +44,26 @@ class DiscoveryHelper:
|
||||
log_fn=log_fn or logger.info,
|
||||
debug_log_fn=debug_log_fn,
|
||||
)
|
||||
self._pending_tasks = set()
|
||||
|
||||
# Set up the request callback
|
||||
self.control_handler.set_request_callback(self._on_discovery_request)
|
||||
logger.debug("Discovery handler initialized")
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def _on_done(done_task: asyncio.Task) -> None:
|
||||
self._pending_tasks.discard(done_task)
|
||||
try:
|
||||
done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background discovery task failed: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
def _on_discovery_request(self, request_data: dict) -> None:
|
||||
"""
|
||||
Handle incoming discovery request.
|
||||
@@ -115,7 +130,8 @@ class DiscoveryHelper:
|
||||
|
||||
# Send response via router injection
|
||||
if self.packet_injector:
|
||||
asyncio.create_task(self._send_packet_async(response_packet, tag))
|
||||
task = asyncio.create_task(self._send_packet_async(response_packet, tag))
|
||||
self._track_task(task)
|
||||
else:
|
||||
logger.warning("No packet injector available - discovery response not sent")
|
||||
|
||||
|
||||
@@ -22,6 +22,21 @@ class LoginHelper:
|
||||
|
||||
self.handlers = {}
|
||||
self.acls = {} # Per-identity ACLs keyed by hash_byte
|
||||
self._pending_tasks = set()
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def _on_done(done_task: asyncio.Task) -> None:
|
||||
self._pending_tasks.discard(done_task)
|
||||
try:
|
||||
done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background login task failed: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
def register_identity(
|
||||
self, name: str, identity, identity_type: str = "room_server", config: dict = None
|
||||
@@ -141,7 +156,8 @@ class LoginHelper:
|
||||
def _send_packet_with_delay(self, packet, delay_ms: int):
|
||||
|
||||
if self.packet_injector:
|
||||
asyncio.create_task(self._delayed_send(packet, delay_ms))
|
||||
task = asyncio.create_task(self._delayed_send(packet, delay_ms))
|
||||
self._track_task(task)
|
||||
else:
|
||||
logger.error("No packet injector configured, cannot send login response")
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ class MeshCLI:
|
||||
return f"> {interval}"
|
||||
|
||||
elif param == "flood.max":
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 3)
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 64)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
|
||||
@@ -401,7 +401,7 @@ class MeshCLI:
|
||||
return f"> {interval}"
|
||||
|
||||
elif param == "flood.max":
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 3)
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 64)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
|
||||
@@ -65,6 +65,21 @@ class TextHelper:
|
||||
|
||||
# Initialize CLI handler later when repeater identity is registered
|
||||
self.cli = None
|
||||
self._pending_tasks = set()
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
def _on_done(done_task: asyncio.Task) -> None:
|
||||
self._pending_tasks.discard(done_task)
|
||||
try:
|
||||
done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Background text task failed: {e}", exc_info=True)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
|
||||
def register_identity(
|
||||
self, name: str, identity, identity_type: str = "room_server", radio_config=None
|
||||
@@ -152,7 +167,8 @@ class TextHelper:
|
||||
self.room_servers[hash_byte] = room_server
|
||||
|
||||
# Start sync loop
|
||||
asyncio.create_task(room_server.start())
|
||||
start_task = asyncio.create_task(room_server.start())
|
||||
self._track_task(start_task)
|
||||
|
||||
logger.info(
|
||||
f"Registered room server '{name}': hash=0x{hash_byte:02X}, "
|
||||
|
||||
+47
-8
@@ -53,6 +53,8 @@ class RepeaterDaemon:
|
||||
self.router = None
|
||||
self.companion_bridges: dict[int, object] = {}
|
||||
self.companion_frame_servers: list = []
|
||||
self._shutdown_started = False
|
||||
self._main_task = None
|
||||
|
||||
log_level = config.get("logging", {}).get("level", "INFO")
|
||||
logging.basicConfig(
|
||||
@@ -940,7 +942,7 @@ class RepeaterDaemon:
|
||||
"queue_len": min(255, queue_len),
|
||||
}
|
||||
if stats_type == STATS_TYPE_RADIO:
|
||||
noise_floor = int(engine.get_noise_floor() or 0)
|
||||
noise_floor = int(engine.get_cached_noise_floor() or 0)
|
||||
radio = getattr(self, "dispatcher", None) and getattr(self.dispatcher, "radio", None)
|
||||
if radio:
|
||||
_r = getattr(radio, "get_last_rssi", lambda: 0)
|
||||
@@ -1020,11 +1022,37 @@ class RepeaterDaemon:
|
||||
|
||||
def _signal_shutdown(self, sig, loop):
|
||||
"""Handle SIGTERM/SIGINT by scheduling async shutdown."""
|
||||
if self._shutdown_started:
|
||||
logger.info(f"Received signal {sig.name}, shutdown already in progress")
|
||||
return
|
||||
logger.info(f"Received signal {sig.name}, shutting down...")
|
||||
loop.create_task(self._shutdown())
|
||||
# Cancel run() so dispatcher.run_forever() unwinds cleanly.
|
||||
if self._main_task and not self._main_task.done():
|
||||
self._main_task.cancel()
|
||||
|
||||
async def _shutdown(self):
|
||||
"""Best-effort shutdown: stop background services and release hardware."""
|
||||
if self._shutdown_started:
|
||||
return
|
||||
self._shutdown_started = True
|
||||
|
||||
# Stop companion frame servers first to close client sockets and child workers.
|
||||
for frame_server in getattr(self, "companion_frame_servers", []):
|
||||
try:
|
||||
await frame_server.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Companion frame server stop error: {e}")
|
||||
|
||||
# Stop companion bridges to flush/persist state.
|
||||
if hasattr(self, "companion_bridges"):
|
||||
for bridge in self.companion_bridges.values():
|
||||
if hasattr(bridge, "stop"):
|
||||
try:
|
||||
await bridge.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Companion bridge stop error: {e}")
|
||||
|
||||
# Stop router
|
||||
if self.router:
|
||||
try:
|
||||
@@ -1035,7 +1063,9 @@ class RepeaterDaemon:
|
||||
# Stop HTTP server
|
||||
if self.http_server:
|
||||
try:
|
||||
self.http_server.stop()
|
||||
await asyncio.wait_for(asyncio.to_thread(self.http_server.stop), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout stopping HTTP server")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping HTTP server: {e}")
|
||||
|
||||
@@ -1046,6 +1076,17 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping Glass handler: {e}")
|
||||
|
||||
# Close storage publishers (MQTT/LetsMesh) to stop their worker threads.
|
||||
try:
|
||||
if self.repeater_handler and self.repeater_handler.storage:
|
||||
await asyncio.wait_for(
|
||||
asyncio.to_thread(self.repeater_handler.storage.close), timeout=5
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout closing storage publishers")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing storage: {e}")
|
||||
|
||||
# Release radio resources
|
||||
if self.radio and hasattr(self.radio, "cleanup"):
|
||||
try:
|
||||
@@ -1062,12 +1103,7 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.debug(f"CH341 reset skipped/failed: {e}")
|
||||
|
||||
# Stop the event loop so the process can exit cleanly
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.stop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
# Do not force-stop the event loop here; asyncio.run() owns loop lifecycle.
|
||||
|
||||
@staticmethod
|
||||
def _detect_container() -> bool:
|
||||
@@ -1083,6 +1119,7 @@ class RepeaterDaemon:
|
||||
async def run(self):
|
||||
|
||||
logger.info("Repeater daemon started")
|
||||
self._main_task = asyncio.current_task()
|
||||
|
||||
# Register signal handlers for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -1141,6 +1178,8 @@ class RepeaterDaemon:
|
||||
# Run dispatcher (handles RX/TX via pymc_core)
|
||||
try:
|
||||
await self.dispatcher.run_forever()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Dispatcher loop cancelled for shutdown")
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
for frame_server in getattr(self, "companion_frame_servers", []):
|
||||
|
||||
@@ -54,6 +54,22 @@ class PacketRouter:
|
||||
self._inject_lock = asyncio.Lock()
|
||||
# Hash -> expiry time; skip delivering same PATH/protocol-response to companions more than once
|
||||
self._companion_delivered = {}
|
||||
# Safety valve: cap the number of _route_packet tasks sleeping concurrently.
|
||||
# LoRa's airtime budget naturally limits throughput, but burst arrivals
|
||||
# (multi-hop amplification, collision retries) can stack many sleeping
|
||||
# delay tasks before the duty-cycle gate fires. 30 is very generous for
|
||||
# any realistic LoRa network but protects against pathological scenarios
|
||||
# (e.g. a busy bridge node during a mesh-wide flood) exhausting memory or
|
||||
# starving the event loop.
|
||||
self._in_flight: int = 0
|
||||
self._max_in_flight: int = 30
|
||||
# Live set of in-flight tasks — kept in sync with _in_flight via the
|
||||
# done-callback. Used exclusively for shutdown drain; the integer
|
||||
# counter is used for the cap check (faster, single source of truth).
|
||||
self._route_tasks: set = set()
|
||||
# Total packets dropped because the cap was reached. Exposed in logs
|
||||
# at shutdown so operators know whether the cap is actually firing.
|
||||
self._cap_drop_count: int = 0
|
||||
|
||||
async def start(self):
|
||||
self.running = True
|
||||
@@ -68,7 +84,43 @@ class PacketRouter:
|
||||
await self.router_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Drain in-flight tasks gracefully, then cancel any that outlast the
|
||||
# timeout. This mirrors what the old _route_tasks set enabled and gives
|
||||
# in-progress packets a fair chance to finish (e.g. their TX delay sleep
|
||||
# + send) before the process exits.
|
||||
if self._route_tasks:
|
||||
pending_snapshot = set(self._route_tasks)
|
||||
logger.info(
|
||||
"Draining %d in-flight route task(s) (5 s timeout)...",
|
||||
len(pending_snapshot),
|
||||
)
|
||||
_, still_pending = await asyncio.wait(pending_snapshot, timeout=5.0)
|
||||
if still_pending:
|
||||
logger.warning(
|
||||
"Cancelling %d route task(s) that did not finish within the shutdown timeout",
|
||||
len(still_pending),
|
||||
)
|
||||
for task in still_pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*still_pending, return_exceptions=True)
|
||||
|
||||
if self._cap_drop_count:
|
||||
logger.warning(
|
||||
"In-flight cap dropped %d packet(s) during this session — "
|
||||
"consider raising _max_in_flight if this is frequent",
|
||||
self._cap_drop_count,
|
||||
)
|
||||
logger.info("Packet router stopped")
|
||||
|
||||
def _on_route_done(self, task: asyncio.Task) -> None:
|
||||
"""Done-callback for _route_packet tasks: decrement counter and surface errors."""
|
||||
self._in_flight -= 1
|
||||
self._route_tasks.discard(task)
|
||||
if not task.cancelled():
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.error("_route_packet raised: %s", exc, exc_info=exc)
|
||||
|
||||
def _should_deliver_path_to_companions(self, packet) -> bool:
|
||||
"""Return True if this PATH/protocol-response should be delivered to companions (first of duplicates)."""
|
||||
@@ -76,8 +128,15 @@ class PacketRouter:
|
||||
if not key:
|
||||
return True
|
||||
now = time.time()
|
||||
# Prune expired
|
||||
self._companion_delivered = {k: v for k, v in self._companion_delivered.items() if v > now}
|
||||
# Prune expired entries only when the dict grows large, avoiding a full
|
||||
# dict comprehension on every packet. 200 entries × 60 s TTL means a
|
||||
# sweep only triggers after ~200 unique PATH packets with no expiry — far
|
||||
# more than any realistic companion session, and well below the 1000-entry
|
||||
# threshold that could accumulate over hours without pruning.
|
||||
if len(self._companion_delivered) > 200:
|
||||
self._companion_delivered = {
|
||||
k: v for k, v in self._companion_delivered.items() if v > now
|
||||
}
|
||||
if key in self._companion_delivered:
|
||||
return False
|
||||
self._companion_delivered[key] = now + _COMPANION_DEDUPE_TTL_SEC
|
||||
@@ -146,7 +205,21 @@ class PacketRouter:
|
||||
while self.running:
|
||||
try:
|
||||
packet = await asyncio.wait_for(self.queue.get(), timeout=0.1)
|
||||
await self._route_packet(packet)
|
||||
# Drop early if the in-flight cap is reached. This is a last-resort
|
||||
# safety valve — under normal operation LoRa airtime and the duty-cycle
|
||||
# gate keep _in_flight well below _max_in_flight.
|
||||
if self._in_flight >= self._max_in_flight:
|
||||
self._cap_drop_count += 1
|
||||
logger.warning(
|
||||
"In-flight task cap reached (%d/%d), dropping packet "
|
||||
"(session total dropped: %d)",
|
||||
self._in_flight, self._max_in_flight, self._cap_drop_count,
|
||||
)
|
||||
continue
|
||||
self._in_flight += 1
|
||||
task = asyncio.create_task(self._route_packet(packet))
|
||||
self._route_tasks.add(task)
|
||||
task.add_done_callback(self._on_route_done)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -164,8 +237,13 @@ class PacketRouter:
|
||||
|
||||
# Route to specific handlers for parsing only
|
||||
if payload_type == TraceHandler.payload_type():
|
||||
# Process trace packet
|
||||
if self.daemon.trace_helper:
|
||||
# Locally injected TRACE requests are TX-only and re-enter the router so
|
||||
# companion delivery can still happen. They are not inbound RF responses,
|
||||
# so skip TraceHelper parsing to avoid matching pending ping tags against
|
||||
# zeroed local metadata.
|
||||
if getattr(packet, "_injected_for_tx", False):
|
||||
processed_by_injection = True
|
||||
elif self.daemon.trace_helper:
|
||||
await self.daemon.trace_helper.process_trace_packet(packet)
|
||||
# Skip engine processing for trace packets - they're handled by trace helper
|
||||
processed_by_injection = True
|
||||
|
||||
@@ -4,22 +4,57 @@ Provides functions for service control operations like restart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Tuple
|
||||
|
||||
logger = logging.getLogger("ServiceUtils")
|
||||
INIT_SCRIPT = "/etc/init.d/S80pymc-repeater"
|
||||
|
||||
|
||||
def is_buildroot() -> bool:
|
||||
if os.path.exists("/etc/pymc-image-build-id"):
|
||||
return True
|
||||
if os.path.exists("/etc/os-release"):
|
||||
try:
|
||||
with open("/etc/os-release", "r", encoding="utf-8") as handle:
|
||||
return any(line.strip() == "ID=buildroot" for line in handle)
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def restart_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Restart the pymc-repeater service via systemctl.
|
||||
Restart the pymc-repeater service.
|
||||
|
||||
Tries polkit-based restart first (plain systemctl), then falls back
|
||||
to sudo-based restart (requires sudoers.d rule installed by manage.sh).
|
||||
On Buildroot/Luckfox, use the shipped init script directly.
|
||||
On systemd hosts, try polkit-based restart first (plain systemctl), then
|
||||
fall back to sudo-based restart (requires sudoers.d rule installed by
|
||||
manage.sh).
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, message)
|
||||
"""
|
||||
if is_buildroot():
|
||||
if not os.path.exists(INIT_SCRIPT):
|
||||
logger.error("Buildroot init script not found: %s", INIT_SCRIPT)
|
||||
return False, f"init script not found: {INIT_SCRIPT}"
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["/bin/sh", "-c", f"sleep 1; exec {INIT_SCRIPT} restart >/dev/null 2>&1"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info("Service restart scheduled via Buildroot init script")
|
||||
return True, "Service restart initiated"
|
||||
except Exception as exc:
|
||||
logger.error(f"Buildroot restart failed: {exc}")
|
||||
return False, f"Restart failed: {exc}"
|
||||
|
||||
# Try polkit-based restart first (works on bare metal / VMs with polkit running)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
||||
+102
-46
@@ -54,8 +54,8 @@ logger = logging.getLogger("HTTPServer")
|
||||
# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config
|
||||
# POST /api/update_radio_config - Update radio configuration
|
||||
# POST /api/update_advert_rate_limit_config - Update advert rate limiting settings
|
||||
# GET /api/letsmesh_status - Get LetsMesh Observer connection status
|
||||
# POST /api/update_letsmesh_config - Update LetsMesh Observer configuration
|
||||
# GET /api/mqtt_status - Get MQTT Observer connection status
|
||||
# POST /api/update_mqtt_config - Update MQTT Observer configuration
|
||||
|
||||
# Packets
|
||||
# GET /api/packet_stats?hours=24 - Get packet statistics
|
||||
@@ -999,18 +999,17 @@ class APIEndpoints:
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def letsmesh_status(self):
|
||||
"""Get LetsMesh connection status and configuration."""
|
||||
def mqtt_status(self):
|
||||
"""Get MQTT connection status and configuration."""
|
||||
self._set_cors_headers()
|
||||
try:
|
||||
letsmesh_cfg = self.config.get("letsmesh", {})
|
||||
enabled = letsmesh_cfg.get("enabled", False)
|
||||
# mqtt_cfg = self.config.get("mqtt_brokers", {})
|
||||
|
||||
# Walk the chain to the letsmesh_handler
|
||||
# Walk the chain to the mqtt_handler
|
||||
handler = None
|
||||
try:
|
||||
storage = self._get_storage()
|
||||
handler = getattr(storage, "letsmesh_handler", None)
|
||||
handler = getattr(storage, "mqtt_handler", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1018,36 +1017,40 @@ class APIEndpoints:
|
||||
if handler:
|
||||
for conn in getattr(handler, "connections", []):
|
||||
connected_brokers.append({
|
||||
"enabled": conn.enabled,
|
||||
"name": conn.broker.get("name", ""),
|
||||
"host": conn.broker.get("host", ""),
|
||||
"connected": conn.is_connected(),
|
||||
"reconnecting": conn.has_pending_reconnect(),
|
||||
"status": {
|
||||
"connected": conn.is_connected(),
|
||||
"reconnecting": conn.has_pending_reconnect(),
|
||||
},
|
||||
"format": conn.format
|
||||
})
|
||||
|
||||
return self._success({
|
||||
"enabled": enabled,
|
||||
"handler_active": handler is not None,
|
||||
"brokers": connected_brokers,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting LetsMesh status: {e}")
|
||||
logger.error(f"Error getting MQTT status: {e}")
|
||||
return self._error(str(e))
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def update_letsmesh_config(self):
|
||||
"""Update LetsMesh Observer configuration.
|
||||
def update_mqtt_config(self):
|
||||
"""Update MQTT Observer configuration.
|
||||
|
||||
POST /api/update_letsmesh_config
|
||||
POST /api/update_mqtt_config
|
||||
Body: {
|
||||
"enabled": true,
|
||||
"iata_code": "SFO",
|
||||
"broker_index": 0,
|
||||
"status_interval": 300,
|
||||
"owner": "Callsign",
|
||||
"email": "user@example.com",
|
||||
"disallowed_packet_types": ["ACK"]
|
||||
"brokers": [
|
||||
{
|
||||
|
||||
}]
|
||||
}
|
||||
"""
|
||||
self._set_cors_headers()
|
||||
@@ -1062,55 +1065,72 @@ class APIEndpoints:
|
||||
if not data:
|
||||
return self._error("No configuration updates provided")
|
||||
|
||||
letsmesh_updates = {}
|
||||
mqtt_updates = {}
|
||||
|
||||
if "enabled" in data:
|
||||
letsmesh_updates["enabled"] = bool(data["enabled"])
|
||||
if "iata_code" in data:
|
||||
letsmesh_updates["iata_code"] = str(data["iata_code"]).strip()
|
||||
if "broker_index" in data:
|
||||
letsmesh_updates["broker_index"] = int(data["broker_index"])
|
||||
mqtt_updates["iata_code"] = str(data["iata_code"]).strip()
|
||||
if "status_interval" in data:
|
||||
letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
|
||||
mqtt_updates["status_interval"] = max(60, int(data["status_interval"]))
|
||||
if "owner" in data:
|
||||
letsmesh_updates["owner"] = str(data["owner"]).strip()
|
||||
mqtt_updates["owner"] = str(data["owner"]).strip()
|
||||
if "email" in data:
|
||||
letsmesh_updates["email"] = str(data["email"]).strip()
|
||||
if "disallowed_packet_types" in data:
|
||||
letsmesh_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
|
||||
if "additional_brokers" in data:
|
||||
brokers = data["additional_brokers"]
|
||||
mqtt_updates["email"] = str(data["email"]).strip()
|
||||
# if "disallowed_packet_types" in data:
|
||||
# mqtt_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
|
||||
if "brokers" in data:
|
||||
brokers = data["brokers"]
|
||||
if not isinstance(brokers, list):
|
||||
return self._error("additional_brokers must be a list")
|
||||
return self._error("brokers must be a list")
|
||||
validated = []
|
||||
for i, b in enumerate(brokers):
|
||||
if not isinstance(b, dict):
|
||||
return self._error(f"Broker at index {i} must be an object")
|
||||
for field in ("name", "host", "audience"):
|
||||
if not b.get(field, "").strip():
|
||||
for field in ("name", "host", "port", "format"):
|
||||
if not b.get(field, ""):
|
||||
return self._error(f"Broker at index {i} missing required field: {field}")
|
||||
|
||||
try:
|
||||
port = int(b.get("port", 443))
|
||||
except (ValueError, TypeError):
|
||||
return self._error(f"Broker at index {i} has invalid port")
|
||||
validated.append({
|
||||
"name": str(b["name"]).strip(),
|
||||
"host": str(b["host"]).strip(),
|
||||
"port": port,
|
||||
"audience": str(b["audience"]).strip(),
|
||||
})
|
||||
letsmesh_updates["additional_brokers"] = validated
|
||||
|
||||
new_broker = {
|
||||
"name": str(b["name"]).strip(),
|
||||
"enabled": b.get("enabled", False),
|
||||
"transport": str(b.get("transport", "websockets")).strip(),
|
||||
"host": str(b["host"]).strip(),
|
||||
"port": port,
|
||||
"format": str(b["format"]).strip(),
|
||||
"disallowed_packet_types": list(b.get("disallowed_packet_types", [])),
|
||||
"retain_status": bool(b.get("retain_status", False)),
|
||||
"tls": {
|
||||
"enabled": bool(b.get("tls", {}).get("enabled", True if port == 443 else False)),
|
||||
"insecure": bool(b.get("tls", {}).get("insecure", False)),
|
||||
}
|
||||
}
|
||||
|
||||
if b.get("use_jwt_auth", False):
|
||||
new_broker["use_jwt_auth"] = True
|
||||
new_broker["audience"] = str(b["audience"]).strip()
|
||||
else:
|
||||
new_broker["use_jwt_auth"] = False
|
||||
new_broker["username"] = b.get("username", None)
|
||||
new_broker["password"] = b.get("password", None)
|
||||
|
||||
if not letsmesh_updates:
|
||||
validated.append(new_broker)
|
||||
|
||||
mqtt_updates["brokers"] = validated
|
||||
|
||||
if not mqtt_updates:
|
||||
return self._error("No valid settings provided")
|
||||
|
||||
result = self.config_manager.update_and_save(
|
||||
updates={"letsmesh": letsmesh_updates},
|
||||
live_update=False, # Restart required for LetsMesh handler changes
|
||||
updates={"mqtt_brokers": mqtt_updates, "mqtt": None, "letsmesh": None},
|
||||
live_update=False, # Restart required for MQTT handler changes
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
|
||||
logger.info(f"MQTT config updated: {list(mqtt_updates.keys())}")
|
||||
return self._success({
|
||||
"persisted": result.get("saved", False),
|
||||
"restart_required": True,
|
||||
@@ -1457,6 +1477,42 @@ class APIEndpoints:
|
||||
logger.error(f"Error getting airtime data: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def airtime_chart_data(
|
||||
self,
|
||||
start_timestamp=None,
|
||||
end_timestamp=None,
|
||||
bucket_seconds=60,
|
||||
sf=9,
|
||||
bw_hz=62500,
|
||||
cr=5,
|
||||
preamble=17,
|
||||
):
|
||||
"""Server-side aggregated airtime utilization for chart rendering.
|
||||
|
||||
Returns pre-bucketed rx_ms/tx_ms per time bucket instead of raw packet rows,
|
||||
reducing response size from potentially hundreds of KB to a few KB.
|
||||
"""
|
||||
try:
|
||||
now = __import__("time").time()
|
||||
start_ts = float(start_timestamp) if start_timestamp is not None else now - 86400
|
||||
end_ts = float(end_timestamp) if end_timestamp is not None else now
|
||||
bucket_s = max(10, min(int(bucket_seconds), 3600))
|
||||
result = self._get_storage().get_airtime_buckets(
|
||||
start_timestamp=start_ts,
|
||||
end_timestamp=end_ts,
|
||||
bucket_seconds=bucket_s,
|
||||
sf=int(sf),
|
||||
bw_hz=int(bw_hz),
|
||||
cr=int(cr),
|
||||
preamble=int(preamble),
|
||||
)
|
||||
return self._success(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting airtime chart data: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def packet_by_hash(self, packet_hash=None):
|
||||
@@ -1727,7 +1783,7 @@ class APIEndpoints:
|
||||
"node_name": "MyNode", # Node name
|
||||
"latitude": 0.0, # Latitude (-90 to 90)
|
||||
"longitude": 0.0, # Longitude (-180 to 180)
|
||||
"max_flood_hops": 3, # Max flood hops (0-64)
|
||||
"max_flood_hops": 64, # Max flood hops (0-64)
|
||||
"flood_advert_interval_hours": 10, # Flood advert interval (0 or 3-48)
|
||||
"advert_interval_minutes": 120 # Local advert interval (0 or 1-10080)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ class CompanionAPIEndpoints:
|
||||
self.config = config or {}
|
||||
self.config_manager = config_manager
|
||||
|
||||
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
|
||||
self._sse_queue_maxsize = max(32, int(http_cfg.get("sse_queue_maxsize", 64)))
|
||||
self._sse_keepalive_sec = max(5, int(http_cfg.get("sse_keepalive_sec", 15)))
|
||||
|
||||
# SSE clients: each gets a thread-safe queue
|
||||
self._sse_clients: list[queue.Queue] = []
|
||||
self._sse_lock = threading.Lock()
|
||||
@@ -666,7 +670,7 @@ class CompanionAPIEndpoints:
|
||||
cherrypy.response.headers["Connection"] = "keep-alive"
|
||||
cherrypy.response.headers["X-Accel-Buffering"] = "no"
|
||||
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=256)
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=self._sse_queue_maxsize)
|
||||
with self._sse_lock:
|
||||
self._sse_clients.append(client_queue)
|
||||
|
||||
@@ -677,12 +681,12 @@ class CompanionAPIEndpoints:
|
||||
|
||||
while True:
|
||||
try:
|
||||
item = client_queue.get(timeout=15.0)
|
||||
item = client_queue.get(timeout=float(self._sse_keepalive_sec))
|
||||
yield f"data: {json.dumps(item)}\n\n"
|
||||
except queue.Empty:
|
||||
# Keep-alive comment
|
||||
payload = {"event": "keepalive", "timestamp": int(time.time())}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
# Keep-alive comment frame keeps EventSource connected
|
||||
# without allocating additional JSON payload objects.
|
||||
yield ": keepalive\n\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as exc:
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{h as s}from"./index-COnQNCNU.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,e(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(o(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,e(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},e(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},e(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,r(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(o(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,r(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},r(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},r(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{f as e,g as t,u as n,w as r}from"./runtime-core.esm-bundler-IofF4kUm.js";var i=t({name:`HelpView`,__name:`Help`,setup(t){return(t,i)=>(r(),n(`div`,null,[...i[0]||=[e(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help & Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> pyMC Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
|
||||
import{f as e,g as t,u as n,w as r}from"./runtime-core.esm-bundler-HnidnMFy.js";var i=t({name:`HelpView`,__name:`Help`,setup(t){return(t,i)=>(r(),n(`div`,null,[...i[0]||=[e(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help & Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> pyMC Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.bg-gradient-light[data-v-fec81ee3]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-fec81ee3]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-fec81ee3]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-fec81ee3]{background:#11191c66}.input-glass[data-v-fec81ee3]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-fec81ee3]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-fec81ee3]:focus{background:#fff}.dark .input-glass[data-v-fec81ee3]:focus{background:#ffffff1a}.input-glass[data-v-fec81ee3]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-fec81ee3]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-fec81ee3]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-fec81ee3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-fec81ee3]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-fec81ee3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-fec81ee3]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-fec81ee3]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-fec81ee3]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-fec81ee3]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-fec81ee3]{opacity:0}.dark .logo-glow[data-v-fec81ee3]{opacity:1}@keyframes float-fec81ee3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-fec81ee3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-fec81ee3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-fec81ee3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-fec81ee3]{animation:8s ease-in-out infinite pulse-slow-fec81ee3}.animate-pulse-slower[data-v-fec81ee3]{animation:10s ease-in-out infinite pulse-slower-fec81ee3}.animate-pulse-slowest[data-v-fec81ee3]{animation:12s ease-in-out infinite pulse-slowest-fec81ee3}@keyframes shake-fec81ee3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-fec81ee3]{animation:.5s ease-in-out shake-fec81ee3}@keyframes logo-aura-cycle-fec81ee3{0%,to{filter:brightness()saturate()drop-shadow(0 0 7px #38bdf873)}25%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #6366f16b)}50%{filter:brightness()saturate(1.03)drop-shadow(0 0 8px #22d3ee73)}75%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #34d3996b)}}.logo-image-animated[data-v-fec81ee3]{will-change:filter;animation:6s ease-in-out infinite logo-aura-cycle-fec81ee3}.form-group[data-v-fec81ee3]{position:relative}.form-group:hover label[data-v-fec81ee3]{color:#aae8e8e6;transition:color .3s}
|
||||
@@ -1 +0,0 @@
|
||||
.bg-gradient-light[data-v-0539335e]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-0539335e]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-0539335e]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-0539335e]{background:#11191c66}.input-glass[data-v-0539335e]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-0539335e]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-0539335e]:focus{background:#fff}.dark .input-glass[data-v-0539335e]:focus{background:#ffffff1a}.input-glass[data-v-0539335e]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-0539335e]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-0539335e]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-0539335e]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-0539335e]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-0539335e]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-0539335e]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-0539335e]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-0539335e]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-0539335e]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-0539335e]{opacity:0}.dark .logo-glow[data-v-0539335e]{opacity:1}@keyframes float-0539335e{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-0539335e{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-0539335e{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-0539335e{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-0539335e]{animation:8s ease-in-out infinite pulse-slow-0539335e}.animate-pulse-slower[data-v-0539335e]{animation:10s ease-in-out infinite pulse-slower-0539335e}.animate-pulse-slowest[data-v-0539335e]{animation:12s ease-in-out infinite pulse-slowest-0539335e}@keyframes shake-0539335e{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-0539335e]{animation:.5s ease-in-out shake-0539335e}@keyframes logo-aura-cycle-0539335e{0%,to{filter:brightness()saturate()drop-shadow(0 0 7px #38bdf873)}25%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #6366f16b)}50%{filter:brightness()saturate(1.03)drop-shadow(0 0 8px #22d3ee73)}75%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #34d3996b)}}.logo-image-animated[data-v-0539335e]{will-change:filter;animation:6s ease-in-out infinite logo-aura-cycle-0539335e}.form-group[data-v-0539335e]{position:relative}.form-group:hover label[data-v-0539335e]{color:#aae8e8e6;transition:color .3s}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{h as s}from"./index-COnQNCNU.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
|
||||
import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,r(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
|
||||
+8
-8
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{n as e}from"./index-BFltqMtv.js";export{e as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{n as e}from"./index-COnQNCNU.js";export{e as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.plotly-chart[data-v-bf282927]{background:0 0!important}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.plotly-chart[data-v-54d032e1]{background:0 0!important}
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{t as e}from"./packets-C-dzvp0W.js";export{e as usePacketStore};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as n}from"./vue-router-Cr0wB7EX.js";import{t as r}from"./api-CbM6k1ZB.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-IofF4kUm.js";import{n}from"./pinia-BrpcNUEi.js";import{t as r}from"./api-DGrRo_Ft.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./system-BH4r-ii6.js";export{e as useSystemStore};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as e}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t}from"./system-CBL1eQwL.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
|
||||
import{o as e}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t}from"./system-BH4r-ii6.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{k as e,z as t}from"./runtime-core.esm-bundler-IofF4kUm.js";var n=`theme-preference`,r=t(`dark`),i=t(!1);function a(e){let t=document.documentElement;e===`dark`?t.classList.add(`dark`):t.classList.remove(`dark`)}function o(){if(i.value)return;let e=localStorage.getItem(n);e&&(e===`light`||e===`dark`)?r.value=e:window.matchMedia(`(prefers-color-scheme: light)`).matches?r.value=`light`:r.value=`dark`,a(r.value),i.value=!0}typeof window<`u`&&o(),e(r,e=>{localStorage.setItem(n,e),a(e)});function s(){return{theme:r,toggleTheme:()=>{r.value=r.value===`dark`?`light`:`dark`},setTheme:e=>{r.value=e},isDark:()=>r.value===`dark`}}export{s as t};
|
||||
import{k as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";var n=`theme-preference`,r=t(`dark`),i=t(!1);function a(e){let t=document.documentElement;e===`dark`?t.classList.add(`dark`):t.classList.remove(`dark`)}function o(){if(i.value)return;let e=localStorage.getItem(n);e&&(e===`light`||e===`dark`)?r.value=e:window.matchMedia(`(prefers-color-scheme: light)`).matches?r.value=`light`:r.value=`dark`,a(r.value),i.value=!0}typeof window<`u`&&o(),e(r,e=>{localStorage.setItem(n,e),a(e)});function s(){return{theme:r,toggleTheme:()=>{r.value=r.value===`dark`?`light`:`dark`},setTheme:e=>{r.value=e},isDark:()=>r.value===`dark`}}export{s as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{t as e}from"./websocket-nXR7EYbj.js";export{e as useWebSocketStore};
|
||||
@@ -0,0 +1 @@
|
||||
import{o as e,z as t}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as n}from"./vue-router-Cr0wB7EX.js";import{c as r,d as i,r as a,s as o}from"./api-CbM6k1ZB.js";import{t as s}from"./system-BH4r-ii6.js";import{t as c}from"./packets-C-dzvp0W.js";var l=n(`websocket`,()=>{let n=t(null),l=t(`idle`),u=t(0),d=t(Date.now()),f=t(null),p=t(null),m=t(!1),h=t(!1),g=t(!1),_=t({visible:!1,message:``,variant:`info`}),v=null,y=c(),b=s(),x=a(),S=e(()=>l.value===`open`);function C(e,t,n=0){v!==null&&(clearTimeout(v),v=null),_.value={visible:!0,message:e,variant:t},n>0&&(v=window.setTimeout(()=>{w()},n))}function w(){v!==null&&(clearTimeout(v),v=null),_.value.visible=!1}function T(){f.value!==null&&(clearTimeout(f.value),f.value=null)}function E(){p.value!==null&&(clearInterval(p.value),p.value=null)}function D(){C(`Reconnecting...`,`info`)}function O(){let e=r();return!m.value&&!h.value&&!!e&&!i()&&x.canMaintainConnections}function k(){let e,t=r(),n=o(),i=new URLSearchParams;return t&&i.set(`token`,t),n&&i.set(`client_id`,n),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function A(){await Promise.allSettled([b.fetchStats(),y.fetchSystemStats(),y.fetchPacketStats({hours:24}),y.fetchRecentPackets({limit:100}),y.initializeSparklineHistory()])}function j(e=!1){E(),n.value&&e&&(n.value.onopen=null,n.value.onmessage=null,n.value.onerror=null,n.value.onclose=null)}function M(){if(T(),!O()){l.value=`closed`;return}if(u.value>=6){l.value=`closed`,C(`Connection lost`,`error`,5e3);return}l.value=`reconnecting`,D();let e=Math.min(1e3*2**u.value,3e4);u.value+=1,f.value=window.setTimeout(()=>{f.value=null,N(!0)},e)}function N(e=!1){if(!O()||n.value?.readyState===WebSocket.OPEN||n.value?.readyState===WebSocket.CONNECTING)return;T(),j(!0),l.value=e||u.value>0||g.value?`reconnecting`:`connecting`,g.value&&D();let t=new WebSocket(k());n.value=t,t.onopen=()=>{l.value=`open`,d.value=Date.now();let e=u.value>0||g.value;u.value=0,g.value=!1,E(),p.value=window.setInterval(()=>{n.value?.readyState===WebSocket.OPEN&&(n.value.send(JSON.stringify({type:`ping`})),Date.now()-d.value>6e4&&(j(!0),n.value?.close()))},3e4),A(),e?C(`Back online`,`success`,2500):w()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?y.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&y.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&b.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?y.updateRealtimeStats(t.data):t.type===`system_stats`?b.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(d.value=Date.now(),t.type===`ping`&&n.value?.readyState===WebSocket.OPEN&&n.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{l.value=u.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=n.value;if(j(),t===n.value&&(n.value=null),m.value||h.value){l.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){x.handleAuthFailure(`expired`);return}M()}}function P(e=`lifecycle`){if(h.value=!0,T(),l.value=`closed`,e===`offline`?(g.value=!0,C(`Connection lost`,`error`,4e3)):e===`hidden`?(g.value=!0,w()):e===`logout`&&(g.value=!1,w()),n.value){let e=n.value;n.value=null,j(!0),e.close()}}function F(){m.value=!1,h.value=!1}function I(e={}){m.value=e.preventReconnect??m.value,e.silent||w(),P(e.preventReconnect?`logout`:`lifecycle`),u.value=0}return{isConnected:S,connectionState:l,reconnectAttempts:u,snackbar:_,connect:N,disconnect:I,pause:P,allowReconnect:F,hideSnackbar:w,resyncData:A}});export{l as t};
|
||||
@@ -8,17 +8,17 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-COnQNCNU.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-V-yks4gF.js">
|
||||
<script type="module" crossorigin src="/assets/index-BFltqMtv.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B7aGp3iI.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-DECur_0Z.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-IofF4kUm.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue-router-BsDVl_JC.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-DGrRo_Ft.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pinia-BrpcNUEi.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useTheme-Dlt6-wEf.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/packets-BgkeSYWF.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-CBL1eQwL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B4rh8qna.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-HnidnMFy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue-router-Cr0wB7EX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-CbM6k1ZB.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useTheme-DMOVV09x.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/packets-C-dzvp0W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-BH4r-ii6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/websocket-nXR7EYbj.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Crl6CjFg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -432,10 +432,17 @@ class HTTPStatsServer:
|
||||
config["/_next"]["cors.expose.on"] = True
|
||||
config["/favicon.ico"]["cors.expose.on"] = True
|
||||
|
||||
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
|
||||
thread_pool = max(2, int(http_cfg.get("thread_pool", 8)))
|
||||
thread_pool_max = max(thread_pool, int(http_cfg.get("thread_pool_max", 16)))
|
||||
socket_timeout = max(15, int(http_cfg.get("socket_timeout", 65)))
|
||||
socket_queue_size = max(10, int(http_cfg.get("socket_queue_size", 100)))
|
||||
|
||||
cherrypy.config.update(
|
||||
{
|
||||
"server.socket_host": self.host,
|
||||
"server.socket_port": self.port,
|
||||
"server.socket_queue_size": socket_queue_size,
|
||||
"engine.autoreload.on": False,
|
||||
"log.screen": False,
|
||||
"log.access_file": "", # Disable access log file
|
||||
@@ -447,8 +454,22 @@ class HTTPStatsServer:
|
||||
# Add auth handlers to config so they're accessible in endpoints
|
||||
"jwt_handler": self.jwt_handler,
|
||||
"token_manager": self.token_manager,
|
||||
# Bound the thread pool to prevent unbounded growth.
|
||||
# SSE streams each hold one thread; allow headroom for concurrent
|
||||
# SSE clients plus normal API polling without growing unboundedly.
|
||||
"server.thread_pool": thread_pool,
|
||||
"server.thread_pool_max": thread_pool_max,
|
||||
# Close idle/stale connections so their threads return to the pool.
|
||||
"server.socket_timeout": socket_timeout,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"HTTP worker config: thread_pool=%s, thread_pool_max=%s, socket_timeout=%ss, socket_queue_size=%s",
|
||||
thread_pool,
|
||||
thread_pool_max,
|
||||
socket_timeout,
|
||||
socket_queue_size,
|
||||
)
|
||||
|
||||
# Mount main app
|
||||
cherrypy.tree.mount(self.app, "/", config)
|
||||
|
||||
@@ -45,6 +45,10 @@ PACKAGE_NAME = "pymc_repeater"
|
||||
CHECK_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
_github_ssl_ctx: Optional[ssl.SSLContext] = None
|
||||
_disk_version_mismatch_logged: Optional[tuple] = None
|
||||
_DISK_VERSION_MISMATCH_LOG_TTL = 300 # seconds
|
||||
_installed_version_cache: Optional[tuple] = None
|
||||
_INSTALLED_VERSION_CACHE_TTL = 15 # seconds
|
||||
|
||||
|
||||
def _get_github_ssl_context() -> ssl.SSLContext:
|
||||
@@ -61,7 +65,7 @@ class _RateLimitError(Exception):
|
||||
self.reset_at = reset_at
|
||||
|
||||
|
||||
def _get_installed_version() -> str:
|
||||
def _get_installed_version(force_refresh: bool = False) -> str:
|
||||
"""
|
||||
Return the highest dist-info version found for pymc_repeater across all
|
||||
directories the running interpreter actually uses.
|
||||
@@ -79,6 +83,20 @@ def _get_installed_version() -> str:
|
||||
import site as _site
|
||||
import sys
|
||||
|
||||
global _installed_version_cache
|
||||
now = time.time()
|
||||
if (
|
||||
not force_refresh
|
||||
and _installed_version_cache is not None
|
||||
and (now - _installed_version_cache[1]) < _INSTALLED_VERSION_CACHE_TTL
|
||||
):
|
||||
return _installed_version_cache[0]
|
||||
|
||||
def _cache_and_return(value: str) -> str:
|
||||
global _installed_version_cache
|
||||
_installed_version_cache = (value, now)
|
||||
return value
|
||||
|
||||
# -- 1. Collect candidate directories ---------------------------------- #
|
||||
dirs: list = []
|
||||
try:
|
||||
@@ -142,9 +160,9 @@ def _get_installed_version() -> str:
|
||||
if disk_version is None:
|
||||
try:
|
||||
from repeater import __version__
|
||||
return __version__
|
||||
return _cache_and_return(__version__)
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return _cache_and_return("unknown")
|
||||
|
||||
# -- 5. Sanity check: never return a version older than what's running -- #
|
||||
# If the running process is already on a higher version than anything found
|
||||
@@ -153,17 +171,33 @@ def _get_installed_version() -> str:
|
||||
from repeater import __version__ as _running
|
||||
from packaging.version import Version
|
||||
if Version(_running) > Version(disk_version):
|
||||
logger.debug(
|
||||
f"[Update] Disk version {disk_version!r} < running {_running!r};"
|
||||
" using running __version__ as installed version."
|
||||
)
|
||||
# status() polls can call this frequently; throttle mismatch logs.
|
||||
global _disk_version_mismatch_logged
|
||||
now = time.time()
|
||||
should_log = True
|
||||
if _disk_version_mismatch_logged is not None:
|
||||
last_disk, last_running, last_ts = _disk_version_mismatch_logged
|
||||
if (
|
||||
last_disk == disk_version
|
||||
and last_running == _running
|
||||
and (now - last_ts) < _DISK_VERSION_MISMATCH_LOG_TTL
|
||||
):
|
||||
should_log = False
|
||||
|
||||
if should_log:
|
||||
logger.debug(
|
||||
f"[Update] Disk version {disk_version!r} < running {_running!r};"
|
||||
" using running __version__ as installed version."
|
||||
)
|
||||
_disk_version_mismatch_logged = (disk_version, _running, now)
|
||||
|
||||
# Strip PEP 440 local identifier (+gXXXXXX) – it only encodes
|
||||
# the git hash and causes spurious mismatches with GitHub versions.
|
||||
return re.sub(r'\+[a-zA-Z0-9.]+$', '', _running)
|
||||
return _cache_and_return(re.sub(r'\+[a-zA-Z0-9.]+$', '', _running))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return re.sub(r'\+[a-zA-Z0-9.]+$', '', disk_version)
|
||||
return _cache_and_return(re.sub(r'\+[a-zA-Z0-9.]+$', '', disk_version))
|
||||
|
||||
# Channels file – persisted so the choice survives daemon restarts
|
||||
_CHANNELS_FILE = "/var/lib/pymc_repeater/.update_channel"
|
||||
@@ -476,7 +510,7 @@ def _parse_dev_number(version_str: str) -> Optional[int]:
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _cleanup_stale_dist_info() -> None:
|
||||
def _cleanup_stale_dist_info(allow_sudo: bool = True) -> None:
|
||||
import glob
|
||||
import shutil
|
||||
import site as _site
|
||||
@@ -524,6 +558,7 @@ def _cleanup_stale_dist_info() -> None:
|
||||
except Exception:
|
||||
return # can't determine winner safely — leave everything alone
|
||||
|
||||
removed_any = False
|
||||
for path, ver in found.items():
|
||||
if path == keep:
|
||||
continue
|
||||
@@ -531,7 +566,13 @@ def _cleanup_stale_dist_info() -> None:
|
||||
shutil.rmtree(path)
|
||||
logger.info(f"[Update] Removed stale dist-info: {path} (version {ver})")
|
||||
_state.append_line(f"[pyMC updater] Removed stale dist-info: {os.path.basename(path)}")
|
||||
removed_any = True
|
||||
except PermissionError:
|
||||
if not allow_sudo:
|
||||
logger.debug(
|
||||
f"[Update] Skipping stale dist-info cleanup without sudo permissions: {path}"
|
||||
)
|
||||
continue
|
||||
# dist-info is root-owned (pip ran via sudo); use sudo to remove
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -540,11 +581,28 @@ def _cleanup_stale_dist_info() -> None:
|
||||
)
|
||||
logger.info(f"[Update] Removed stale dist-info (sudo): {path} (version {ver})")
|
||||
_state.append_line(f"[pyMC updater] Removed stale dist-info: {os.path.basename(path)}")
|
||||
removed_any = True
|
||||
except Exception as exc2:
|
||||
logger.warning(f"[Update] Could not remove stale dist-info {path}: {exc2}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Update] Could not remove stale dist-info {path}: {exc}")
|
||||
|
||||
if removed_any:
|
||||
global _installed_version_cache
|
||||
_installed_version_cache = None
|
||||
|
||||
|
||||
def _startup_dist_info_cleanup() -> None:
|
||||
"""Best-effort cleanup during startup without sudo escalation."""
|
||||
try:
|
||||
_cleanup_stale_dist_info(allow_sudo=False)
|
||||
fresh = _get_installed_version(force_refresh=True)
|
||||
if fresh != "unknown":
|
||||
with _state._lock:
|
||||
_state.current_version = fresh
|
||||
except Exception as exc:
|
||||
logger.debug(f"[Update] Startup dist-info cleanup skipped: {exc}")
|
||||
|
||||
|
||||
def _has_update(installed: str, latest: str) -> bool:
|
||||
"""
|
||||
@@ -685,6 +743,10 @@ def _migrate_service_unit() -> None:
|
||||
"""Strip legacy PYTHONPATH, fix WorkingDirectory, and ensure ExecStart
|
||||
uses the venv python in the systemd service unit.
|
||||
"""
|
||||
if os.path.exists("/etc/pymc-image-build-id"):
|
||||
logger.info("[Update] Buildroot image detected, skipping systemd unit migration.")
|
||||
return
|
||||
|
||||
import subprocess as _sp
|
||||
_SVC_UNIT = "/etc/systemd/system/pymc-repeater.service"
|
||||
_VENV_PYTHON = "/opt/pymc_repeater/venv/bin/python"
|
||||
@@ -814,6 +876,9 @@ def _do_install() -> None:
|
||||
_state.finish_install(False, "pip install failed – see progress log for details")
|
||||
|
||||
|
||||
_startup_dist_info_cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CherryPy Endpoint class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Tests for PacketRouter in-flight cap and shutdown behaviour.
|
||||
Addresses the three concerns raised in PR 191 review:
|
||||
|
||||
1. Cap enforcement: packets beyond _max_in_flight are dropped, not queued.
|
||||
2. Drop counter: _cap_drop_count increments on each cap-drop so operators
|
||||
have visibility into how often the safety valve fires.
|
||||
3. Shutdown drain: stop() waits for in-flight tasks to finish (up to 5 s),
|
||||
then cancels any that remain — tasks are never silently abandoned.
|
||||
|
||||
Run with:
|
||||
python -m pytest tests/test_packet_router.py -v
|
||||
or:
|
||||
python -m unittest tests.test_packet_router -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
|
||||
from repeater.packet_router import PacketRouter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal daemon stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_daemon():
|
||||
"""Minimal daemon that satisfies PacketRouter without touching hardware."""
|
||||
daemon = MagicMock()
|
||||
daemon.repeater_handler = AsyncMock(return_value=None)
|
||||
daemon.trace_helper = None
|
||||
daemon.discovery_helper = None
|
||||
daemon.advert_helper = None
|
||||
daemon.companion_bridges = {}
|
||||
daemon.login_helper = None
|
||||
daemon.text_helper = None
|
||||
daemon.path_helper = None
|
||||
daemon.protocol_request_helper = None
|
||||
return daemon
|
||||
|
||||
|
||||
def _make_packet(payload_type: int = 0xFF):
|
||||
"""Minimal packet stub."""
|
||||
pkt = MagicMock()
|
||||
pkt.get_payload_type.return_value = payload_type
|
||||
pkt.payload = b"\xff"
|
||||
pkt.header = 0x00
|
||||
pkt.rssi = -80
|
||||
pkt.snr = 5.0
|
||||
pkt.timestamp = time.time()
|
||||
pkt._injected_for_tx = False
|
||||
pkt.path = bytearray()
|
||||
return pkt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
# ── 1. Cap enforcement ──────────────────────────────────────────────────
|
||||
|
||||
async def test_cap_drops_packets_when_full(self):
|
||||
"""
|
||||
When _in_flight reaches _max_in_flight, new packets from the queue
|
||||
must be dropped (not passed to _route_packet).
|
||||
"""
|
||||
router = PacketRouter(_make_daemon())
|
||||
router._max_in_flight = 3
|
||||
|
||||
# Manually occupy all slots with long-sleeping tasks
|
||||
barrier = asyncio.Event()
|
||||
|
||||
async def slow_route(pkt):
|
||||
await barrier.wait() # blocks until we release
|
||||
|
||||
routed = []
|
||||
|
||||
async def counting_route(pkt):
|
||||
routed.append(pkt)
|
||||
await barrier.wait()
|
||||
|
||||
router._route_packet = counting_route
|
||||
|
||||
await router.start()
|
||||
|
||||
# Fill the cap
|
||||
for _ in range(3):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05) # let queue drain into tasks
|
||||
self.assertEqual(router._in_flight, 3)
|
||||
|
||||
# These should be dropped
|
||||
for _ in range(5):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
self.assertEqual(router._in_flight, 3,
|
||||
"In-flight count exceeded cap")
|
||||
self.assertEqual(router._cap_drop_count, 5,
|
||||
"Expected 5 cap-drops, got different count")
|
||||
|
||||
barrier.set() # release blocked tasks
|
||||
await router.stop()
|
||||
|
||||
# ── 2. Drop counter ─────────────────────────────────────────────────────
|
||||
|
||||
async def test_cap_drop_count_increments(self):
|
||||
"""_cap_drop_count must increment by exactly 1 for each dropped packet."""
|
||||
router = PacketRouter(_make_daemon())
|
||||
router._max_in_flight = 1
|
||||
|
||||
barrier = asyncio.Event()
|
||||
|
||||
async def blocking_route(pkt):
|
||||
await barrier.wait()
|
||||
|
||||
router._route_packet = blocking_route
|
||||
|
||||
await router.start()
|
||||
|
||||
# Fill the single slot
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(router._in_flight, 1)
|
||||
|
||||
# Drop three packets
|
||||
for _ in range(3):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
self.assertEqual(router._cap_drop_count, 3)
|
||||
|
||||
barrier.set()
|
||||
await router.stop()
|
||||
|
||||
async def test_cap_drop_count_zero_when_cap_not_reached(self):
|
||||
"""_cap_drop_count must stay 0 when the cap is never reached."""
|
||||
router = PacketRouter(_make_daemon())
|
||||
router._max_in_flight = 30
|
||||
|
||||
completed = []
|
||||
|
||||
async def fast_route(pkt):
|
||||
completed.append(pkt)
|
||||
|
||||
router._route_packet = fast_route
|
||||
|
||||
await router.start()
|
||||
|
||||
for _ in range(10):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self.assertEqual(router._cap_drop_count, 0)
|
||||
await router.stop()
|
||||
|
||||
async def test_injected_trace_packet_skips_inbound_trace_processing(self):
|
||||
"""Locally injected TRACE packets must not be re-parsed as inbound trace responses."""
|
||||
daemon = _make_daemon()
|
||||
daemon.trace_helper = MagicMock()
|
||||
daemon.trace_helper.process_trace_packet = AsyncMock()
|
||||
|
||||
router = PacketRouter(daemon)
|
||||
pkt = _make_packet(payload_type=TraceHandler.payload_type())
|
||||
|
||||
await router.start()
|
||||
try:
|
||||
injected = await router.inject_packet(pkt)
|
||||
self.assertTrue(injected)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
daemon.repeater_handler.assert_awaited_once()
|
||||
daemon.trace_helper.process_trace_packet.assert_not_awaited()
|
||||
finally:
|
||||
await router.stop()
|
||||
|
||||
# ── 3. Shutdown: in-flight tasks drained ────────────────────────────────
|
||||
|
||||
async def test_stop_waits_for_in_flight_tasks(self):
|
||||
"""
|
||||
stop() must wait for in-flight tasks to complete before returning.
|
||||
Tasks that finish within the 5-second timeout must complete normally,
|
||||
not be cancelled.
|
||||
"""
|
||||
router = PacketRouter(_make_daemon())
|
||||
|
||||
completed = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def slow_route(pkt):
|
||||
started.set()
|
||||
await asyncio.sleep(0.2) # finishes well within 5 s timeout
|
||||
completed.append(pkt)
|
||||
|
||||
router._route_packet = slow_route
|
||||
|
||||
await router.start()
|
||||
pkt = _make_packet()
|
||||
await router.enqueue(pkt)
|
||||
|
||||
# Wait until the task has actually started
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
|
||||
await router.stop()
|
||||
|
||||
# Task should have completed, not been cancelled
|
||||
self.assertEqual(len(completed), 1,
|
||||
"In-flight task was cancelled instead of drained")
|
||||
|
||||
async def test_stop_cancels_tasks_that_exceed_timeout(self):
|
||||
"""
|
||||
Tasks that don't finish within the 5-second timeout must be cancelled,
|
||||
not left running indefinitely.
|
||||
"""
|
||||
router = PacketRouter(_make_daemon())
|
||||
router._max_in_flight = 5
|
||||
|
||||
cancelled = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def hanging_route(pkt):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(999) # will not finish within 5 s
|
||||
except asyncio.CancelledError:
|
||||
cancelled.append(pkt)
|
||||
raise
|
||||
|
||||
router._route_packet = hanging_route
|
||||
|
||||
# Patch the timeout to 0.1 s so the test runs fast
|
||||
original_stop = router.stop
|
||||
|
||||
async def fast_stop():
|
||||
router.running = False
|
||||
if router.router_task:
|
||||
router.router_task.cancel()
|
||||
try:
|
||||
await router.router_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if router._route_tasks:
|
||||
snapshot = set(router._route_tasks)
|
||||
_, still_pending = await asyncio.wait(snapshot, timeout=0.1)
|
||||
for task in still_pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*still_pending, return_exceptions=True)
|
||||
|
||||
router.stop = fast_stop
|
||||
|
||||
await router.start()
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||
|
||||
await router.stop()
|
||||
|
||||
self.assertEqual(len(cancelled), 1,
|
||||
"Hanging task was not cancelled on shutdown")
|
||||
|
||||
# ── 4. Route-tasks set stays in sync with counter ───────────────────────
|
||||
|
||||
async def test_route_tasks_set_cleaned_up_on_completion(self):
|
||||
"""
|
||||
_route_tasks must be empty after all tasks complete — the done-callback
|
||||
must discard each task so the set doesn't grow unboundedly.
|
||||
"""
|
||||
router = PacketRouter(_make_daemon())
|
||||
|
||||
async def fast_route(pkt):
|
||||
await asyncio.sleep(0) # yield, then done
|
||||
|
||||
router._route_packet = fast_route
|
||||
|
||||
await router.start()
|
||||
|
||||
for _ in range(10):
|
||||
await router.enqueue(_make_packet())
|
||||
|
||||
# Give tasks time to complete
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self.assertEqual(len(router._route_tasks), 0,
|
||||
"_route_tasks not cleaned up after task completion")
|
||||
self.assertEqual(router._in_flight, 0,
|
||||
"_in_flight counter not back to 0 after completion")
|
||||
|
||||
await router.stop()
|
||||
|
||||
# ── 5. Counter and set always agree ─────────────────────────────────────
|
||||
|
||||
async def test_counter_matches_set_size_under_load(self):
|
||||
"""
|
||||
_in_flight must always equal len(_route_tasks) while tasks are running.
|
||||
Checked at steady state when the cap is saturated.
|
||||
"""
|
||||
router = PacketRouter(_make_daemon())
|
||||
router._max_in_flight = 5
|
||||
|
||||
barrier = asyncio.Event()
|
||||
|
||||
async def blocking_route(pkt):
|
||||
await barrier.wait()
|
||||
|
||||
router._route_packet = blocking_route
|
||||
|
||||
await router.start()
|
||||
|
||||
for _ in range(5):
|
||||
await router.enqueue(_make_packet())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
self.assertEqual(
|
||||
router._in_flight, len(router._route_tasks),
|
||||
f"Counter ({router._in_flight}) != set size ({len(router._route_tasks)})"
|
||||
)
|
||||
|
||||
barrier.set()
|
||||
await router.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.modules.setdefault("psutil", types.ModuleType("psutil"))
|
||||
|
||||
nacl_module = types.ModuleType("nacl")
|
||||
nacl_signing_module = types.ModuleType("nacl.signing")
|
||||
|
||||
|
||||
class _SigningKeyStub:
|
||||
pass
|
||||
|
||||
|
||||
nacl_signing_module.SigningKey = _SigningKeyStub
|
||||
nacl_module.signing = nacl_signing_module
|
||||
|
||||
sys.modules.setdefault("nacl", nacl_module)
|
||||
sys.modules.setdefault("nacl.signing", nacl_signing_module)
|
||||
|
||||
from repeater.data_acquisition.storage_collector import StorageCollector
|
||||
|
||||
|
||||
def _make_collector() -> StorageCollector:
|
||||
with (
|
||||
patch("repeater.data_acquisition.storage_collector.SQLiteHandler"),
|
||||
patch("repeater.data_acquisition.storage_collector.RRDToolHandler"),
|
||||
patch("repeater.data_acquisition.hardware_stats.HardwareStatsCollector"),
|
||||
):
|
||||
collector = StorageCollector(config={"storage": {"storage_dir": "/tmp/pymc_repeater_test"}})
|
||||
|
||||
collector.sqlite_handler = MagicMock()
|
||||
collector.sqlite_handler.get_packet_stats.return_value = {"total_packets": 1}
|
||||
collector.websocket_available = True
|
||||
collector.websocket_broadcast_packet = MagicMock()
|
||||
collector.websocket_broadcast_stats = MagicMock()
|
||||
collector.websocket_has_connected_clients = MagicMock(return_value=True)
|
||||
collector.repeater_handler = SimpleNamespace(start_time=100.0)
|
||||
return collector
|
||||
|
||||
|
||||
def test_publish_packet_sync_first_call_broadcasts_stats_immediately():
|
||||
collector = _make_collector()
|
||||
|
||||
with patch("repeater.data_acquisition.storage_collector.time.monotonic", return_value=1000.0):
|
||||
collector._publish_packet_sync({"type": 1, "transmitted": True}, skip_mqtt=False)
|
||||
|
||||
assert collector.sqlite_handler.get_packet_stats.call_count == 1
|
||||
assert collector.websocket_broadcast_stats.call_count == 1
|
||||
assert collector.websocket_broadcast_packet.call_count == 1
|
||||
|
||||
|
||||
def test_publish_packet_sync_throttles_stats_to_interval():
|
||||
collector = _make_collector()
|
||||
call_times = [1000.0 + (i * 0.1) for i in range(10)] + [1005.1]
|
||||
|
||||
with patch(
|
||||
"repeater.data_acquisition.storage_collector.time.monotonic",
|
||||
side_effect=call_times,
|
||||
):
|
||||
for _ in call_times:
|
||||
collector._publish_packet_sync({"type": 1, "transmitted": True}, skip_mqtt=False)
|
||||
|
||||
assert collector.sqlite_handler.get_packet_stats.call_count == 2
|
||||
assert collector.websocket_broadcast_stats.call_count == 2
|
||||
assert collector.websocket_broadcast_packet.call_count == len(call_times)
|
||||
|
||||
|
||||
def test_publish_packet_sync_always_broadcasts_packet_event_even_without_clients():
|
||||
collector = _make_collector()
|
||||
collector.websocket_has_connected_clients.return_value = False
|
||||
|
||||
for _ in range(5):
|
||||
collector._publish_packet_sync({"type": 1, "transmitted": True}, skip_mqtt=False)
|
||||
|
||||
assert collector.websocket_broadcast_packet.call_count == 5
|
||||
assert collector.sqlite_handler.get_packet_stats.call_count == 0
|
||||
assert collector.websocket_broadcast_stats.call_count == 0
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Tests for TX lock serialisation and duty-cycle TOCTOU fix.
|
||||
Addresses the three concerns raised in PR 190 review:
|
||||
|
||||
1. Concurrent delayed_sends must not interleave send_packet calls.
|
||||
2. Duty-cycle TOCTOU must be fixed: the second packet is dropped when the
|
||||
first consumes the airtime budget inside the lock.
|
||||
3. Local retry must NOT hold _tx_lock during the 1-second backoff sleep —
|
||||
other queued packets must be able to transmit during that window.
|
||||
|
||||
Run with:
|
||||
python -m pytest tests/test_tx_lock.py -v
|
||||
or:
|
||||
python -m unittest tests.test_tx_lock
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal handler factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_handler():
|
||||
"""
|
||||
Return a RepeaterHandler instance with all external I/O mocked.
|
||||
|
||||
Uses __new__ + manual attribute injection to bypass StorageCollector,
|
||||
radio hardware, and other heavy dependencies that are irrelevant to the
|
||||
TX lock behaviour under test.
|
||||
"""
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
radio = MagicMock()
|
||||
radio.spreading_factor = 9
|
||||
radio.bandwidth = 62500
|
||||
radio.coding_rate = 5
|
||||
radio.preamble_length = 17
|
||||
radio.frequency = 915_000_000
|
||||
radio.tx_power = 14
|
||||
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.radio = radio
|
||||
dispatcher.local_identity = None
|
||||
dispatcher.send_packet = AsyncMock()
|
||||
|
||||
h = RepeaterHandler.__new__(RepeaterHandler)
|
||||
h.config = {
|
||||
"repeater": {"mode": "forward", "cache_ttl": 3600,
|
||||
"send_advert_interval_hours": 0},
|
||||
"delays": {"tx_delay_factor": 1.0, "direct_tx_delay_factor": 0.5},
|
||||
"duty_cycle": {"enforcement_enabled": True,
|
||||
"max_airtime_per_minute": 3600},
|
||||
"storage": {},
|
||||
"mesh": {},
|
||||
}
|
||||
h.dispatcher = dispatcher
|
||||
h.airtime_mgr = MagicMock()
|
||||
h.airtime_mgr.can_transmit.return_value = (True, 0.0)
|
||||
h.airtime_mgr.calculate_airtime.return_value = 100.0
|
||||
h._tx_lock = asyncio.Lock()
|
||||
h.sent_flood_count = 0
|
||||
h.sent_direct_count = 0
|
||||
# Stub out _record_packet_sent so it doesn't touch packet.header constants
|
||||
h._record_packet_sent = MagicMock()
|
||||
return h
|
||||
|
||||
|
||||
def _make_packet(size: int = 50) -> MagicMock:
|
||||
pkt = MagicMock()
|
||||
pkt.get_raw_length.return_value = size
|
||||
pkt.header = 0x00
|
||||
return pkt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTxLockSerialisation(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
# ── Test 1: no interleaving ─────────────────────────────────────────────
|
||||
|
||||
async def test_concurrent_sends_do_not_interleave(self):
|
||||
"""
|
||||
Two delayed_sends with identical delays race to the radio.
|
||||
send_packet must never be called while another call is already
|
||||
in-flight — i.e. _tx_lock must gate them sequentially.
|
||||
"""
|
||||
h = _make_handler()
|
||||
pkt = _make_packet()
|
||||
|
||||
in_flight = [False]
|
||||
overlap_detected = [False]
|
||||
|
||||
async def send_with_overlap_check(*args, **kwargs):
|
||||
if in_flight[0]:
|
||||
overlap_detected[0] = True
|
||||
in_flight[0] = True
|
||||
await asyncio.sleep(0.05) # simulate ~50ms radio TX
|
||||
in_flight[0] = False
|
||||
|
||||
h.dispatcher.send_packet.side_effect = send_with_overlap_check
|
||||
|
||||
# Both tasks use the same tiny delay so their timers expire together.
|
||||
t1 = await h.schedule_retransmit(pkt, delay=0.01, airtime_ms=0)
|
||||
t2 = await h.schedule_retransmit(pkt, delay=0.01, airtime_ms=0)
|
||||
await asyncio.gather(t1, t2, return_exceptions=True)
|
||||
|
||||
self.assertFalse(
|
||||
overlap_detected[0],
|
||||
"send_packet was entered while another call was already in-flight "
|
||||
"— _tx_lock is not serialising correctly",
|
||||
)
|
||||
self.assertEqual(h.dispatcher.send_packet.call_count, 2,
|
||||
"Expected exactly 2 send_packet calls")
|
||||
|
||||
# ── Test 2: TOCTOU duty-cycle fix ──────────────────────────────────────
|
||||
|
||||
async def test_duty_cycle_toctou_is_fixed(self):
|
||||
"""
|
||||
When two tasks both pass the advisory can_transmit() check in __call__
|
||||
before either has recorded airtime, the authoritative check inside
|
||||
_tx_lock must ensure only one of them actually transmits.
|
||||
|
||||
Simulated here by making can_transmit return True for the first
|
||||
in-lock check and False for every subsequent one.
|
||||
"""
|
||||
h = _make_handler()
|
||||
pkt = _make_packet()
|
||||
airtime_ms = 100.0
|
||||
|
||||
# First lock-holder gets True; second gets False (budget consumed).
|
||||
allow = [True]
|
||||
|
||||
def can_tx(ms):
|
||||
if allow[0]:
|
||||
allow[0] = False
|
||||
return (True, 0.0)
|
||||
return (False, 5.0)
|
||||
|
||||
h.airtime_mgr.can_transmit.side_effect = can_tx
|
||||
|
||||
# Both tasks start simultaneously (delay=0).
|
||||
t1 = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=airtime_ms)
|
||||
t2 = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=airtime_ms)
|
||||
await asyncio.gather(t1, t2, return_exceptions=True)
|
||||
|
||||
self.assertEqual(
|
||||
h.dispatcher.send_packet.call_count, 1,
|
||||
"Both packets were sent — duty-cycle TOCTOU race was NOT fixed",
|
||||
)
|
||||
|
||||
# ── Test 3: retry backoff does not hold the lock ────────────────────────
|
||||
|
||||
async def test_local_retry_releases_lock_during_backoff(self):
|
||||
"""
|
||||
When a local_transmission send fails on the first attempt, the 1-second
|
||||
backoff sleep must happen with _tx_lock released.
|
||||
|
||||
We schedule:
|
||||
- pkt_local: local_transmission=True, delay=0s, fails on attempt 1
|
||||
- pkt_other: local_transmission=False, delay=0.1s
|
||||
|
||||
pkt_other fires at ~0.1s. Without the fix, the backoff sleep holds
|
||||
the lock until ~1.0s, so pkt_other would have to wait. With the fix,
|
||||
pkt_other sends freely at ~0.1s, well before pkt_local retries at ~1.0s.
|
||||
"""
|
||||
h = _make_handler()
|
||||
pkt_local = _make_packet()
|
||||
pkt_other = _make_packet()
|
||||
|
||||
send_times: dict[int, float] = {}
|
||||
first_local_call = [True]
|
||||
|
||||
async def tracked_send(*args, **kwargs):
|
||||
pkt = args[0]
|
||||
if pkt is pkt_local and first_local_call[0]:
|
||||
first_local_call[0] = False
|
||||
raise RuntimeError("simulated transient radio failure")
|
||||
send_times[id(pkt)] = time.monotonic()
|
||||
|
||||
h.dispatcher.send_packet.side_effect = tracked_send
|
||||
|
||||
t_local = await h.schedule_retransmit(
|
||||
pkt_local, delay=0.0, airtime_ms=0, local_transmission=True
|
||||
)
|
||||
t_other = await h.schedule_retransmit(
|
||||
pkt_other, delay=0.1, airtime_ms=0, local_transmission=False
|
||||
)
|
||||
await asyncio.gather(t_local, t_other, return_exceptions=True)
|
||||
|
||||
self.assertIn(id(pkt_other), send_times,
|
||||
"pkt_other was never sent")
|
||||
self.assertIn(id(pkt_local), send_times,
|
||||
"pkt_local retry was never sent")
|
||||
|
||||
# pkt_other fires at ~0.1s; pkt_local retry fires at ~1.0s.
|
||||
# If the lock were held during backoff, pkt_other would block until ~1.0s
|
||||
# and would be recorded AFTER pkt_local's retry — the assertion below
|
||||
# would fail.
|
||||
self.assertLess(
|
||||
send_times[id(pkt_other)],
|
||||
send_times[id(pkt_local)],
|
||||
"pkt_other sent AFTER pkt_local retry — "
|
||||
"_tx_lock was still held during the 1-second backoff sleep",
|
||||
)
|
||||
|
||||
# ── Test 4: non-local single-attempt re-raises on failure ──────────────
|
||||
|
||||
async def test_non_local_failure_propagates(self):
|
||||
"""A relayed (non-local) packet that fails send_packet raises immediately."""
|
||||
h = _make_handler()
|
||||
pkt = _make_packet()
|
||||
|
||||
h.dispatcher.send_packet.side_effect = RuntimeError("radio error")
|
||||
|
||||
task = await h.schedule_retransmit(pkt, delay=0.0, airtime_ms=0,
|
||||
local_transmission=False)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await task
|
||||
|
||||
# Only one attempt should have been made.
|
||||
self.assertEqual(h.dispatcher.send_packet.call_count, 1)
|
||||
|
||||
# ── Test 5: duty-cycle check re-runs after backoff ──────────────────────
|
||||
|
||||
async def test_duty_cycle_rechecked_on_retry(self):
|
||||
"""
|
||||
If the duty cycle is exhausted during the 1-second backoff, the retry
|
||||
attempt must still be dropped — i.e. the duty-cycle gate runs on every
|
||||
lock acquisition, not just the first.
|
||||
"""
|
||||
h = _make_handler()
|
||||
pkt = _make_packet()
|
||||
|
||||
# First attempt: send_packet raises → triggers backoff.
|
||||
# Between attempts the budget is consumed, so the retry lock sees False.
|
||||
transmit_seq = iter([(True, 0.0), (False, 5.0)])
|
||||
h.airtime_mgr.can_transmit.side_effect = lambda ms: next(transmit_seq)
|
||||
|
||||
send_calls = [0]
|
||||
|
||||
async def failing_then_gone(*args, **kwargs):
|
||||
send_calls[0] += 1
|
||||
if send_calls[0] == 1:
|
||||
raise RuntimeError("transient failure")
|
||||
# Should not reach here on attempt 1 (gate rejects)
|
||||
pytest_fail = AssertionError("send_packet called on attempt 1 despite gate rejection")
|
||||
raise pytest_fail
|
||||
|
||||
h.dispatcher.send_packet.side_effect = failing_then_gone
|
||||
|
||||
task = await h.schedule_retransmit(
|
||||
pkt, delay=0.0, airtime_ms=100.0, local_transmission=True
|
||||
)
|
||||
await task # should complete without error (gate returns silently)
|
||||
|
||||
self.assertEqual(send_calls[0], 1,
|
||||
"send_packet called on retry despite duty-cycle rejection")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user