Merge pull request #202 from rightup/fix-perfom-speed

Fix perfom speed
This commit is contained in:
Lloyd
2026-04-24 16:03:33 +01:00
committed by GitHub
92 changed files with 5475 additions and 795 deletions
+1286
View File
File diff suppressed because it is too large Load Diff
+38 -93
View File
@@ -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
+346
View File
@@ -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 50200 byte
LoRa payload takes approximately 13 µs. The `.hex().upper()` string conversion
adds another ~0.5 µs. Savings per forwarded packet: ~38 µs.
At 3 packets/second sustained forwarding rate this saves ~1025 µ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.
+349
View File
@@ -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 510 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 ~23 packets per second; with delays of 0.55 s each, the
steady-state in-flight count is at most 515 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 34 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.53 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.
+395
View File
@@ -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 ~12 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.
+76
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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"]
+44 -6
View File
@@ -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
+35 -9
View File
@@ -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:
+398 -194
View File
@@ -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 520 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
+172 -78
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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)
)
+14 -14
View File
@@ -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
+17 -1
View File
@@ -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")
+17 -1
View File
@@ -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")
+1 -1
View File
@@ -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":
+1 -1
View File
@@ -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":
+17 -1
View File
@@ -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
View File
@@ -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", []):
+83 -5
View File
@@ -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
+38 -3
View File
@@ -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
View File
@@ -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)
}
+9 -5
View File
@@ -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 @@
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};
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 @@
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 &amp; 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 &amp; 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 @@
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};
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};
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
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
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 @@
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 @@
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};
+10 -10
View File
@@ -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>
+21
View File
@@ -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)
+75 -10
View File
@@ -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
# ---------------------------------------------------------------------------
+328
View File
@@ -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
+267
View File
@@ -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()