from __future__ import annotations import asyncio import logging from collections import deque from types import SimpleNamespace from typing import Any import pytest import cmvr_edge_ai.connectors.cmvr_es.camera as camera_module from cmvr_edge_ai.connectors.cmvr_es import ( CmvrCameraRgbStreamSource, CmvrServiceError, ) from cmvr_edge_ai.contracts import ImageFrame from cmvr_edge_ai.core import Emission class _FakeCall: def __init__(self, *events: Any) -> None: self._events = deque(events) self.cancel_count = 0 def __aiter__(self) -> _FakeCall: return self async def __anext__(self) -> Any: if not self._events: raise StopAsyncIteration event = self._events.popleft() if isinstance(event, BaseException): raise event return event def cancel(self) -> bool: self.cancel_count += 1 return True class _BlockingCall: def __init__(self) -> None: self.entered = asyncio.Event() self._released = asyncio.Event() self.cancel_count = 0 def __aiter__(self) -> _BlockingCall: return self async def __anext__(self) -> Any: self.entered.set() await self._released.wait() raise asyncio.CancelledError def cancel(self) -> bool: self.cancel_count += 1 self._released.set() return True class _ImmediateRetryEvent(asyncio.Event): """Exercise reconnect paths without waiting for wall-clock backoff.""" async def wait(self) -> bool: if self.is_set(): return True raise TimeoutError class _OneProgressTickEvent: """Cause exactly one heartbeat tick, then terminate its logging loop.""" def __init__(self) -> None: self._done = False def is_set(self) -> bool: return self._done async def wait(self) -> bool: self._done = True raise TimeoutError class _FakeStub: def __init__( self, *calls: Any, start_feedbacks: tuple[Any, ...] = (), ) -> None: self._calls = deque(calls) self._start_feedbacks = deque(start_feedbacks) self.events: list[str] = [] self.start_requests: list[Any] = [] self.start_timeouts: list[float | None] = [] self.start_count = 0 self.open_count = 0 async def StartCamera( self, request: Any, *, timeout: float | None = None, ) -> Any: self.events.append("start") self.start_requests.append(request) self.start_timeouts.append(timeout) self.start_count += 1 if not self._start_feedbacks: return _start_feedback() feedback = self._start_feedbacks.popleft() if isinstance(feedback, BaseException): raise feedback return feedback def GetRGBImageStream(self, request_stream: Any) -> Any: # The connector owns request-stream lifetime. This fake only exercises # response and reconnect behavior, so protobuf request objects are not # imported or inspected. del request_stream self.events.append("stream") self.open_count += 1 if not self._calls: raise AssertionError("camera connector opened an unexpected stream") return self._calls.popleft() def _start_feedback( *, success: bool = True, error_message: str = "", ) -> Any: return SimpleNamespace( header=SimpleNamespace( success=success, error_message=error_message, ) ) def _response( remote_sequence: int, *, data: bytes = b"encoded-frame", codec: str = "H264", key_frame: bool = False, width: int = 640, height: int = 480, seconds: int = 0, nanos: int = 0, success: bool = True, error_message: str = "", ) -> Any: return SimpleNamespace( header=SimpleNamespace( success=success, error_message=error_message, timestamp=SimpleNamespace(seconds=seconds, nanos=nanos), ), color_frame=SimpleNamespace( data=data, width=width, height=height, codec=codec, is_key_frame=key_frame, ), intrinsics=SimpleNamespace( fx=100.0, fy=101.0, cx=50.0, cy=51.0, coeffs=(0.1, 0.2, 0.3, 0.4, 0.5), ), seq_no=remote_sequence, ) def _source(stub: Any, **params: Any) -> CmvrCameraRgbStreamSource: configured = { "endpoint": "cmvr_es", "device_id": "camera-1", "pixel_format": "BGR8", "reconnect_initial_s": 0.001, "reconnect_max_s": 0.002, **params, } source = CmvrCameraRgbStreamSource("camera", configured) # setup() is deliberately bypassed: these unit tests exercise stream state # without importing generated protobuf bindings or constructing gRPC objects. source._stub = stub # type: ignore[attr-defined] source._start_request = object() # type: ignore[attr-defined] source._stream_request = object() # type: ignore[attr-defined] source._shutdown_event = asyncio.Event() # type: ignore[attr-defined] source._rpc_timeout_s = 2.5 # type: ignore[attr-defined] return source def test_reconnect_uses_local_monotonic_sequence_and_new_session_id( monkeypatch: pytest.MonkeyPatch, ) -> None: session_ids = iter(("session-a", "session-b")) monkeypatch.setattr( camera_module, "uuid4", lambda: SimpleNamespace(hex=next(session_ids)), ) first_call = _FakeCall( _response( 900, data=b"first", key_frame=True, seconds=12, nanos=34, ) ) second_call = _FakeCall(_response(3, data=b"second")) stub = _FakeStub(first_call, second_call) async def exercise(): # type: ignore[no-untyped-def] source = _source(stub) stream = source.messages() first = await asyncio.wait_for(anext(stream), timeout=1) second = await asyncio.wait_for(anext(stream), timeout=1) await stream.aclose() return first, second first, second = asyncio.run(exercise()) assert isinstance(first, Emission) assert isinstance(second, Emission) assert first.port == second.port == "frames" assert [first.envelope.sequence, second.envelope.sequence] == [0, 1] assert [first.envelope.session_id, second.envelope.session_id] == [ "session-a", "session-b", ] assert [ first.envelope.attributes["remote_sequence"], second.envelope.attributes["remote_sequence"], ] == [900, 3] assert first.envelope.attributes["stream_session_id"] == "session-a" assert second.envelope.attributes["stream_session_id"] == "session-b" assert first.envelope.source_id == second.envelope.source_id == "camera-1" assert first.envelope.captured_at_ns == 12_000_000_034 assert second.envelope.captured_at_ns is None assert isinstance(first.envelope.payload, ImageFrame) assert first.envelope.payload.data == b"first" assert first.envelope.payload.codec == "H264" assert first.envelope.payload.is_key_frame is True assert first.envelope.payload.pixel_format == "BGR8" assert first.envelope.payload.intrinsics is not None assert first.envelope.payload.intrinsics.coefficients == ( 0.1, 0.2, 0.3, 0.4, 0.5, ) assert first_call.cancel_count == 1 assert second_call.cancel_count == 1 assert stub.events == ["start", "stream", "start", "stream"] assert stub.start_count == stub.open_count == 2 assert stub.start_timeouts == [2.5, 2.5] assert all(request is not None for request in stub.start_requests) def test_transport_failure_reconnects_to_a_new_call() -> None: disconnected = _FakeCall(ConnectionError("temporary disconnect")) recovered = _FakeCall(_response(77, data=b"recovered", key_frame=True)) stub = _FakeStub(disconnected, recovered) async def exercise() -> Emission: source = _source(stub) stream = source.messages() result = await asyncio.wait_for(anext(stream), timeout=1) await stream.aclose() assert isinstance(result, Emission) return result emission = asyncio.run(exercise()) assert stub.open_count == 2 assert stub.start_count == 2 assert stub.events == ["start", "stream", "start", "stream"] assert disconnected.cancel_count == 1 assert recovered.cancel_count == 1 assert emission.envelope.sequence == 0 assert emission.envelope.attributes["remote_sequence"] == 77 assert emission.envelope.payload.data == b"recovered" def test_start_camera_rejection_prevents_stream_open() -> None: stub = _FakeStub( _FakeCall(_response(1)), start_feedbacks=( _start_feedback( success=False, error_message="camera is unavailable", ), ), ) async def exercise() -> None: source = _source(stub, reconnect=False) with pytest.raises(CmvrServiceError, match="camera is unavailable"): await asyncio.wait_for(anext(source.messages()), timeout=1) asyncio.run(exercise()) assert stub.events == ["start"] assert stub.start_count == 1 assert stub.start_timeouts == [2.5] assert stub.open_count == 0 def test_start_camera_transport_failure_is_retried_before_stream_open() -> None: recovered = _FakeCall(_response(5, data=b"after-start-retry")) stub = _FakeStub( recovered, start_feedbacks=( ConnectionError("StartCamera unavailable"), _start_feedback(), ), ) async def exercise() -> Emission: source = _source(stub) emission = await asyncio.wait_for(anext(source.messages()), timeout=1) await source.stop() assert isinstance(emission, Emission) return emission emission = asyncio.run(exercise()) assert stub.events == ["start", "start", "stream"] assert stub.start_count == 2 assert stub.start_timeouts == [2.5, 2.5] assert stub.open_count == 1 assert emission.envelope.payload.data == b"after-start-retry" def test_camera_stream_logs_the_complete_lifecycle( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: now_ns = 0 def advancing_clock() -> int: nonlocal now_ns now_ns += 2_000_000_000 return now_ns elapsed_s = 0.0 def advancing_timer() -> float: nonlocal elapsed_s elapsed_s += 0.05 return elapsed_s monkeypatch.setattr(camera_module, "monotonic_ns", advancing_clock) monkeypatch.setattr(camera_module, "perf_counter", advancing_timer) first_session = _FakeCall( _response(10, data=b"first", key_frame=True), _response(11, data=b"second"), ConnectionError("stream interrupted"), ) recovered_session = _FakeCall(_response(12, data=b"recovered")) stub = _FakeStub(first_session, recovered_session) async def exercise() -> None: source = _source(stub, stream_log_interval_s=1) source._shutdown_event = _ImmediateRetryEvent() # type: ignore[attr-defined] stream = source.messages() await anext(stream) await anext(stream) await anext(stream) await source.stop() await stream.aclose() with caplog.at_level(logging.INFO, logger=camera_module.__name__): asyncio.run(exercise()) messages = [record.getMessage() for record in caplog.records] expected_phrases = ( "camera start requested", "camera start succeeded", "camera stream opening", "camera stream first frame", "camera stream disconnected", "camera source stopping", "camera source stopped", ) for phrase in expected_phrases: assert any(phrase in message for message in messages), messages assert sum("camera start requested" in message for message in messages) == 2 assert sum("camera start succeeded" in message for message in messages) == 2 assert sum("camera stream opening" in message for message in messages) == 2 first_frame_messages = [ message for message in messages if "camera stream first frame" in message ] assert len(first_frame_messages) == 2 assert "remote_sequence=10" in first_frame_messages[0] assert "remote_sequence=12" in first_frame_messages[1] def test_camera_stream_progress_log_reports_window_statistics( caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(camera_module, "monotonic_ns", lambda: 3_000_000_000) stats = camera_module._StreamStats( session_id="session-progress", opened_ns=0, window_started_ns=1_000_000_000, frames=3, received_bytes=600, key_frames=1, window_frames=2, window_bytes=500, window_key_frames=1, last_frame_ns=2_500_000_000, last_remote_sequence=42, codec="H264", width=640, height=480, ) source = _source(_FakeStub(), stream_log_interval_s=1) async def exercise() -> None: await source._log_stream_progress( # type: ignore[attr-defined] stats, _OneProgressTickEvent(), # type: ignore[arg-type] ) with caplog.at_level(logging.INFO, logger=camera_module.__name__): asyncio.run(exercise()) messages = [ record.getMessage() for record in caplog.records if "camera stream progress" in record.getMessage() ] assert len(messages) == 1 message = messages[0] assert "session=session-progress" in message assert "total_frames=3" in message assert "window_frames=2" in message assert "window_bytes=500" in message assert "last_remote_sequence=42" in message assert "codec=H264" in message assert "width=640" in message assert "height=480" in message def test_reconnect_false_propagates_the_disconnect() -> None: failed_call = _FakeCall(ConnectionError("camera cable disconnected")) stub = _FakeStub(failed_call) async def exercise() -> None: source = _source(stub, reconnect=False) with pytest.raises(ConnectionError, match="camera cable disconnected"): await asyncio.wait_for(anext(source.messages()), timeout=1) asyncio.run(exercise()) assert stub.open_count == 1 assert stub.start_count == 1 assert stub.events == ["start", "stream"] assert failed_call.cancel_count == 1 def test_max_reconnect_attempts_raises_after_the_allowed_retries() -> None: first_failure = _FakeCall(ConnectionError("first disconnect")) second_failure = _FakeCall(ConnectionError("second disconnect")) stub = _FakeStub(first_failure, second_failure) async def exercise() -> None: source = _source(stub, max_reconnect_attempts=1) with pytest.raises( CmvrServiceError, match="exceeded max_reconnect_attempts", ): await asyncio.wait_for(anext(source.messages()), timeout=1) asyncio.run(exercise()) assert stub.open_count == 2 assert stub.start_count == 2 assert stub.events == ["start", "stream", "start", "stream"] assert first_failure.cancel_count == 1 assert second_failure.cancel_count == 1 def test_stop_cancels_the_active_stream_call() -> None: async def exercise(): # type: ignore[no-untyped-def] call = _BlockingCall() stub = _FakeStub(call) source = _source(stub) stream = source.messages() pending = asyncio.create_task(anext(stream)) await asyncio.wait_for(call.entered.wait(), timeout=1) await source.stop() with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(pending, timeout=1) await stream.aclose() return call, source, stub call, source, stub = asyncio.run(exercise()) assert call.cancel_count == 1 assert stub.events == ["start", "stream"] assert source._call is None # type: ignore[attr-defined] @pytest.mark.parametrize( ("params", "message"), [ ({"typo": True}, "unknown camera source parameter"), ({"device_id": None}, "non-empty device_id"), ({"device_id": " "}, "non-empty device_id"), ({"device_id": 1}, "non-empty device_id"), ({"pixel_format": ""}, "pixel_format must be a non-empty string"), ({"pixel_format": 1}, "pixel_format must be a non-empty string"), ({"reconnect": "true"}, "reconnect must be a boolean"), ({"reconnect": 1}, "reconnect must be a boolean"), ({"reconnect_initial_s": True}, "must be a positive number"), ({"reconnect_initial_s": 0}, "must be a positive number"), ({"reconnect_initial_s": -1}, "must be a positive number"), ({"reconnect_initial_s": float("nan")}, "must be a positive number"), ({"reconnect_initial_s": float("inf")}, "must be a positive number"), ({"reconnect_max_s": 0}, "must be a positive number"), ({"reconnect_max_s": float("nan")}, "must be a positive number"), ({"stream_log_interval_s": True}, "must be a positive number"), ({"stream_log_interval_s": 0}, "must be a positive number"), ({"stream_log_interval_s": -1}, "must be a positive number"), ({"stream_log_interval_s": float("nan")}, "must be a positive number"), ({"stream_log_interval_s": float("inf")}, "must be a positive number"), ( {"reconnect_initial_s": 2, "reconnect_max_s": 1}, "reconnect_max_s must be >= reconnect_initial_s", ), ({"max_reconnect_attempts": True}, "must be an integer or null"), ({"max_reconnect_attempts": 1.5}, "must be an integer or null"), ({"max_reconnect_attempts": "1"}, "must be an integer or null"), ({"max_reconnect_attempts": -1}, "must not be negative"), ], ) def test_camera_reconnect_parameters_are_strictly_validated( params: dict[str, Any], message: str, ) -> None: configured: dict[str, Any] = {"device_id": "camera-1"} configured.update(params) with pytest.raises(ValueError, match=message): CmvrCameraRgbStreamSource("camera", configured) def test_numeric_strings_are_accepted_for_environment_expanded_delays() -> None: source = CmvrCameraRgbStreamSource( "camera", { "device_id": "camera-1", "reconnect_initial_s": "0.25", "reconnect_max_s": "2.5", "max_reconnect_attempts": 0, "stream_log_interval_s": "12.5", }, ) assert source._reconnect_initial_s == 0.25 # type: ignore[attr-defined] assert source._reconnect_max_s == 2.5 # type: ignore[attr-defined] assert source._max_reconnect_attempts == 0 # type: ignore[attr-defined] assert source._stream_log_interval_s == 12.5 # type: ignore[attr-defined]