feat: wedge detection and automatic recovery orchestration

Adds the recovery layer for the NL-43 TCP wedge failure mode (control
listener on 2255 dies while device/FTP stay alive — see
SLM-stress-test/NL43_RX55_TCP_Wedge_Investigation_2026-02-18.md).

- Failure classification: poll failures probed raw and classified as
  wedged (refused = stack alive, listener gone) vs offline (timeout).
  FTP-port probe upgrades wedge confidence to definitive.
- RecoveryManager state machine: confirm wedge -> trigger reset ->
  wait boot -> reconnect -> resume measurement via start_cycle (keeps
  overwrite protection) -> full incident trail in device logs.
- Pluggable reset backends: manual (notify, default) and webhook
  (HTTP to relay controller: Pi GPIO, Shelly, Tasmota, etc.).
- Safety: auto_recovery_enabled off by default, 2-failure confirmation
  threshold, windowed attempt limit (2 per 6h), one recovery per device.
- Endpoints: GET recovery/status, POST recovery/probe (diagnostic),
  POST recovery/trigger (manual, bypasses threshold).
- Migration: migrate_add_recovery_fields.py
- Tests: test_wedge_recovery.py — fake wedge-able NL-43 + fake relay
  webhook; 23 assertions covering classification, end-to-end recovery
  with resume, and gating. All passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 05:03:25 +00:00
parent 450509d210
commit 3c22f1d70c
7 changed files with 1205 additions and 0 deletions
+57
View File
@@ -17,6 +17,7 @@ from app.database import SessionLocal
from app.models import NL43Config, NL43Status
from app.services import NL43Client, persist_snapshot, sync_measurement_start_time_from_ftp
from app.device_logger import log_device_event, cleanup_old_logs
from app.recovery import classify_failure, recovery_manager
logger = logging.getLogger(__name__)
@@ -231,6 +232,7 @@ class BackgroundPoller:
status.consecutive_failures = 0
status.last_success = datetime.utcnow()
status.last_error = None
status.connection_state = "ok"
db.commit()
self._logger.info(f"✓ Successfully polled {unit_id}")
@@ -322,6 +324,61 @@ class BackgroundPoller:
db.commit()
# Classify the failure: wedge (listener dead, host alive) vs offline
await self._classify_and_recover(cfg, status, db)
async def _classify_and_recover(self, cfg: NL43Config, status: NL43Status, db: Session):
"""Probe the device to classify a poll failure and trigger wedge recovery.
The wedge signature (see SLM-stress-test investigation doc): control
port REFUSES connections (RST = host stack alive, listener gone) while
the device is otherwise up. A timeout means offline (power/cell loss).
"""
unit_id = cfg.unit_id
# Skip while a recovery for this device is already running — its own
# probes manage state until it finishes.
if recovery_manager.is_active(unit_id):
return
try:
classification = await classify_failure(
cfg.host,
cfg.tcp_port,
ftp_port=(cfg.ftp_port or 21) if cfg.ftp_enabled else None,
)
except Exception as e:
self._logger.warning(f"Failure classification error for {unit_id}: {e}")
return
new_state = classification["state"]
prev_state = status.connection_state or "ok"
if new_state != prev_state:
status.connection_state = new_state
if new_state == "wedged":
status.last_wedge_at = datetime.utcnow()
status.wedge_count = (status.wedge_count or 0) + 1
log_device_event(
unit_id, "ERROR", "RECOVERY",
f"WEDGE detected: control port {cfg.tcp_port} refused "
f"(tcp_probe={classification['tcp_probe']}, ftp_probe={classification['ftp_probe']}, "
f"confidence={classification['confidence']})",
db,
)
self._logger.error(f"Device {unit_id} classified as WEDGED ({classification})")
elif new_state == "offline":
log_device_event(
unit_id, "WARNING", "RECOVERY",
f"Device classified OFFLINE (tcp_probe={classification['tcp_probe']}) - "
f"not a wedge, no reset will be triggered",
db,
)
db.commit()
if new_state == "wedged":
recovery_manager.maybe_start_recovery(cfg, status, classification, db)
def _calculate_sleep_interval(self) -> int:
"""
Calculate the next sleep interval based on all device poll intervals.