| """Utils for running a desktop cast_shell instance.""" |
| |
| from __future__ import absolute_import |
| import json |
| import logging |
| import os |
| import shutil |
| import socket |
| import sys |
| import time |
| |
| # The step property key that stores the cast_shell executable path. |
| CAST_SHELL_EXE_PATH = 'cast_shell_exe_path' |
| |
| _AVAILABILITY_RETRY_WAIT_TIME_SECS = 10 |
| _AVAILABILITY_WAIT_TIME_SECS = 80 |
| _CAST_V2_DEBUG_APP_ID = 'C566BECD' |
| _CONNECTION_NAMESPACE = 'urn:x-cast:com.google.cast.tp.connection' |
| _SETUP_API_ROOT = 'http://{device_ip}:8008/setup/{api}' |
| _OZONE_PLATFORM_CAST = 'cast' |
| _OZONE_PLATFORM_HEADLESS = 'headless' # Used for chirp_shell. |
| |
| # Default cast_shell parameters |
| _CAST_V2_PORT = 8009 |
| _MULTIZONE_PORT = 10001 |
| _REMOTE_DUBIGGING_PORT = 9222 |
| _MDNS_MULTICAST_PORT = 5353 |
| _GCM_SUB_PATH = 'gcm_cast_cq' |
| |
| _DUMP_LOGCAT_MSG = ('dump of cast_shell logs: \n' |
| '*** BEGIN CAST_SHELL ***\n' |
| '%s\n' |
| '*** END CAST_SHELL ***\n') |
| |
| |
| class CastShellError(Exception): |
| """An error while trying to use cast_shell.""" |
| |
| |
| class CastShellTimeoutError(Exception): |
| """A timeout error while trying to use cast_shell.""" |
| |
| |
| def start_cast_shell(executor, |
| test_project_path, |
| cast_shell_path, |
| tmp_dir, |
| enable_cma_media_pipeline=False, |
| enable_ccs_transport_v2=False, |
| use_headless_ozone_platform=False, |
| launch_utility_process=False): |
| """Starts a local x86 cast shell. |
| |
| Note: if multiple cast_shell instances are to be started, they must |
| all have unique ports for all the keyword ports specified below and unique |
| HOME and TMPDIR directories. |
| |
| Args: |
| executor: Executor to run the subprocess |
| test_project_path: Path to the 'test' project. This will be appened to the |
| sys.path if it is not already there. |
| cast_shell_path: Path the to cast_shell executable |
| tmp_dir: A temporary dir to use as HOME and TMPDIR. If there are multiple |
| cast_shell instances, this should be unique across all instances. |
| enable_cma_media_pipeline: If True, pass in --enable-cma-media-pipeline |
| enable_ccs_transport_v2: If True, pass in --enable-ccs-transport-v2 |
| use_headless_ozone_platform: If True, use "headless" as --ozone-platform |
| (required for assistant-enabled cast_shells); otherwise, uses "cast". |
| launch_utility_process: If True, launches the utility process (required to |
| start the assistant debug HTTP server at port 8007). |
| |
| Returns: |
| A tuple of (<started subprocess>, |
| <file descriptor of cast_shell output file>, |
| <absolute path of cast_shell output file>). |
| These can be passed to upload_cast_shell_logs() below to store the log |
| files in GCS. |
| |
| Raises: |
| CastShellError: if cast_shell does not start correctly. |
| CastShellTimeoutError: if cast_shell takes too long to become available. |
| """ |
| if test_project_path not in sys.path: |
| sys.path.append(test_project_path) |
| |
| if use_headless_ozone_platform: |
| ozone_platform = _OZONE_PLATFORM_HEADLESS |
| else: |
| ozone_platform = _OZONE_PLATFORM_CAST |
| |
| command = [ |
| cast_shell_path, |
| '--no-sandbox', |
| '--disable-gpu', |
| '--ignore-gpu-blacklist', |
| '--ozone-platform={}'.format(ozone_platform), |
| '--port-for-cast-v2={}'.format(_CAST_V2_PORT), |
| '--port-for-multizone={}'.format(_MULTIZONE_PORT), |
| '--remote-debugging-port={}'.format(_REMOTE_DUBIGGING_PORT), |
| '--port-for-mdns-multicast={}'.format(_MDNS_MULTICAST_PORT), |
| '--gcm-sub-path="{}"'.format(_GCM_SUB_PATH), |
| '--disable-reboot-on-config-update', |
| ] |
| |
| if launch_utility_process: |
| # max-output-volume-dba1m is required when --launch-utility-process |
| # is specified. |
| command.extend(['--launch-utility-process', '--max-output-volume-dba1m=88']) |
| |
| if enable_cma_media_pipeline: |
| command.append('--enable-cma-media-pipeline') |
| if enable_ccs_transport_v2: |
| command.append('--enable-ccs-transport-v2') |
| |
| # HOME and TMPDIR should be unique across any running cast_shell instances. |
| cast_shell_env = {'HOME': tmp_dir, 'TMPDIR': tmp_dir} |
| |
| try: |
| subprocess, log_file_descriptor, log_file_path = ( |
| executor.exec_non_blocking_subprocess(command, env=cast_shell_env)) |
| |
| if subprocess.returncode is not None: |
| raise CastShellError('cast_shell startup failed') |
| |
| if not _await_cast_shell_availability(): |
| raise CastShellTimeoutError( |
| 'cast_shell took too long to become available.') |
| |
| except: |
| logging.exception('Unable to start Cast Shell.') |
| _dump_cast_shell_logs(log_file_descriptor, log_file_path) |
| raise |
| |
| logging.info('cast_shell started successfully.') |
| return subprocess, log_file_descriptor, log_file_path |
| |
| |
| def _dump_cast_shell_logs(log_file_descriptor, log_file_path): |
| """Dump the cast_shell logcat to the logger.""" |
| os.fsync(log_file_descriptor) |
| os.close(log_file_descriptor) |
| with open(log_file_path, 'r') as log_file: |
| logging.info(_DUMP_LOGCAT_MSG, log_file.read()) |
| |
| |
| def upload_cast_shell_logs(log_file_descriptor, log_file_path, gcs_root_dir, |
| sub_dir, upload_file_name): |
| """Move the process log file into GCS folder so that it's uploaded. |
| |
| Args: |
| log_file_descriptor: File descriptor of log file to be copied. This should |
| be the descriptor returned by start_cast_shell() above. |
| log_file_path: Absolute path of the log file to be copied. This should be |
| the path returned by start_cast_shell() above. |
| gcs_root_dir: The GCS directory. |
| sub_dir: The sub directory within GCS to upload to. |
| upload_file_name: A string; log file will be renamed and uploaded with this |
| name. |
| """ |
| output_dir = os.path.join(gcs_root_dir, sub_dir) |
| if not os.path.exists(output_dir): |
| os.makedirs(output_dir) |
| os.fsync(log_file_descriptor) |
| shutil.copy(log_file_path, os.path.join(output_dir, upload_file_name)) |
| logging.info( |
| 'cast_shell log will be avilable on GCS in the gs://cast-cq bucket under ' |
| '%s for this CL. (See the end of stdio of the "shell" step for the ' |
| 'specific path. It should be similar to: ' |
| 'gs://cast-cq/eureka-interal/project/branch/issue/patchset', |
| os.path.join(sub_dir, upload_file_name)) |
| |
| |
| def _get_eureka_info(executor, ip_address='localhost', params=None): |
| """Returns the parsed eureka info. |
| |
| The //chromecast/internal/setup/eureka_info.h describes the various fields |
| that will be available. |
| |
| This assumes that a cast_shell instance is available at |ip_address| (such |
| as from running start_cast_shell above). |
| |
| Args: |
| executor: Executor to run the subprocess |
| ip_address: IP address (as a string) of the device to get the info from. |
| (Defaults to 'localhost') |
| params: A list of parameters (as strings) to get from euruka_info. This must |
| be specified and cannot be an empty list. e.g. params=['name', |
| 'wifi.ssid', 'setup.stats.num_obtain_ip'] will |
| return {'name': 'devicename', |
| 'wifi': {'ssid': 'wifi-ssid-value'}, |
| 'setup': {'stats': {'num_obtain_ip': 0}}} |
| |
| Returns: |
| A dict with the eureka_info information on success, or an empty dict on |
| failure. |
| """ |
| api_endpoint = _SETUP_API_ROOT.format( |
| device_ip=ip_address, |
| api='eureka_info?params={}'.format(','.join(params))) |
| logging.info('Making Request to API Endpoint: %s', api_endpoint) |
| returncode, json_result, _ = executor.exec_subprocess(['curl', api_endpoint], |
| quiet=True) |
| if returncode == 0: |
| return json.loads(json_result) |
| |
| return {} |
| |
| |
| def get_device_name(executor, ip_address='localhost'): |
| """Returns the name of the device at |ip_address|.""" |
| return _get_eureka_info(executor, ip_address, ['name']).get('name') |
| |
| |
| def _await_cast_shell_availability(max_wait_time=_AVAILABILITY_WAIT_TIME_SECS): |
| """Wait's until max_wait_time seconds for cast shell availability. |
| |
| Args: |
| max_wait_time: Integer of wait time in seconds. |
| |
| Returns: |
| True if cast shell is up and running. |
| """ |
| remaining_time = max_wait_time |
| while remaining_time >= 0: |
| time.sleep(_AVAILABILITY_RETRY_WAIT_TIME_SECS) |
| remaining_time -= _AVAILABILITY_RETRY_WAIT_TIME_SECS |
| try: |
| if _connect_cast_shell(): |
| return True |
| except (socket.error, AssertionError): |
| logging.exception('Cast shell is not ready, will retry for %d seconds', |
| remaining_time) |
| return False |
| |
| |
| def _connect_cast_shell(): |
| """Returns True if able to to connect and send a message to cast shell. |
| |
| Returns: |
| True if able to to connect and send a message to cast shell. |
| |
| Raises: |
| Socket.error: if not able to connect to cast shell. |
| AssertionError: if not able to send message to cast shell. |
| """ |
| |
| # Assumes that test/ is on the PYTHONPATH |
| transport = __import__( |
| 'cast.tools.transport', |
| fromlist=[ |
| 'application', 'application_connection', 'receiver', 'transport' |
| ]) |
| receiver = transport.receiver.Receiver('receiver-0', 'localhost') |
| app = transport.application.Application(receiver, _CAST_V2_DEBUG_APP_ID) |
| try: |
| with (transport.application_connection.ApplicationConnection( |
| app, 'sender-1')) as app_conn: |
| app_conn.ConnectToReceiver() |
| message = transport.transport.BuildMessage( |
| 'sender-1', 'receiver-0', _CONNECTION_NAMESPACE, b'', binary=True) |
| app_conn.Send(message) |
| app_conn.Status() |
| except transport.socket_helper.SocketError as e: |
| raise socket.error(socket.error(e), sys.exc_info()[2]) |
| |
| return True |