66import io
77import json
88from collections import deque
9- from datetime import datetime
9+ from datetime import datetime , timedelta
1010from typing import Optional , Dict , Any , Tuple
1111import paho .mqtt .client as mqtt
1212from PIL import Image
1313
14+ # Watchdog constants
15+ CLIENT_TIMEOUT_SECONDS = 300 # 5 Minutes
16+
1417# Import from sibling modules
1518from .config import AlpacaConfig , get_current_time
1619# Assuming detect.py is in the root path or installed as a package
@@ -158,6 +161,31 @@ def _update_cached_safety(self, detection: Dict[str, Any]):
158161 f"(class={ class_name } , confidence={ confidence :.1f} %, "
159162 f"threshold={ threshold :.1f} %, debounce={ elapsed_time :.1f} s)" )
160163
164+ def _prune_stale_clients (self ):
165+ """Remove clients that haven't been seen for CLIENT_TIMEOUT_SECONDS (assumes lock is held)"""
166+ now = get_current_time (self .alpaca_config .timezone )
167+ cutoff_time = now - timedelta (seconds = CLIENT_TIMEOUT_SECONDS )
168+
169+ stale_clients = []
170+ for key , last_seen in list (self .connected_clients .items ()):
171+ if last_seen < cutoff_time :
172+ stale_clients .append (key )
173+
174+ for key in stale_clients :
175+ client_ip , client_id = key
176+ conn_time = self .connected_clients [key ]
177+ self .disconnected_clients [key ] = (conn_time , now )
178+ del self .connected_clients [key ]
179+ logger .warning (f"Watchdog: Pruned stale client { client_ip } (ID: { client_id } ) - "
180+ f"inactive for { (now - conn_time ).total_seconds ():.0f} s" )
181+
182+ def register_heartbeat (self , client_ip : str , client_id : int ):
183+ """Update the last seen timestamp for a connected client"""
184+ with self .connection_lock :
185+ key = (client_ip , client_id )
186+ if key in self .connected_clients :
187+ self .connected_clients [key ] = get_current_time (self .alpaca_config .timezone )
188+
161189 def _setup_mqtt (self ):
162190 """Setup and return MQTT client based on detect_config"""
163191 if not self .detect_config .broker :
@@ -299,6 +327,7 @@ def _get_arg(self, key: str, default: Any = None) -> str:
299327 def is_connected (self ) -> bool :
300328 """Check if any clients are connected"""
301329 with self .connection_lock :
330+ self ._prune_stale_clients ()
302331 return len (self .connected_clients ) > 0
303332
304333 def connect (self , client_ip : str , client_id : int ):
@@ -321,6 +350,7 @@ def connect(self, client_ip: str, client_id: int):
321350 def disconnect (self , client_ip : str = None , client_id : int = None ):
322351 """Disconnect a client from the device"""
323352 with self .connection_lock :
353+ self ._prune_stale_clients ()
324354 if client_ip is None or client_id is None :
325355 # Disconnect all
326356 for key in list (self .connected_clients .keys ()):
0 commit comments