66import time
77from abc import ABC , abstractmethod
88from concurrent .futures import ThreadPoolExecutor
9- from typing import Optional , Callable
9+ from typing import Literal , Optional , Callable
1010import numpy as np
1111
1212from arduino .app_utils import Logger
@@ -46,6 +46,7 @@ def __init__(
4646 self .adjustments = adjustments
4747 self .logger = logger # This will be overridden by subclasses if needed
4848 self .name = self .__class__ .__name__ # This will be overridden by subclasses if needed
49+ self ._status : Literal ['disconnected' , 'connected' , 'streaming' , 'paused' ] = "disconnected"
4950
5051 self ._camera_lock = threading .Lock ()
5152 self ._is_started = False
@@ -59,12 +60,16 @@ def __init__(
5960
6061 # Stream interruption detection
6162 self ._consecutive_none_frames = 0
62- self ._stream_paused = False
6363
6464 # Event handling
65- self ._on_event_cb : Callable [[str , dict ], None ] | None = None
65+ self ._on_status_changed_cb : Callable [[str , dict ], None ] | None = None
6666 self ._event_executor = ThreadPoolExecutor (max_workers = 1 , thread_name_prefix = "CameraEvent" )
6767
68+ @property
69+ def status (self ) -> Literal ['disconnected' , 'connected' , 'streaming' , 'paused' ]:
70+ """Read-only property for camera status."""
71+ return self ._status
72+
6873 @property
6974 def _none_frame_threshold (self ) -> int :
7075 """Heuristic: 750ms of empty frames based on current fps."""
@@ -146,14 +151,11 @@ def capture(self) -> Optional[np.ndarray]:
146151 frame = self ._read_frame ()
147152 if frame is None :
148153 self ._consecutive_none_frames += 1
149- if self ._consecutive_none_frames >= self ._none_frame_threshold and not self ._stream_paused :
150- self ._stream_paused = True
151- self ._emit_event ("paused" )
154+ if self ._consecutive_none_frames >= self ._none_frame_threshold :
155+ self ._set_status ("paused" )
152156 return None
153157
154- if self ._stream_paused :
155- self ._stream_paused = False
156- self ._emit_event ("resumed" )
158+ self ._set_status ("streaming" )
157159
158160 self ._consecutive_none_frames = 0
159161
@@ -190,48 +192,48 @@ def is_started(self) -> bool:
190192 """Check if the camera has been started."""
191193 return self ._is_started
192194
193- def on_event (self , callback : Callable [[str , dict | None ], None ] | None ):
195+ def on_status_changed (self , callback : Callable [[str , dict ], None ] | None ):
194196 """Registers or removes a callback to be triggered on camera lifecycle events.
195197
196- When a camera lifecycle event will happen , the provided callback function will be invoked.
198+ When a camera status changes , the provided callback function will be invoked.
197199 If None is provided, the callback will be removed.
198200
199201 Args:
200- callback (Callable[[str, dict | None], None]): A callback that will be called every time a camera
201- lifecycle event will happen with the event name and any associated data. The event
202- names depend on the actual camera implementation being used. Some common events are:
203- - 'disconnected': The camera has been disconnected.
202+ callback (Callable[[str, dict], None]): A callback that will be called every time the
203+ camera status changes with the new status and any associated data. The status names
204+ depend on the actual camera implementation being used. Some common events are:
204205 - 'connected': The camera has been reconnected.
206+ - 'disconnected': The camera has been disconnected.
207+ - 'streaming': The stream is streaming.
205208 - 'paused': The stream has been paused and is temporarily unavailable.
206- - 'resumed': The stream has resumed after being paused.
207209 callback (None): To unregister the current callback, if any.
208210
209211 Example:
210- def on_event(event : str, data: dict):
211- print(f"Camera is now: {event }")
212+ def on_status(status : str, data: dict):
213+ print(f"Camera is now: {status }")
212214 print(f"Data: {data}")
213215 # Here you can add your code to react to the event
214216
215- camera.on_event(on_event )
217+ camera.on_status_changed(on_status )
216218 """
217219 if callback is None :
218- self ._on_event_cb = None
220+ self ._on_status_changed_cb = None
219221 else :
220222
221- def _callback_wrapper (event : str , data : dict ):
223+ def _callback_wrapper (new_status : str , data : dict ):
222224 try :
223- callback (event , data )
225+ callback (new_status , data )
224226 except Exception as e :
225- self .logger .error (f"Callback for event ' { event } ' failed with error: { e } " )
227+ self .logger .error (f"Callback for ' { new_status } ' status failed with error: { e } " )
226228
227- self ._on_event_cb = _callback_wrapper
229+ self ._on_status_changed_cb = _callback_wrapper
228230
229231 @abstractmethod
230232 def _open_camera (self ) -> None :
231233 """
232234 Open the camera connection.
233235
234- Must be implemented by subclasses and events should be emitted accordingly.
236+ Must be implemented by subclasses and status changes should be emitted accordingly.
235237 """
236238 pass
237239
@@ -240,7 +242,7 @@ def _close_camera(self) -> None:
240242 """
241243 Close the camera connection.
242244
243- Must be implemented by subclasses and events should be emitted accordingly.
245+ Must be implemented by subclasses and status changes should be emitted accordingly.
244246 """
245247 pass
246248
@@ -253,16 +255,38 @@ def _read_frame(self) -> Optional[np.ndarray]:
253255 """
254256 pass
255257
256- def _emit_event (self , event : str , data : dict | None = None ) -> None :
258+ def _set_status (self , new_status : str , data : dict | None = None ) -> None :
257259 """
258- Invoke the registered event callback in the background, if any.
260+ Updates the current status of the camera and invokes the registered status
261+ changed callback in the background, if any.
262+
263+ Only allowed states and transitions are considered, other states are ignored.
264+ Allowed states are:
265+ - disconnected
266+ - connected
267+ - streaming
268+ - paused
259269
260270 Args:
261- event (str): The name of the event .
262- data (dict): Additional data associated with the event .
271+ new_status (str): The name of the new status .
272+ data (dict): Additional data associated with the status change .
263273 """
264- if self ._on_event_cb is not None :
265- self ._event_executor .submit (self ._on_event_cb , event , data if data is not None else {})
274+ allowed_transitions = {
275+ "disconnected" : ["connected" ],
276+ "connected" : ["disconnected" , "streaming" ],
277+ "streaming" : ["paused" , "disconnected" ],
278+ "paused" : ["streaming" , "disconnected" ],
279+ }
280+
281+ # If current status is not in the state machine, do nothing
282+ if self ._status not in allowed_transitions :
283+ return
284+
285+ # Check if new_status is an allowed transition for the current status
286+ if new_status in allowed_transitions [self ._status ]:
287+ self ._status = new_status
288+ if self ._on_status_changed_cb is not None :
289+ self ._event_executor .submit (self ._on_status_changed_cb , new_status , data if data is not None else {})
266290
267291 def __enter__ (self ):
268292 """Context manager entry."""
0 commit comments