diff --git a/th_cli/api_lib_autogen/api_client.py b/th_cli/api_lib_autogen/api_client.py index c4d4fdd..53bb48a 100644 --- a/th_cli/api_lib_autogen/api_client.py +++ b/th_cli/api_lib_autogen/api_client.py @@ -80,14 +80,12 @@ def close(self) -> None: @overload async def request( self, *, type_: Type[T], method: str, url: str, path_params: Optional[Dict[str, Any]] = None, **kwargs: Any - ) -> T: - ... + ) -> T: ... @overload # noqa F811 async def request( self, *, type_: None, method: str, url: str, path_params: Optional[Dict[str, Any]] = None, **kwargs: Any - ) -> str: - ... + ) -> str: ... async def request( # noqa F811 self, *, type_: Any, method: str, url: str, path_params: Optional[Dict[str, Any]] = None, **kwargs: Any @@ -99,12 +97,10 @@ async def request( # noqa F811 return await self.send(request, type_) @overload - def request_sync(self, *, type_: Type[T], **kwargs: Any) -> T: - ... + def request_sync(self, *, type_: Type[T], **kwargs: Any) -> T: ... @overload # noqa F811 - def request_sync(self, *, type_: None, **kwargs: Any) -> str: - ... + def request_sync(self, *, type_: None, **kwargs: Any) -> str: ... def request_sync(self, *, type_: Any, **kwargs: Any) -> Any: # noqa F811 """ diff --git a/th_cli/commands/available_tests.py b/th_cli/commands/available_tests.py index bc76b46..cd56617 100644 --- a/th_cli/commands/available_tests.py +++ b/th_cli/commands/available_tests.py @@ -13,10 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from typing import Any, Dict, List, Optional import re import textwrap from collections import defaultdict +from typing import Any, Dict, List, Optional import click import yaml diff --git a/th_cli/test_run/camera/camera_stream_handler.py b/th_cli/test_run/camera/camera_stream_handler.py index a957c8f..43fec40 100644 --- a/th_cli/test_run/camera/camera_stream_handler.py +++ b/th_cli/test_run/camera/camera_stream_handler.py @@ -22,6 +22,8 @@ from loguru import logger +from th_cli.th_utils.ffmpeg_converter import FFmpegNotInstalledError, FFmpegStreamConverter + from .camera_http_server import CameraHTTPServer from .websocket_manager import VideoWebSocketManager @@ -46,6 +48,7 @@ def __init__(self, output_dir: Optional[str] = None): # Stream readiness signaling self.stream_ready_event = asyncio.Event() + self.initialization_error: Optional[str] = None # Store initialization errors def set_prompt_data(self, prompt_text: str, options: dict): """Set prompt text and options for the web UI.""" @@ -61,8 +64,9 @@ async def start_video_capture_and_stream(self, prompt_id: str) -> Path: logger.info(f"Starting video capture to: {self.current_stream_file}") - # Reset the stream ready event + # Reset the stream ready event and clear any previous errors self.stream_ready_event.clear() + self.initialization_error = None # Start HTTP server with current prompt data self.http_server.start( @@ -89,16 +93,31 @@ async def wait_for_stream_ready(self, timeout: float = 10.0) -> bool: async def _initialize_video_capture(self) -> None: """Initialize video capture with retry logic.""" - # Try to connect and start capturing - if await self.websocket_manager.wait_and_connect_with_retry(): - # Signal that the stream is ready once connection is established - self.stream_ready_event.set() - logger.info("Video stream is ready for viewing") - - await self.websocket_manager.start_capture_and_stream(self.current_stream_file, self.mp4_queue) - else: - logger.error("Failed to establish video stream connection") - # Don't set the event if connection failed + try: + is_installed, error_msg = FFmpegStreamConverter.check_ffmpeg_installed() + if not is_installed: + logger.error(error_msg) + self.initialization_error = error_msg + return + + # Try to connect and start capturing + if await self.websocket_manager.wait_and_connect_with_retry(): + # Signal that the stream is ready once connection is established + self.stream_ready_event.set() + logger.info("Video stream is ready for viewing") + + await self.websocket_manager.start_capture_and_stream(self.current_stream_file, self.mp4_queue) + else: + logger.error("Failed to establish video stream connection") + self.initialization_error = "Failed to establish video stream connection" + # Don't set the event if connection failed + except FFmpegNotInstalledError as e: + logger.error(e.message) + self.initialization_error = e.message + except Exception as e: + error_message = f"Unexpected error during video capture initialization: {e}" + logger.error(error_message) + self.initialization_error = error_message async def wait_for_user_response(self, timeout: float) -> Optional[int]: """Wait for user response from web UI.""" diff --git a/th_cli/test_run/prompt_manager.py b/th_cli/test_run/prompt_manager.py index 9a110d7..3b07ca6 100644 --- a/th_cli/test_run/prompt_manager.py +++ b/th_cli/test_run/prompt_manager.py @@ -123,7 +123,19 @@ async def __handle_stream_verification_prompt(socket: WebSocketClientProtocol, p # Wait for stream to be ready instead of fixed delay stream_ready = await video_handler.wait_for_stream_ready(timeout=10.0) if not stream_ready: - click.echo(colorize_error("Video stream failed to initialize"), err=True) + # Display specific error if available + if video_handler.initialization_error: + click.echo(video_handler.initialization_error, err=True) + else: + click.echo(colorize_error("Video stream failed to initialize"), err=True) + + # Send CANCELLED response to abort test execution + await _send_prompt_response( + socket=socket, + prompt=prompt, + response="Video stream initialization failed", + status_code=UserResponseStatusEnum.CANCELLED, + ) return click.echo(italic(prompt.prompt)) @@ -154,7 +166,7 @@ async def __handle_stream_verification_prompt(socket: WebSocketClientProtocol, p # Stop video capture and streaming _ = await video_handler.stop_video_capture_and_stream() - await _send_prompt_response(socket=socket, input=user_answer, prompt=prompt) + await _send_prompt_response(socket=socket, response=user_answer, prompt=prompt) except asyncio.exceptions.TimeoutError: click.echo(colorize_error("Video prompt timed out"), err=True) @@ -176,7 +188,7 @@ async def handle_file_upload_request(socket: WebSocketClientProtocol, request: P async def __handle_options_prompt(socket: WebSocketClientProtocol, prompt: OptionsSelectPromptRequest) -> None: try: user_answer = await asyncio.wait_for(_prompt_user_for_option(prompt), float(prompt.timeout)) - await _send_prompt_response(socket=socket, input=user_answer, prompt=prompt) + await _send_prompt_response(socket=socket, response=user_answer, prompt=prompt) except asyncio.exceptions.TimeoutError: click.echo(colorize_error("Prompt timed out"), err=True) pass @@ -185,7 +197,7 @@ async def __handle_options_prompt(socket: WebSocketClientProtocol, prompt: Optio async def __handle_message_prompt(socket: WebSocketClientProtocol, prompt: PromptRequest) -> None: """Handle simple message prompts that only require acknowledgment.""" click.echo(italic(prompt.prompt)) - await _send_prompt_response(socket=socket, input="ACK", prompt=prompt) + await _send_prompt_response(socket=socket, response="ACK", prompt=prompt) async def _prompt_user_for_option(prompt: OptionsSelectPromptRequest) -> int: @@ -216,7 +228,7 @@ async def _prompt_user_for_option(prompt: OptionsSelectPromptRequest) -> int: async def __handle_text_prompt(socket: WebSocketClientProtocol, prompt: TextInputPromptRequest) -> None: try: user_answer = await asyncio.wait_for(__prompt_user_for_text_input(prompt), float(prompt.timeout)) - await _send_prompt_response(socket=socket, input=user_answer, prompt=prompt) + await _send_prompt_response(socket=socket, response=user_answer, prompt=prompt) except asyncio.exceptions.TimeoutError: click.echo(colorize_error("Prompt timed out"), err=True) pass @@ -230,7 +242,7 @@ async def __handle_file_upload_prompt(socket: WebSocketClientProtocol, prompt: P await __upload_file_and_send_response(socket=socket, file_path=file_path, prompt=prompt) else: # User cancelled or provided empty path - await _send_prompt_response(socket=socket, input="", prompt=prompt) + await _send_prompt_response(socket=socket, response="", prompt=prompt) except asyncio.exceptions.TimeoutError: click.echo("File upload prompt timed out", err=True) pass @@ -300,7 +312,7 @@ async def __upload_file_and_send_response( try: if not os.path.isfile(file_path): click.echo(f"Error: File '{file_path}' does not exist or is not accessible", err=True) - await _send_prompt_response(socket=socket, input="", prompt=prompt) + await _send_prompt_response(socket=socket, response="", prompt=prompt) return file_size = os.path.getsize(file_path) @@ -308,7 +320,7 @@ async def __upload_file_and_send_response( # Check file size limit if file_size > MAX_FILE_SIZE: click.echo(f"❌ File too large: {file_size} bytes (max: {MAX_FILE_SIZE} bytes)", err=True) - await _send_prompt_response(socket=socket, input="", prompt=prompt) + await _send_prompt_response(socket=socket, response="", prompt=prompt) return click.echo(f"File selected: {file_path} (size: {file_size:,} bytes)") @@ -329,17 +341,17 @@ async def __upload_file_and_send_response( response.raise_for_status() click.echo("✅ File uploaded successfully") - await _send_prompt_response(socket=socket, input="SUCCESS", prompt=prompt) + await _send_prompt_response(socket=socket, response="SUCCESS", prompt=prompt) except httpx.RequestError as e: click.echo(f"❌ Network error during file upload: {str(e)}", err=True) - await _send_prompt_response(socket=socket, input="", prompt=prompt) + await _send_prompt_response(socket=socket, response="", prompt=prompt) except httpx.HTTPStatusError as e: click.echo(f"❌ HTTP error during file upload: {e.response.status_code} - {e.response.text}", err=True) - await _send_prompt_response(socket=socket, input="", prompt=prompt) + await _send_prompt_response(socket=socket, response="", prompt=prompt) except Exception as e: click.echo(f"❌ Unexpected error uploading file: {str(e)}", err=True) - await _send_prompt_response(socket=socket, input="", prompt=prompt) + await _send_prompt_response(socket=socket, response="", prompt=prompt) def __valid_text_input(input: Any, prompt: TextInputPromptRequest) -> bool: @@ -366,15 +378,29 @@ def __valid_file_upload(file_path: str, prompt: PromptRequest) -> bool: return True -async def _send_prompt_response(socket: WebSocketClientProtocol, input: Union[str, int], prompt: PromptRequest) -> None: - response = PromptResponse( - response=input, - status_code=UserResponseStatusEnum.OKAY, +async def _send_prompt_response( + socket: WebSocketClientProtocol, + prompt: PromptRequest, + response: Union[str, int], + status_code: UserResponseStatusEnum = UserResponseStatusEnum.OKAY, +) -> None: + """ + Send a prompt response to the backend. + + Args: + socket: WebSocket connection to send the response through + prompt: The original prompt request + response: The response data (user input, error message, etc.) + status_code: Status of the response (OKAY, CANCELLED, TIMEOUT, INVALID) + """ + response_obj = PromptResponse( + response=response, + status_code=status_code, message_id=prompt.message_id, ) payload_dict = { MessageKeysEnum.TYPE: "prompt_response", - MessageKeysEnum.PAYLOAD: response.dict(), + MessageKeysEnum.PAYLOAD: response_obj.dict(), } payload = json.dumps(payload_dict) await socket.send(payload) diff --git a/th_cli/test_run/socket_schemas.py b/th_cli/test_run/socket_schemas.py index 03ed653..4fd4351 100644 --- a/th_cli/test_run/socket_schemas.py +++ b/th_cli/test_run/socket_schemas.py @@ -89,6 +89,7 @@ class TextInputPromptRequest(PromptRequest): class MessagePromptRequest(PromptRequest): """Simple message prompt that only requires acknowledgment.""" + pass diff --git a/th_cli/th_utils/ffmpeg_converter.py b/th_cli/th_utils/ffmpeg_converter.py index 7dfbc18..227357a 100644 --- a/th_cli/th_utils/ffmpeg_converter.py +++ b/th_cli/th_utils/ffmpeg_converter.py @@ -44,6 +44,15 @@ ) +# Custom Exceptions +class FFmpegNotInstalledError(RuntimeError): + """Raised when FFmpeg is not installed or not found in PATH.""" + + def __init__(self, message: str = FFMPEG_NOT_INSTALLED_MSG): + self.message = message + super().__init__(self.message) + + class FFmpegStreamConverter: """Converts H.264 raw stream to MP4 in real-time using FFmpeg.""" @@ -83,9 +92,7 @@ def start_conversion(self): is_installed, error_msg = self.check_ffmpeg_installed() if not is_installed: logger.error(error_msg) - raise RuntimeError( - "FFmpeg is not installed. Video streaming requires FFmpeg. " "See installation instructions above." - ) + raise FFmpegNotInstalledError(error_msg) try: # Create FFmpeg stream using ffmpeg-python diff --git a/th_cli/utils.py b/th_cli/utils.py index 603d7e6..30b7c66 100644 --- a/th_cli/utils.py +++ b/th_cli/utils.py @@ -344,10 +344,7 @@ def parse_pics_xml(xml_content: str) -> dict: if support_element is not None and support_element.text: support = support_element.text.lower() == "true" - result["clusters"][cluster_name]["items"][item_number] = { - "number": item_number, - "enabled": support - } + result["clusters"][cluster_name]["items"][item_number] = {"number": item_number, "enabled": support} return result