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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user