77import json
88from collections import deque
99from datetime import datetime , timedelta
10- from typing import Optional , Dict , Any , Tuple
10+ from typing import Optional , Dict , Any , Tuple , List
1111import paho .mqtt .client as mqtt
1212from PIL import Image
1313
@@ -194,6 +194,9 @@ def _prune_stale_clients(self):
194194 def register_heartbeat (self , client_ip : str , client_id : int ):
195195 """Update the last seen timestamp for a connected client"""
196196 with self .connection_lock :
197+ # Prune stale clients during heartbeat (periodic cleanup)
198+ self ._prune_stale_clients ()
199+
197200 key = (client_ip , client_id )
198201 if key in self .connected_clients :
199202 # Only update last_seen, preserve connected_clients (start time)
@@ -348,12 +351,20 @@ def _get_arg(self, key: str, default: Any = None) -> str:
348351 def is_connected (self ) -> bool :
349352 """Check if any clients are connected"""
350353 with self .connection_lock :
351- self ._prune_stale_clients ()
352354 return len (self .connected_clients ) > 0
355+
356+ def is_client_connected (self , client_ip : str , client_id : int ) -> bool :
357+ """Check if a specific client is connected"""
358+ with self .connection_lock :
359+ key = (client_ip , client_id )
360+ return key in self .connected_clients
353361
354362 def connect (self , client_ip : str , client_id : int ):
355363 """Connect a client to the device"""
356364 with self .connection_lock :
365+ # Prune stale clients BEFORE adding new connection
366+ self ._prune_stale_clients ()
367+
357368 key = (client_ip , client_id )
358369 current_time = get_current_time (self .alpaca_config .timezone )
359370 self .connected_clients [key ] = current_time
@@ -373,13 +384,13 @@ def connect(self, client_ip: str, client_id: int):
373384 def disconnect (self , client_ip : str = None , client_id : int = None ):
374385 """Disconnect a client from the device"""
375386 with self .connection_lock :
376- self ._prune_stale_clients ()
377387 if client_ip is None or client_id is None :
378- # Disconnect all
388+ # Disconnect all - IMMEDIATE state change
389+ disc_time = get_current_time (self .alpaca_config .timezone )
379390 for key in list (self .connected_clients .keys ()):
380391 conn_time = self .connected_clients [key ]
381- disc_time = get_current_time (self .alpaca_config .timezone )
382392 self .disconnected_clients [key ] = (conn_time , disc_time )
393+
383394 self .connected_clients .clear ()
384395 self .client_last_seen .clear ()
385396 self .disconnected_at = disc_time
@@ -420,6 +431,54 @@ def is_safe(self) -> bool:
420431 with self .detection_lock :
421432 return self ._stable_safe_state
422433
434+ def get_pending_status (self ) -> Dict [str , Any ]:
435+ """Get information about any pending state changes"""
436+ with self .detection_lock :
437+ if self ._pending_safe_state is None or self ._state_change_start_time is None :
438+ return {'is_pending' : False }
439+
440+ now = get_current_time (self .alpaca_config .timezone )
441+ elapsed = (now - self ._state_change_start_time ).total_seconds ()
442+
443+ if self ._pending_safe_state :
444+ required = self .alpaca_config .debounce_to_safe_sec
445+ else :
446+ required = self .alpaca_config .debounce_to_unsafe_sec
447+
448+ remaining = max (0 , required - elapsed )
449+
450+ return {
451+ 'is_pending' : True ,
452+ 'target_state' : 'SAFE' if self ._pending_safe_state else 'UNSAFE' ,
453+ 'target_color' : 'rgb(52, 211, 153)' if self ._pending_safe_state else 'rgb(248, 113, 113)' ,
454+ 'remaining_seconds' : round (remaining , 1 ),
455+ 'total_duration' : required
456+ }
457+
458+ def get_safety_history (self ) -> List [Dict [str , Any ]]:
459+ """Get a thread-safe copy of the safety history"""
460+ with self .detection_lock :
461+ return list (self ._safety_history )
462+
463+ def get_connected_clients_info (self ) -> List [Dict [str , Any ]]:
464+ """Get detailed information about connected clients"""
465+ clients = []
466+ now = get_current_time (self .alpaca_config .timezone )
467+ with self .connection_lock :
468+ # Prune stale clients before returning list
469+ self ._prune_stale_clients ()
470+
471+ for (ip , client_id ), conn_time in self .connected_clients .items ():
472+ last_seen = self .client_last_seen .get ((ip , client_id ))
473+ clients .append ({
474+ "ip" : ip ,
475+ "client_id" : client_id ,
476+ "connected_at" : conn_time ,
477+ "last_seen" : last_seen ,
478+ "duration_seconds" : (now - conn_time ).total_seconds ()
479+ })
480+ return clients
481+
423482 def get_device_state (self ) -> list :
424483 """Get current operational state"""
425484 is_safe_val = self .is_safe ()
0 commit comments