Skip to content

Commit 18860f4

Browse files
committed
feat: implement a (simple) state machine for state management
1 parent 751427e commit 18860f4

File tree

8 files changed

+87
-52
lines changed

8 files changed

+87
-52
lines changed

src/arduino/app_peripherals/camera/base_camera.py

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
from abc import ABC, abstractmethod
88
from concurrent.futures import ThreadPoolExecutor
9-
from typing import Optional, Callable
9+
from typing import Literal, Optional, Callable
1010
import numpy as np
1111

1212
from 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."""

src/arduino/app_peripherals/camera/ip_camera.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def _open_camera(self) -> None:
8989
if not ret and frame is None:
9090
raise CameraOpenError(f"Read test failed for IP camera at {self.url}")
9191

92-
self._emit_event("connected", {"camera_url": self.url})
92+
self._set_status("connected", {"camera_url": self.url})
9393

9494
except CameraOpenError:
9595
if self._cap is not None:
@@ -138,7 +138,7 @@ def _close_camera(self) -> None:
138138
if self._cap is not None:
139139
self._cap.release()
140140
self._cap = None
141-
self._emit_event("disconnected", {"camera_url": self.url})
141+
self._set_status("disconnected", {"camera_url": self.url})
142142

143143
def _read_frame(self) -> np.ndarray | None:
144144
"""Read a frame from the IP camera with automatic reconnection."""

src/arduino/app_peripherals/camera/v4l_camera.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def _open_camera(self) -> None:
188188
if not ret and frame is None:
189189
raise CameraOpenError(f"Read test failed for camera {self.name}")
190190

191-
self._emit_event("connected", {"camera_name": self.name, "camera_path": self.v4l_path})
191+
self._set_status("connected", {"camera_name": self.name, "camera_path": self.v4l_path})
192192

193193
except CameraOpenError:
194194
if self._cap is not None:
@@ -207,7 +207,7 @@ def _close_camera(self) -> None:
207207
if self._cap is not None:
208208
self._cap.release()
209209
self._cap = None
210-
self._emit_event("disconnected", {"camera_name": self.name, "camera_path": self.v4l_path})
210+
self._set_status("disconnected", {"camera_name": self.name, "camera_path": self.v4l_path})
211211

212212
def _read_frame(self) -> np.ndarray | None:
213213
"""

src/arduino/app_peripherals/camera/websocket_camera.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None:
178178
# Accept the client
179179
self._client = conn
180180

181-
self._emit_event("connected", {"client_address": client_addr})
181+
self._set_status("connected", {"client_address": client_addr})
182182

183183
self.logger.debug(f"Client connected: {client_addr}")
184184

@@ -218,7 +218,7 @@ async def _ws_handler(self, conn: websockets.ServerConnection) -> None:
218218
async with self._client_lock:
219219
if self._client == conn:
220220
self._client = None
221-
self._emit_event("disconnected", {"client_address": client_addr})
221+
self._set_status("disconnected", {"client_address": client_addr})
222222
self.logger.debug(f"Client disconnected: {client_addr}")
223223

224224
def _parse_message(self, message: str | bytes) -> np.ndarray | None:

tests/arduino/app_peripherals/camera/test_base_camera.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ def _open_camera(self):
3737
if self.should_fail_open:
3838
raise RuntimeError(self.open_error_message)
3939
else:
40-
self._emit_event("connected")
40+
self._set_status("connected")
4141

4242
def _close_camera(self):
4343
"""Mock implementation of _close_camera."""
4444
self.close_call_count += 1
4545
if self.should_fail_close:
4646
raise RuntimeError(self.close_error_message)
4747
else:
48-
self._emit_event("disconnected")
48+
self._set_status("disconnected")
4949

5050
def _read_frame(self):
5151
"""Mock implementation that returns a dummy frame."""
@@ -388,28 +388,39 @@ def test_events():
388388
def event_callback(event, data):
389389
events.append((event, data))
390390

391-
camera.on_event(event_callback)
391+
camera.on_status_changed(event_callback)
392392

393393
camera.start() # Should emit "connected" event
394394

395-
camera.capture()
395+
assert camera.status == "connected"
396+
397+
camera.capture() # Should emit "streaming" event
398+
399+
assert camera.status == "streaming"
396400

397401
camera.frame = None
398402
camera.capture()
399403
camera.capture()
400404
camera.capture() # Should emit "paused" event
401405
camera.frame = np.zeros((480, 640, 3), dtype=np.uint8)
406+
407+
assert camera.status == "paused"
402408

403-
camera.capture() # Should emit "resumed" event
409+
camera.capture() # Should emit "streaming" event
410+
411+
assert camera.status == "streaming"
404412

405413
camera.stop() # Should emit "disconnected" event
406414

415+
assert camera.status == "disconnected"
416+
407417
# The events list is modified from another thread, so a brief sleep
408418
# helps ensure the main thread sees the appended items before asserting.
409419
time.sleep(0.1)
410420

411-
assert len(events) == 4
421+
assert len(events) == 5
412422
assert "connected" in events[0][0]
413-
assert "paused" in events[1][0]
414-
assert "resumed" in events[2][0]
415-
assert "disconnected" in events[3][0]
423+
assert "streaming" in events[1][0]
424+
assert "paused" in events[2][0]
425+
assert "streaming" in events[3][0]
426+
assert "disconnected" in events[4][0]

tests/arduino/app_peripherals/camera/test_ip_camera.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def test_events(mock_videocapture, mock_requests):
312312
def event_callback(event, data):
313313
events.append((event, data))
314314

315-
camera.on_event(event_callback)
315+
camera.on_status_changed(event_callback)
316316

317317
camera.start()
318318
camera.stop()

tests/arduino/app_peripherals/camera/test_v4l_camera.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ def test_events(self, mock_videocapture, mock_successful_connect):
493493
def event_callback(event, data):
494494
events.append((event, data))
495495

496-
camera.on_event(event_callback)
496+
camera.on_status_changed(event_callback)
497497

498498
camera.start()
499499
camera.stop()

tests/arduino/app_peripherals/camera/test_websocket_camera.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ def event_listener(event_type, data):
344344
events.append((event_type, data))
345345

346346
camera = WebSocketCamera(port=0)
347-
camera.on_event(event_listener)
347+
camera.on_status_changed(event_listener)
348348
camera.start()
349349

350350
# This should emit connection and disconnection events
@@ -396,7 +396,7 @@ def event_listener(event_type, data):
396396
events.append((event_type, data))
397397

398398
camera = WebSocketCamera(port=0)
399-
camera.on_event(event_listener)
399+
camera.on_status_changed(event_listener)
400400
camera.start()
401401

402402
await asyncio.sleep(0.1)
@@ -425,7 +425,7 @@ def event_listener(event_type, data):
425425
events.append((event_type, data))
426426

427427
camera = WebSocketCamera(port=0, timeout=1) # Reduced timeout for faster stop() call
428-
camera.on_event(event_listener)
428+
camera.on_status_changed(event_listener)
429429
camera.start()
430430

431431
can_close = asyncio.Event()

0 commit comments

Comments
 (0)