fix(vertex2): fiabiliser les lectures +5V et enrichir les diagnostics TCP

- Regrouper les demandes de lecture +5V et ignorer les callbacks obsolètes.
- Retenter la lecture après 5 secondes au premier échec et journaliser le rétablissement.
- Annuler les lectures planifiées à la réinitialisation, à la suspension et à l’arrêt.
- Détailler les timeouts TCP : champs reçus et manquants, octets reçus, interruptions et dernières lignes.
This commit is contained in:
Pierre committed 2026-11-10 19:30:18 +01:00
1 parent b76f107f01
commit ff39f4cb2f
1 file changed
+63 -6
+63 -6
View File
@@ -7,6 +7,7 @@ import time
import json import json
import socket import socket
import threading import threading
from collections import deque
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -14,6 +15,7 @@ class Vertex2(SmartObject):
def on_initialize_smart_object(self): def on_initialize_smart_object(self):
# HASS can call initialize() again without calling terminate() first. # HASS can call initialize() again without calling terminate() first.
self._stop_interrupt_listener() self._stop_interrupt_listener()
self._cancel_input_5v_refresh()
self._interrupt_stop = threading.Event() self._interrupt_stop = threading.Event()
self._status_requested = threading.Event() self._status_requested = threading.Event()
self._last_logged_status = None self._last_logged_status = None
@@ -217,9 +219,28 @@ class Vertex2(SmartObject):
if sensor.get_state() != 'unavailable': if sensor.get_state() != 'unavailable':
sensor.set_state(state='unavailable') sensor.set_state(state='unavailable')
def _schedule_input_5v_refresh(self): def _cancel_input_5v_refresh(self):
# Device monitoring must run even when no HA input sensors are exposed. handle = getattr(self, '_input_5v_refresh_handle', None)
self.run_in(self._refresh_input_5v_sensors, 0.1) if handle is not None:
self.cancel_timer(handle, silent=True)
self._input_5v_refresh_handle = None
self._input_5v_refresh_generation = getattr(self, '_input_5v_refresh_generation', 0) + 1
self._input_5v_failure_since = None
self._input_5v_failures = 0
def _schedule_input_5v_refresh(self, delay=1):
# Coalesce bursts without postponing a pending retry or starving reads.
if self._input_5v_refresh_handle is not None:
return
self._input_5v_refresh_generation += 1
self._input_5v_refresh_handle = self.run_in(
self._on_input_5v_refresh, delay, generation=self._input_5v_refresh_generation)
def _on_input_5v_refresh(self, kwargs):
if kwargs['generation'] != self._input_5v_refresh_generation:
return
self._input_5v_refresh_handle = None
self._refresh_input_5v_sensors()
def _refresh_input_5v_sensors(self, kwargs=None): def _refresh_input_5v_sensors(self, kwargs=None):
if not self._vertex2_ready(): if not self._vertex2_ready():
@@ -233,11 +254,27 @@ class Vertex2(SmartObject):
raise ValueError('expected an information object') raise ValueError('expected an information object')
except (RequestException, ValueError) as error: except (RequestException, ValueError) as error:
self._invalidate_input_5v_sensors() self._invalidate_input_5v_sensors()
self.log_warning(f'Vertex2 input +5V readback failed: {error}') if not self._vertex2_ready():
self._cancel_input_5v_refresh()
return
self._input_5v_failures += 1
if self._input_5v_failure_since is None:
self._input_5v_failure_since = time.monotonic()
log = self.log_warning if self._input_5v_failures == 2 else self.log_info
log(f'Vertex2 input +5V readback failed '
f'(attempt={self._input_5v_failures}, {type(error).__name__}): {error}')
if self._input_5v_failures == 1:
self._schedule_input_5v_refresh(delay=5)
return return
if not self._vertex2_ready(): if not self._vertex2_ready():
self._invalidate_input_5v_sensors() self._invalidate_input_5v_sensors()
return return
if self._input_5v_failure_since is not None:
self.log_info(
f'Vertex2 input +5V readback restored after '
f'{time.monotonic() - self._input_5v_failure_since:.1f}s '
f'({self._input_5v_failures} failed attempts)')
self._cancel_input_5v_refresh()
states = {} states = {}
for index in range(4): for index in range(4):
# Firmware uses 2 for present and 1 for absent, not boolean 1/0. # Firmware uses 2 for present and 1 for absent, not boolean 1/0.
@@ -442,11 +479,26 @@ class Vertex2(SmartObject):
status_started = None status_started = None
reconfigure = True reconfigure = True
pending_interrupt_at = None pending_interrupt_at = None
recent_lines = deque(maxlen=12)
received_bytes = 0
interrupt_count = 0
def status_timeout_error():
return OSError(
'Vertex2 TCP status response timed out '
f'(elapsed={time.monotonic() - status_started:.2f}s, limit=5s, '
f'received={sorted(status)}, missing={sorted(expected - status.keys())}, '
f'bytes={received_bytes}, interrupts={interrupt_count}, '
f'reconfigure={reconfigure}, '
f'recent_lines={list(recent_lines)!r}, partial_line={buffer[-256:]!r})')
while not interrupt_stop.is_set() and self._vertex2_powered(): while not interrupt_stop.is_set() and self._vertex2_powered():
if status_started is None and self._status_requested.is_set(): if status_started is None and self._status_requested.is_set():
self._status_requested.clear() self._status_requested.clear()
status = {} status = {}
recent_lines.clear()
received_bytes = 0
interrupt_count = 0
status_started = time.monotonic() status_started = time.monotonic()
request_interrupt_at = pending_interrupt_at request_interrupt_at = pending_interrupt_at
pending_interrupt_at = None pending_interrupt_at = None
@@ -457,7 +509,7 @@ class Vertex2(SmartObject):
data = connection.recv(1024) data = connection.recv(1024)
except socket.timeout: except socket.timeout:
if status_started is not None and time.monotonic() - status_started > 5: if status_started is not None and time.monotonic() - status_started > 5:
raise OSError('Vertex2 TCP status response timed out') raise status_timeout_error()
continue continue
if interrupt_stop.is_set() or not self._vertex2_powered(): if interrupt_stop.is_set() or not self._vertex2_powered():
@@ -465,6 +517,7 @@ class Vertex2(SmartObject):
if not data: if not data:
raise OSError('Vertex2 closed the IP INTERRUPT connection') raise OSError('Vertex2 closed the IP INTERRUPT connection')
received_bytes += len(data)
buffer += data.decode('ascii', errors='ignore') buffer += data.decode('ascii', errors='ignore')
if len(buffer) > 4096: if len(buffer) > 4096:
raise OSError('Vertex2 TCP response exceeded buffer limit') raise OSError('Vertex2 TCP response exceeded buffer limit')
@@ -472,7 +525,9 @@ class Vertex2(SmartObject):
while '\n' in buffer: while '\n' in buffer:
line, buffer = buffer.split('\n', 1) line, buffer = buffer.split('\n', 1)
line = line.strip() line = line.strip()
recent_lines.append(line[:256])
if line == 'int': if line == 'int':
interrupt_count += 1
pending_interrupt_at = time.monotonic() pending_interrupt_at = time.monotonic()
self._status_requested.set() self._status_requested.set()
elif status_started is not None: elif status_started is not None:
@@ -497,7 +552,7 @@ class Vertex2(SmartObject):
status_started = None status_started = None
if status_started is not None and time.monotonic() - status_started > 5: if status_started is not None and time.monotonic() - status_started > 5:
raise OSError('Vertex2 TCP status response timed out') raise status_timeout_error()
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
if interrupt_stop.is_set(): if interrupt_stop.is_set():
@@ -557,6 +612,7 @@ class Vertex2(SmartObject):
self._command_processing_handle = None self._command_processing_handle = None
def terminate(self): def terminate(self):
self._cancel_input_5v_refresh()
if getattr(self, '_input_select_config', None): if getattr(self, '_input_select_config', None):
self._publish_input_select('/availability', 'offline') self._publish_input_select('/availability', 'offline')
self._stop_interrupt_listener() self._stop_interrupt_listener()
@@ -721,6 +777,7 @@ class Vertex2(SmartObject):
) )
def _suspend_vertex2(self): def _suspend_vertex2(self):
self._cancel_input_5v_refresh()
self._invalidate_input_5v_sensors() self._invalidate_input_5v_sensors()
self._reset_no_signal_spam() self._reset_no_signal_spam()
self._invalidate_plus5_sensors() self._invalidate_plus5_sensors()