diff --git a/src/tools/interop/idt/.gitignore b/src/tools/interop/idt/.gitignore index d2216005c33dc3..55da2a806dd412 100644 --- a/src/tools/interop/idt/.gitignore +++ b/src/tools/interop/idt/.gitignore @@ -7,3 +7,4 @@ __pycache__/ pycache/ venv/ .zip +BUILD diff --git a/src/tools/interop/idt/README.md b/src/tools/interop/idt/README.md index dc60067fba6fe3..4651d90437f7ce 100644 --- a/src/tools/interop/idt/README.md +++ b/src/tools/interop/idt/README.md @@ -308,5 +308,4 @@ This module must contain a single class which is a subclass of Note the following runtime expectations of platforms: -- Start should be able to be called repeatedly without restarting streaming. -- Stop should not cause an error even if the stream is not running. +- Connect, Start, and Stop may be called multiple times. i.e. start may be called when already streaming. diff --git a/src/tools/interop/idt/capture/base.py b/src/tools/interop/idt/capture/base.py index 6c1ab6fc8be8be..4f45e446f6b1d9 100644 --- a/src/tools/interop/idt/capture/base.py +++ b/src/tools/interop/idt/capture/base.py @@ -30,6 +30,13 @@ def __init__(self, artifact_dir: str) -> None: """ raise NotImplementedError + @abstractmethod + async def connect(self) -> None: + """ + Establish connections to log targets for this platform + """ + raise NotImplementedError + @abstractmethod async def start_streaming(self) -> None: """ diff --git a/src/tools/interop/idt/capture/ecosystem/play_services/analysis.py b/src/tools/interop/idt/capture/ecosystem/play_services/analysis.py index 46fcfc1c4d6734..d3aba9dc3cf24b 100644 --- a/src/tools/interop/idt/capture/ecosystem/play_services/analysis.py +++ b/src/tools/interop/idt/capture/ecosystem/play_services/analysis.py @@ -89,7 +89,7 @@ def process_line(self, line: str) -> None: getattr(self, line_func)(line) def do_analysis(self) -> None: - with open(self.platform.logcat_output_path, mode='r') as logcat_file: + with open(self.platform.streams["LogcatStreamer"].logcat_artifact, mode='r') as logcat_file: for line in logcat_file: self.process_line(line) self._show_analysis() diff --git a/src/tools/interop/idt/capture/ecosystem/play_services/play_services.py b/src/tools/interop/idt/capture/ecosystem/play_services/play_services.py index aa9276844471ea..dc9d7d0b0c6ad3 100644 --- a/src/tools/interop/idt/capture/ecosystem/play_services/play_services.py +++ b/src/tools/interop/idt/capture/ecosystem/play_services/play_services.py @@ -19,12 +19,14 @@ import os from typing import Dict +from capture import log_format from capture.base import EcosystemCapture, UnsupportedCapturePlatformException from capture.file_utils import create_standard_log_name from capture.platform.android import Android from .analysis import PlayServicesAnalysis from .command_map import dumpsys, getprop +from .prober import PlayServicesProber class PlayServices(EcosystemCapture): @@ -33,7 +35,7 @@ class PlayServices(EcosystemCapture): """ def __init__(self, platform: Android, artifact_dir: str) -> None: - + self.logger = log_format.get_logger(__file__) self.artifact_dir = artifact_dir if not isinstance(platform, Android): @@ -55,7 +57,7 @@ def __init__(self, platform: Android, artifact_dir: str) -> None: def _write_standard_info_file(self) -> None: for k, v in self.standard_info_data.items(): - print(f"{k}: {v}") + self.logger.info(f"{k}: {v}") standard_info_data_json = json.dumps(self.standard_info_data, indent=2) with open(self.standard_info_file_path, mode='w+') as standard_info_file: standard_info_file.write(standard_info_data_json) @@ -94,3 +96,4 @@ async def stop_capture(self) -> None: async def analyze_capture(self) -> None: self.analysis.do_analysis() + await PlayServicesProber(self.platform).probe_services() diff --git a/src/tools/interop/idt/capture/ecosystem/play_services/prober.py b/src/tools/interop/idt/capture/ecosystem/play_services/prober.py new file mode 100644 index 00000000000000..972a78b1914f94 --- /dev/null +++ b/src/tools/interop/idt/capture/ecosystem/play_services/prober.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os + +from capture import log_format +from capture.shell_utils import Bash + + +class PlayServicesProber: + + def __init__(self, platform): + self.platform = platform + self.artifact_dir = self.platform.artifact_dir + self.logger = log_format.get_logger(__file__) + + async def _probe_foyer(self) -> None: + self.logger.info("probing remote services") + tgt = "googlehomefoyer-pa.googleapis.com" + probe_artifact = os.path.join(self.artifact_dir, "net_probes.txt") + command_suffix = f" 2>&1 | tee -a {probe_artifact}" + ping_cmd = f"ping -c 4 {tgt} {command_suffix}" + Bash(ping_cmd, sync=True).start_command() + trace_cmd_i = f"sudo traceroute {tgt} {command_suffix}" + Bash(trace_cmd_i, sync=True).start_command() + trace_cmd_t = f"sudo traceroute -T -p 443 {tgt} {command_suffix}" + Bash(trace_cmd_t, sync=True).start_command() + trace_cmd_u = f"sudo traceroute -U -p 443 {tgt} {command_suffix}" + Bash(trace_cmd_u, sync=True).start_command() + dig_cmd = f"dig {tgt} {command_suffix}" + Bash(dig_cmd, sync=True).start_command() + self.logger.info("probing from phone") + ping_from_phone = f"shell {ping_cmd} {command_suffix}" + self.platform.run_adb_command(ping_from_phone) + + async def probe_services(self) -> None: + for probe_func in filter(lambda s: s.startswith('_probe'), dir(self)): + await getattr(self, probe_func)() diff --git a/src/tools/interop/idt/capture/ecosystem/play_services_user/play_services_user.py b/src/tools/interop/idt/capture/ecosystem/play_services_user/play_services_user.py index 5044842e80eb28..87a6fedaac5f51 100644 --- a/src/tools/interop/idt/capture/ecosystem/play_services_user/play_services_user.py +++ b/src/tools/interop/idt/capture/ecosystem/play_services_user/play_services_user.py @@ -49,7 +49,7 @@ async def stop_capture(self) -> None: async def analyze_capture(self) -> None: """"Show the start and end times of commissioning boundaries""" analysis_file = open(self.analysis_file, mode='w+') - with open(self.platform.logcat_output_path, mode='r') as logcat_file: + with open(self.platform.streams["LogcatStreamer"].logcat_artifact, mode='r') as logcat_file: for line in logcat_file: if "CommissioningServiceBin: Binding to service" in line: print_and_write( diff --git a/src/tools/interop/idt/capture/factory.py b/src/tools/interop/idt/capture/factory.py index 3c86045f146a0e..2ed2bd43325987 100644 --- a/src/tools/interop/idt/capture/factory.py +++ b/src/tools/interop/idt/capture/factory.py @@ -22,12 +22,15 @@ import typing import capture +from capture import log_format from capture.base import EcosystemCapture, PlatformLogStreamer, UnsupportedCapturePlatformException from capture.file_utils import border_print, safe_mkdir _CONFIG_TIMEOUT = 45.0 _PLATFORM_MAP: typing.Dict[str, PlatformLogStreamer] = {} _ECOSYSTEM_MAP: typing.Dict[str, PlatformLogStreamer] = {} +_ERROR_REPORT: typing.Dict[str, list[str]] = {} +logger = log_format.get_logger(__file__) def _get_timeout(): @@ -41,16 +44,18 @@ def list_available_platforms() -> typing.List[str]: return copy.deepcopy(capture.platform.__all__) @staticmethod - def get_platform_impl( + async def get_platform_impl( platform: str, artifact_dir: str) -> PlatformLogStreamer: if platform in _PLATFORM_MAP: return _PLATFORM_MAP[platform] + border_print(f"Initializing platform {platform}") platform_class = getattr(capture.platform, platform) platform_artifact_dir = os.path.join(artifact_dir, platform) safe_mkdir(platform_artifact_dir) platform_inst = platform_class(platform_artifact_dir) _PLATFORM_MAP[platform] = platform_inst + await platform_inst.connect() return platform_inst @@ -63,21 +68,23 @@ def list_available_ecosystems() -> typing.List[str]: @staticmethod async def get_ecosystem_impl( ecosystem: str, - platform: str, + platform: PlatformLogStreamer, artifact_dir: str) -> EcosystemCapture: if ecosystem in _ECOSYSTEM_MAP: return _ECOSYSTEM_MAP[ecosystem] + border_print(f"Initializing ecosystem {ecosystem}") ecosystem_class = getattr(capture.ecosystem, ecosystem) ecosystem_artifact_dir = os.path.join(artifact_dir, ecosystem) safe_mkdir(ecosystem_artifact_dir) - platform_instance = PlatformFactory.get_platform_impl( - platform, artifact_dir) - ecosystem_instance = ecosystem_class(platform_instance, ecosystem_artifact_dir) + ecosystem_instance = ecosystem_class(platform, ecosystem_artifact_dir) _ECOSYSTEM_MAP[ecosystem] = ecosystem_instance return ecosystem_instance @staticmethod async def init_ecosystems(platform, ecosystem, artifact_dir): + async with asyncio.timeout_at(_get_timeout()): + platform = await PlatformFactory.get_platform_impl( + platform, artifact_dir) ecosystems_to_load = EcosystemFactory.list_available_ecosystems() \ if ecosystem == 'ALL' \ else [ecosystem] @@ -87,12 +94,18 @@ async def init_ecosystems(platform, ecosystem, artifact_dir): await EcosystemFactory.get_ecosystem_impl( ecosystem, platform, artifact_dir) except UnsupportedCapturePlatformException: - print(f"ERROR unsupported platform {ecosystem} {platform}") + logger.error(f"Unsupported platform {ecosystem} {platform}") except TimeoutError: - print(f"ERROR timeout starting ecosystem {ecosystem} {platform}") + logger.error(f"Timeout starting ecosystem {ecosystem} {platform}") except Exception: - print("ERROR unknown error instantiating ecosystem") - print(traceback.format_exc()) + logger.error("unknown error instantiating ecosystem") + logger.error(traceback.format_exc()) + + +def track_error(ecosystem: str, error_type: str) -> None: + if ecosystem not in _ERROR_REPORT: + _ERROR_REPORT[ecosystem] = [] + _ERROR_REPORT[ecosystem].append(error_type) class EcosystemController: @@ -102,14 +115,16 @@ async def handle_capture(attr): attr = f"{attr}_capture" for ecosystem in _ECOSYSTEM_MAP: try: - border_print(f"{attr} capture for {ecosystem}") + border_print(f"{attr} for {ecosystem}") async with asyncio.timeout_at(_get_timeout()): await getattr(_ECOSYSTEM_MAP[ecosystem], attr)() except TimeoutError: - print(f"ERROR timeout {attr} {ecosystem}") + logger.error(f"timeout {attr} {ecosystem}") + track_error(ecosystem, "TIMEOUT") except Exception: - print(f"ERROR unexpected error {attr} {ecosystem}") - print(traceback.format_exc()) + logger.error(f"unexpected error {attr} {ecosystem}") + logger.error(traceback.format_exc()) + track_error(ecosystem, "UNEXPECTED") @staticmethod async def start(): @@ -121,4 +136,14 @@ async def stop(): @staticmethod async def analyze(): + # TODO: allow real time, not just post await EcosystemController.handle_capture("analyze") + + @staticmethod + def error_report(): + for k, v in _ERROR_REPORT.items(): + print(f"{k}: {v}") + + @staticmethod + def has_errors(): + return len(_ERROR_REPORT) > 0 diff --git a/src/tools/interop/idt/capture/file_utils.py b/src/tools/interop/idt/capture/file_utils.py index 29aabfe6133193..b906223d19fbb9 100644 --- a/src/tools/interop/idt/capture/file_utils.py +++ b/src/tools/interop/idt/capture/file_utils.py @@ -15,6 +15,7 @@ # limitations under the License. # +import os import time from pathlib import Path from typing import TextIO @@ -30,10 +31,10 @@ def create_file_timestamp() -> str: return time.strftime("%Y%m%d_%H%M%S") -def create_standard_log_name(name: str, ext: str) -> str: +def create_standard_log_name(name: str, ext: str, parent: str = "") -> str: """Returns the name argument wrapped as a standard log name""" ts = create_file_timestamp() - return f'idt_{ts}_{name}.{ext}' + return os.path.join(parent, f'idt_{ts}_{name}.{ext}') def safe_mkdir(dir_name: str) -> None: @@ -41,12 +42,12 @@ def safe_mkdir(dir_name: str) -> None: def print_and_write(to_print: str, file: TextIO) -> None: - print(to_print) + print(f"\x1b[32;1m{to_print}\x1b[0m") file.write(to_print) def border_print(to_print: str, important: bool = False) -> None: - len_borders = 64 + len_borders = len(to_print) border = f"\n{'_' * len_borders}\n" i_border = f"\n{'!' * len_borders}\n" if important else "" - print(f"{border}{i_border}{to_print}{i_border}{border}") + print(f"\x1b[35;1m{border}{i_border}{to_print}{i_border}{border}\x1b[0m") diff --git a/src/tools/interop/idt/capture/loader.py b/src/tools/interop/idt/capture/loader.py index 27fd7c19dd6560..7da41cfb77d6ca 100644 --- a/src/tools/interop/idt/capture/loader.py +++ b/src/tools/interop/idt/capture/loader.py @@ -18,12 +18,18 @@ import importlib import inspect import os +import traceback from typing import Any +from capture import log_format + +logger = log_format.get_logger(__file__) + class CaptureImplsLoader: def __init__(self, root_dir: str, root_package: str, search_type: type): + self.logger = logger self.root_dir = root_dir self.root_package = root_package self.search_type = search_type @@ -49,14 +55,17 @@ def verify_coroutines(self, subclass) -> bool: def is_type_match(self, potential_class_match: Any) -> bool: if inspect.isclass(potential_class_match): + self.logger.debug(f"Checking {self.search_type} match against {potential_class_match}") if issubclass(potential_class_match, self.search_type): + self.logger.debug(f"Found type match search: {self.search_type} match: {potential_class_match}") if self.verify_coroutines(potential_class_match): return True else: - print(f"WARNING missing coroutine {potential_class_match}") + self.logger.warning(f"Missing coroutine {potential_class_match}") return False def load_module(self, to_load): + self.logger.debug(f"Loading module {to_load}") saw_more_than_one_impl = False saw_one_impl = False found_class = None @@ -73,14 +82,17 @@ def load_module(self, to_load): self.impl_names.append(found_class) self.impls[found_class] = found_impl elif saw_more_than_one_impl: - print(f"WARNING more than one impl in {module_item}") + self.logger.warning(f"more than one impl in {module_item}") def fetch_impls(self): + self.logger.debug(f"Searching for implementations in {self.root_dir}") for item in os.listdir(self.root_dir): dir_content = os.path.join(self.root_dir, item) if self.is_package(dir_content): + self.logger.debug(f"Found package in {dir_content}") try: module = importlib.import_module("." + item, self.root_package) self.load_module(module) except ModuleNotFoundError: - print(f"WARNING no module matching package name for {item}") + self.logger.warning(f"No module matching package name for {item}") + traceback.print_exc() diff --git a/src/tools/interop/idt/capture/log_format.py b/src/tools/interop/idt/capture/log_format.py new file mode 100644 index 00000000000000..29684af4097220 --- /dev/null +++ b/src/tools/interop/idt/capture/log_format.py @@ -0,0 +1,59 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging + + +class LoggingFormatter(logging.Formatter): + + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + + red = "\x1b[31;40;20m" + bold_red = "\x1b[31;43;1m" + + magenta = "\x1b[35;1m" + cyan = "\x1b[36;1m" + green = "\x1b[32;1m" + blue = "\x1b[34;1m" + + reset = "\x1b[0m" + format_pre = cyan + "%(asctime)s %(levelname)s {%(module)s} [%(funcName)s] " + reset + format_post = "%(message)s" + reset + + FORMATS = { + logging.DEBUG: format_pre + blue + format_post, + logging.INFO: format_pre + green + format_post, + logging.WARNING: format_pre + yellow + format_post, + logging.ERROR: format_pre + red + format_post, + logging.CRITICAL: format_pre + bold_red + format_post + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +def get_logger(logger_name) -> logging.Logger: + logger = logging.getLogger(logger_name) + logger.setLevel(logging.DEBUG) + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + ch.setFormatter(LoggingFormatter()) + logger.addHandler(ch) + return logger diff --git a/src/tools/interop/idt/capture/pcap/pcap.py b/src/tools/interop/idt/capture/pcap/pcap.py index 8208d59f87d780..45028027c642d3 100644 --- a/src/tools/interop/idt/capture/pcap/pcap.py +++ b/src/tools/interop/idt/capture/pcap/pcap.py @@ -18,6 +18,7 @@ import os import time +from capture import log_format from capture.file_utils import create_standard_log_name from capture.shell_utils import Bash @@ -25,7 +26,7 @@ class PacketCaptureRunner: def __init__(self, artifact_dir: str, interface: str) -> None: - + self.logger = log_format.get_logger(__file__) self.artifact_dir = artifact_dir self.output_path = str( os.path.join( @@ -40,22 +41,23 @@ def __init__(self, artifact_dir: str, interface: str) -> None: def start_pcap(self) -> None: self.pcap_proc.start_command() - print("Pausing to check if pcap started...") + self.logger.info("Pausing to check if pcap started...") time.sleep(self.start_delay_seconds) if not self.pcap_proc.command_is_running(): - print( + self.logger.error( "Pcap did not start, you might need root; please authorize if prompted.") - Bash("sudo echo \"\"", sync=True) - print("Retrying pcap with sudo...") + Bash("sudo echo \"\"", sync=True).start_command() + self.logger.warning("Retrying pcap with sudo...") self.pcap_command = f"sudo {self.pcap_command}" self.pcap_proc = Bash(self.pcap_command) self.pcap_proc.start_command() time.sleep(self.start_delay_seconds) if not self.pcap_proc.command_is_running(): - print("WARNING Failed to start pcap!") + self.logger.error("Failed to start pcap!") else: - print(f"Pcap output path {self.output_path}") + self.logger.info(f"Pcap output path {self.output_path}") def stop_pcap(self) -> None: + self.logger.info("Stopping pcap proc") self.pcap_proc.stop_command(soft=True) - print("Pcap stopped") + diff --git a/src/tools/interop/idt/capture/platform/android/__init__.py b/src/tools/interop/idt/capture/platform/android/__init__.py index 7ec319feb0e9a3..021ec15688c706 100644 --- a/src/tools/interop/idt/capture/platform/android/__init__.py +++ b/src/tools/interop/idt/capture/platform/android/__init__.py @@ -18,5 +18,5 @@ from .android import Android __all__ = [ - 'Android' + 'Android', ] diff --git a/src/tools/interop/idt/capture/platform/android/android.py b/src/tools/interop/idt/capture/platform/android/android.py index 2a394495835ef5..9340937942e652 100644 --- a/src/tools/interop/idt/capture/platform/android/android.py +++ b/src/tools/interop/idt/capture/platform/android/android.py @@ -15,64 +15,57 @@ # limitations under the License. # -import asyncio import ipaddress -import os +import traceback import typing +from capture import log_format from capture.base import PlatformLogStreamer -from capture.file_utils import create_standard_log_name from capture.shell_utils import Bash +from . import streams +from .capabilities import Capabilities + +logger = log_format.get_logger(__file__) class Android(PlatformLogStreamer): - """ - Class that supports: - - Running synchronous adb commands - - Maintaining a singleton logcat stream - - Maintaining a singleton screen recording - """ def __init__(self, artifact_dir: str) -> None: - + self.logger = logger self.artifact_dir = artifact_dir - self.device_id: str | None = None self.adb_devices: typing.Dict[str, bool] = {} - self._authorize_adb() - - self.logcat_output_path = os.path.join( - self.artifact_dir, create_standard_log_name( - 'logcat', 'txt')) - self.logcat_command = f'adb -s {self.device_id} logcat -T 1 >> {self.logcat_output_path}' - self.logcat_proc = Bash(self.logcat_command) - - screen_cast_name = create_standard_log_name('screencast', 'mp4') - self.screen_cap_output_path = os.path.join( - self.artifact_dir, screen_cast_name) - self.check_screen_command = "shell dumpsys deviceidle | grep mScreenOn" - self.screen_path = f'/sdcard/Movies/{screen_cast_name}' - self.screen_command = f'adb -s {self.device_id} shell screenrecord --bugreport {self.screen_path}' - self.screen_proc = Bash(self.screen_command) - self.pull_screen = False - self.screen_pull_command = f'pull {self.screen_path} {self.screen_cap_output_path}' + self.capabilities: None | Capabilities = None + self.streams = {} + self.connected = False def run_adb_command( self, command: str, - capture_output: bool = False) -> Bash: + capture_output: bool = False, + cwd=None) -> Bash: """ Run an adb command synchronously Capture_output must be true to call get_captured_output() later """ - return Bash( + bash_command = Bash( f'adb -s {self.device_id} {command}', sync=True, - capture_output=capture_output) + capture_output=capture_output, + cwd=cwd) + bash_command.start_command() + return bash_command + + def get_adb_background_command( + self, + command: str, + cwd=None) -> Bash: + return Bash(f'adb -s {self.device_id} {command}', cwd=cwd) def get_adb_devices(self) -> typing.Dict[str, bool]: """Returns a dict of device ids and whether they are authorized""" adb_devices = Bash('adb devices', sync=True, capture_output=True) + adb_devices.start_command() adb_devices_output = adb_devices.get_captured_output().split('\n') devices_auth = {} header_done = False @@ -83,11 +76,11 @@ def get_adb_devices(self) -> typing.Dict[str, bool]: device_is_auth = line_parsed[1] == "device" if line_parsed[1] == "offline": disconnect_command = f"adb disconnect {device_id}" - print(f"Device {device_id} is offline, trying disconnect!") + self.logger.warning(f"Device {device_id} is offline, trying disconnect!") Bash( disconnect_command, sync=True, - capture_output=False) + capture_output=False).start_command() else: devices_auth[device_id] = device_is_auth header_done = True @@ -103,11 +96,11 @@ def _get_first_connected_device(self) -> str: def _set_device_if_only_one_connected(self) -> None: if self._only_one_device_connected(): self.device_id = self._get_first_connected_device() - print(f'Only one device detected; using {self.device_id}') + self.logger.warning(f'Only one device detected; using {self.device_id}') def _log_adb_devices(self) -> None: for dev in self.adb_devices: - print(dev) + self.logger.info(dev) @staticmethod def _is_connection_str(adb_input_str: str) -> bool: @@ -134,22 +127,23 @@ def _is_connection_str(adb_input_str: str) -> bool: def _check_connect_wireless_adb(self, temp_device_id: str) -> None: if Android._is_connection_str(temp_device_id): connect_command = f"adb connect {temp_device_id}" - print( + self.logger.warning( f"Detected connection string; attempting to connect: {connect_command}") - Bash(connect_command, sync=True, capture_output=False) + Bash(connect_command, sync=True, capture_output=False).start_command() self.get_adb_devices() def _device_id_user_input(self) -> None: - print('If there is no output below, press enter after connecting your phone under test OR') - print('Enter (copy paste) the target device id from the list of available devices below OR') - print('Enter $IP4:$PORT to connect wireless debugging.') + # TODO: Fix + self.logger.critical('If there is no output below, press enter after connecting your phone under test OR') + self.logger.critical('Enter (copy paste) the target device id from the list of available devices below OR') + self.logger.critical('Enter $IP4:$PORT to connect wireless debugging.') self._log_adb_devices() temp_device_id = input('').strip() self._check_connect_wireless_adb(temp_device_id) if self._only_one_device_connected(): self._set_device_if_only_one_connected() elif temp_device_id not in self.adb_devices: - print('Entered device not in adb devices!') + self.logger.warning('Entered device not in adb devices!') else: self.device_id = temp_device_id @@ -161,7 +155,7 @@ def _choose_device_id(self) -> None: self._set_device_if_only_one_connected() while self.device_id not in self.get_adb_devices(): self._device_id_user_input() - print(f'Selected device {self.device_id}') + self.logger.info(f'Selected device {self.device_id}') def _authorize_adb(self) -> None: """ @@ -170,48 +164,32 @@ def _authorize_adb(self) -> None: self.get_adb_devices() self._choose_device_id() while not self.get_adb_devices()[self.device_id]: - print('Confirming authorization, press enter after auth') + self.logger.info('Confirming authorization, press enter after auth') input('') - print(f'Target android device ID is authorized: {self.device_id}') - - def check_screen(self) -> bool: - screen_cmd_output = self.run_adb_command( - self.check_screen_command, capture_output=True) - return "true" in screen_cmd_output.get_captured_output() - - async def prepare_screen_recording(self) -> None: - if self.screen_proc.command_is_running(): - return - try: - async with asyncio.timeout_at(asyncio.get_running_loop().time() + 20.0): - screen_on = self.check_screen() - print("Please turn the screen on so screen recording can start!") - while not screen_on: - await asyncio.sleep(2) - screen_on = self.check_screen() - if not screen_on: - print("Screen is still not on for recording!") - except TimeoutError: - print("WARNING screen recording timeout") - return + self.logger.info(f'Target android device ID is authorized: {self.device_id}') + + async def connect(self) -> None: + if not self.connected: + self._authorize_adb() + self.capabilities = Capabilities(self) + self.capabilities.check_capabilities() + for stream in streams.__all__: + self.streams[stream] = getattr(streams, stream)(self) + self.connected = True + + async def handle_stream_action(self, action: str) -> None: + had_error = False + for stream_name, stream in self.streams.items(): + try: + await getattr(stream, action)() + except: + traceback.print_exc() + had_error = True + if had_error: + raise Exception("Propagating to controller!") async def start_streaming(self) -> None: - await self.prepare_screen_recording() - if self.check_screen(): - self.pull_screen = True - self.screen_proc.start_command() - self.logcat_proc.start_command() - - async def pull_screen_recording(self) -> None: - if self.pull_screen: - self.screen_proc.stop_command() - print("screen proc stopped") - await asyncio.sleep(3) - self.run_adb_command(self.screen_pull_command) - print("screen recording pull attempted") - self.pull_screen = False + await self.handle_stream_action("start") async def stop_streaming(self) -> None: - await self.pull_screen_recording() - self.logcat_proc.stop_command() - print("logcat stopped") + await self.handle_stream_action("stop") diff --git a/src/tools/interop/idt/capture/platform/android/capabilities.py b/src/tools/interop/idt/capture/platform/android/capabilities.py new file mode 100644 index 00000000000000..b713b5c49e1a1a --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/capabilities.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +from capture import log_format +from capture.shell_utils import Bash + +if TYPE_CHECKING: + from capture.platform.android import Android + +logger = log_format.get_logger(__file__) + + +class Capabilities: + + def __init__(self, platform: "Android"): + self.logger = logger + self.platform = platform + self.has_tcpdump = False + self.has_root = False + self.is_64 = False + + def check_capabilities(self): + self.logger.info("Checking if phone has tcpdump") + self.has_tcpdump = self.platform.run_adb_command( + "shell which tcpdump", capture_output=True).finished_success() + self.logger.info("Checking if phone has root") + self.has_root = self.platform.run_adb_command( + "shell which su", capture_output=True).finished_success() + if self.has_root: + self.logger.warning("adb root!") + Bash("adb root", sync=True).start_command() + self.logger.info("Checking CPU arch") + cpu_arch_return = self.platform.run_adb_command( + "shell cat /proc/cpuinfo | grep rch", capture_output=True) + cpu_arch_output = cpu_arch_return.get_captured_output() + self.is_64 = cpu_arch_return.finished_success() and "8" in cpu_arch_output diff --git a/src/tools/interop/idt/capture/platform/android/streams/__init__.py b/src/tools/interop/idt/capture/platform/android/streams/__init__.py new file mode 100644 index 00000000000000..65b7998abd3b4f --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/__init__.py @@ -0,0 +1,13 @@ +from capture.loader import CaptureImplsLoader +from .streams_base import AndroidStream + +impl_loader = CaptureImplsLoader( + __path__[0], + "capture.platform.android.streams", + AndroidStream +) + +for impl_name, impl in impl_loader.impls.items(): + globals()[impl_name] = impl + +__all__ = impl_loader.impl_names diff --git a/src/tools/interop/idt/capture/platform/android/streams/android_pcap/__init__.py b/src/tools/interop/idt/capture/platform/android/streams/android_pcap/__init__.py new file mode 100644 index 00000000000000..cbd2cc919831ec --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/android_pcap/__init__.py @@ -0,0 +1,20 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .android_pcap import AndroidPcap + +__all__ = ["AndroidPcap"] diff --git a/src/tools/interop/idt/capture/platform/android/streams/android_pcap/android_pcap.py b/src/tools/interop/idt/capture/platform/android/streams/android_pcap/android_pcap.py new file mode 100644 index 00000000000000..7b46fc3ec2b341 --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/android_pcap/android_pcap.py @@ -0,0 +1,79 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import asyncio +import os + +from capture import log_format +from capture.file_utils import create_standard_log_name, safe_mkdir +from capture.shell_utils import Bash +from typing import TYPE_CHECKING +from ..streams_base import AndroidStream + +if TYPE_CHECKING: + from capture.platform.android import Android + +logger = log_format.get_logger(__file__) + + +class AndroidPcap(AndroidStream): + + def __init__(self, platform: "Android"): + self.logger = logger + self.platform = platform + self.pcap_artifact = create_standard_log_name("android_tcpdump", "cap", parent=platform.artifact_dir) + self.pcap_phone_out_path = f"/sdcard/Movies/{os.path.basename(self.pcap_artifact)}" + self.pcap_phone_bin_location = "tcpdump" if platform.capabilities.has_tcpdump else "/sdcard/Movies/tcpdump" + self.pcap_command = f"shell {self.pcap_phone_bin_location} -w {self.pcap_phone_out_path}" + self.pcap_proc = platform.get_adb_background_command(self.pcap_command) + self.pcap_pull = False + self.pcap_pull_command = f"pull {self.pcap_phone_out_path} {self.pcap_artifact}" + self.build_dir = os.path.join(os.path.dirname(__file__), "../BUILD") + + async def pull_packet_capture(self) -> None: + if self.pcap_pull: + self.logger.info("Attempting to pull android pcap") + await asyncio.sleep(3) + self.platform.run_adb_command(self.pcap_pull_command) + self.pcap_pull = False + + async def start(self): + safe_mkdir(self.build_dir) + if self.platform.capabilities.has_tcpdump: + self.logger.info("tcpdump already available on this phone; using!") + self.pcap_proc.start_command() + self.pcap_pull = True + return + if not os.path.exists(os.path.join(self.build_dir, "tcpdump")): + self.logger.warning("tcpdump bin not found, attempting to build, please wait a few moments!") + build_script = os.path.join(os.path.dirname(__file__), "../build_tcpdump_64.sh") + Bash(f"{build_script} 2>&1 >> BUILD_LOG.txt", sync=True, cwd=self.build_dir).start_command() + else: + self.logger.warning("Reusing existing tcpdump build") + if not self.platform.run_adb_command("shell ls /sdcard/Movies/tcpdump").finished_success(): + self.logger.warning("Pushing tcpdump to device") + self.platform.run_adb_command(f'push {os.path.join(self.build_dir, "tcpdump")} /sdcard/Movies/') + self.platform.run_adb_command("chmod +x /sdcard/Movies/tcpdump") + else: + self.logger.info("tcpdump already in the expected location, not pushing!") + self.pcap_proc.start_command() + self.pcap_pull = True + + async def stop(self): + self.logger.info("Stopping android pcap proc") + self.pcap_proc.stop_command() + await self.pull_packet_capture() diff --git a/src/tools/interop/idt/capture/platform/android/streams/build_tcpdump_64.sh b/src/tools/interop/idt/capture/platform/android/streams/build_tcpdump_64.sh new file mode 100755 index 00000000000000..914da032c6f477 --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/build_tcpdump_64.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e +export TCPDUMP=4.99.4 +export LIBPCAP=1.10.4 + +wget https://www.tcpdump.org/release/tcpdump-$TCPDUMP.tar.gz +wget https://www.tcpdump.org/release/libpcap-$LIBPCAP.tar.gz + +tar zxvf tcpdump-$TCPDUMP.tar.gz +tar zxvf libpcap-$LIBPCAP.tar.gz +export CC=aarch64-linux-gnu-gcc +cd libpcap-$LIBPCAP +./configure --host=arm-linux --with-pcap=linux +make +cd .. + +cd tcpdump-$TCPDUMP +export ac_cv_linux_vers=2 +export CFLAGS=-static +export CPPFLAGS=-static +export LDFLAGS=-static + +./configure --host=arm-linux # --disable-ipv6 +make + +aarch64-linux-gnu-strip tcpdump +cp tcpdump .. +cd .. +rm -R libpcap-$LIBPCAP +rm -R tcpdump-$TCPDUMP +rm libpcap-$LIBPCAP.tar.gz +rm tcpdump-$TCPDUMP.tar.gz diff --git a/src/tools/interop/idt/capture/platform/android/streams/logcat/__init__.py b/src/tools/interop/idt/capture/platform/android/streams/logcat/__init__.py new file mode 100644 index 00000000000000..eebc071333d89e --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/logcat/__init__.py @@ -0,0 +1,20 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .logcat import LogcatStreamer + +__all__ = ["LogcatStreamer"] diff --git a/src/tools/interop/idt/capture/platform/android/streams/logcat/logcat.py b/src/tools/interop/idt/capture/platform/android/streams/logcat/logcat.py new file mode 100644 index 00000000000000..1964dd59e3ccc7 --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/logcat/logcat.py @@ -0,0 +1,38 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from capture.file_utils import create_standard_log_name +from typing import TYPE_CHECKING +from ..streams_base import AndroidStream + + +if TYPE_CHECKING: + from capture.platform.android import Android + + +class LogcatStreamer(AndroidStream): + + def __init__(self, platform: "Android"): + self.logcat_artifact = create_standard_log_name("logcat", "txt", parent=platform.artifact_dir) + self.logcat_command = f"logcat -T 1 >> {self.logcat_artifact}" + self.logcat_proc = platform.get_adb_background_command(self.logcat_command) + + async def start(self): + self.logcat_proc.start_command() + + async def stop(self): + self.logcat_proc.stop_command() diff --git a/src/tools/interop/idt/capture/platform/android/streams/screen/__init__.py b/src/tools/interop/idt/capture/platform/android/streams/screen/__init__.py new file mode 100644 index 00000000000000..1170a57700a7f7 --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/screen/__init__.py @@ -0,0 +1,20 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .screen import ScreenRecorder + +__all__ = ["ScreenRecorder"] diff --git a/src/tools/interop/idt/capture/platform/android/streams/screen/screen.py b/src/tools/interop/idt/capture/platform/android/streams/screen/screen.py new file mode 100644 index 00000000000000..62048969998193 --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/screen/screen.py @@ -0,0 +1,83 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import asyncio +import os + +from capture import log_format +from capture.file_utils import create_standard_log_name +from typing import TYPE_CHECKING +from ..streams_base import AndroidStream + +if TYPE_CHECKING: + from capture.platform.android import Android + +logger = log_format.get_logger(__file__) + + +class ScreenRecorder(AndroidStream): + + def __init__(self, platform: "Android"): + # TODO need to continuously start new streams to workaround limits + self.logger = logger + self.platform = platform + self.screen_artifact = create_standard_log_name("screencast", "mp4", parent=platform.artifact_dir) + self.screen_check_command = "shell dumpsys deviceidle | grep mScreenOn" + self.screen_phone_out_path = f"/sdcard/Movies/{os.path.basename(self.screen_artifact)}" + self.screen_command = f"shell screenrecord --bugreport {self.screen_phone_out_path}" + self.screen_proc = platform.get_adb_background_command(self.screen_command) + self.screen_pull = False + self.screen_pull_command = f"pull {self.screen_phone_out_path} {self.screen_artifact}" + + def check_screen(self) -> bool: + screen_cmd_output = self.platform.run_adb_command( + self.screen_check_command, capture_output=True) + return "true" in screen_cmd_output.get_captured_output() + + async def prepare_screen_recording(self) -> None: + if self.screen_proc.command_is_running(): + return + try: + async with asyncio.timeout_at(asyncio.get_running_loop().time() + 20.0): + screen_on = self.check_screen() + self.logger.error("Please turn the screen on so screen recording can start!") + while not screen_on: + await asyncio.sleep(2) + screen_on = self.check_screen() + if not screen_on: + self.logger.error("Screen is still not on for recording!") + except TimeoutError: + self.logger.error("WARNING screen recording timeout") + return + + async def start(self): + await self.prepare_screen_recording() + if self.check_screen(): + self.screen_pull = True + self.screen_proc.start_command() + + async def pull_screen_recording(self) -> None: + if self.screen_pull: + self.logger.info("Attempting to pull screen recording") + await asyncio.sleep(3) + self.platform.run_adb_command(self.screen_pull_command) + self.screen_pull = False + + async def stop(self): + self.logger.info("Stopping screen proc") + self.screen_proc.stop_command() + await self.pull_screen_recording() diff --git a/src/tools/interop/idt/capture/platform/android/streams/streams_base.py b/src/tools/interop/idt/capture/platform/android/streams/streams_base.py new file mode 100644 index 00000000000000..ebebb08507bda7 --- /dev/null +++ b/src/tools/interop/idt/capture/platform/android/streams/streams_base.py @@ -0,0 +1,29 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from abc import ABC, abstractmethod + + +class AndroidStream(ABC): + + @abstractmethod + async def start(self) -> None: + raise NotImplementedError + + @abstractmethod + async def stop(self) -> None: + raise NotImplementedError diff --git a/src/tools/interop/idt/capture/shell_utils.py b/src/tools/interop/idt/capture/shell_utils.py index 92bb11cbc25a8f..6365d9e9996aaf 100644 --- a/src/tools/interop/idt/capture/shell_utils.py +++ b/src/tools/interop/idt/capture/shell_utils.py @@ -18,33 +18,35 @@ import shlex import subprocess +from capture import log_format from mobly.utils import stop_standing_subprocess +logger = log_format.get_logger(__file__) + class Bash: - """ - Uses subprocess to execute bash commands - Intended to be instantiated and then only interacted with through instance methods - """ def __init__(self, command: str, sync: bool = False, - capture_output: bool = False) -> None: + capture_output: bool = False, + cwd: str = None) -> None: """ Run a bash command as a sub process :param command: Command to run :param sync: If True, wait for command to terminate - :param capture_output: Only applies to sync; if True, store stdout and stderr + :param capture_output: Only applies to sync; if True, store and supress stdout and stderr + :param cwd: Set working directory of command """ + self.logger = logger self.command: str = command self.sync = sync self.capture_output = capture_output + self.cwd = cwd self.args: list[str] = [] self._init_args() - self.proc = subprocess.run(self.args, capture_output=capture_output) if self.sync else None + self.proc: None | subprocess.CompletedProcess | subprocess.Popen = None def _init_args(self) -> None: - """Escape quotes, call bash, and prep command for subprocess args""" command_escaped = self.command.replace('"', '\"') self.args = shlex.split(f'/bin/bash -c "{command_escaped}"') @@ -52,15 +54,17 @@ def command_is_running(self) -> bool: return self.proc is not None and self.proc.poll() is None def get_captured_output(self) -> str: - """Return captured output when the relevant instance var is set""" return "" if not self.capture_output or not self.sync \ else self.proc.stdout.decode().strip() def start_command(self) -> None: - if not self.sync and not self.command_is_running(): - self.proc = subprocess.Popen(self.args) + if self.sync: + self.proc = subprocess.run(self.args, capture_output=self.capture_output, cwd=self.cwd) + return + if not self.command_is_running(): + self.proc = subprocess.Popen(self.args, cwd=self.cwd) else: - print(f'INFO {self.command} start requested while running') + self.logger.warning(f'{self.command} start requested while running') def stop_command(self, soft: bool = False) -> None: if self.command_is_running(): @@ -74,5 +78,11 @@ def stop_command(self, soft: bool = False) -> None: else: stop_standing_subprocess(self.proc) else: - print(f'INFO {self.command} stop requested while not running') + self.logger.warning(f'{self.command} stop requested while not running') self.proc = None + + def finished_success(self) -> bool: + if not self.sync: + return not self.command_is_running() and self.proc.returncode == 0 + else: + return self.proc is not None and self.proc.returncode == 0 diff --git a/src/tools/interop/idt/idt.py b/src/tools/interop/idt/idt.py index b401f88dd8b231..fff7a963a43473 100644 --- a/src/tools/interop/idt/idt.py +++ b/src/tools/interop/idt/idt.py @@ -27,10 +27,6 @@ from capture.file_utils import border_print, create_file_timestamp, safe_mkdir from discovery import MatterBleScanner, MatterDnssdListener -logging.basicConfig( - format='%(asctime)s.%(msecs)03d %(levelname)s {%(module)s} [%(funcName)s]\n%(message)s \n', - level=logging.INFO) - class InteropDebuggingTool: @@ -194,3 +190,7 @@ def command_capture(self, args: argparse.Namespace) -> None: border_print("Compressing artifacts, this may take some time!") self.zip_artifacts() + + if EcosystemController.has_errors(): + border_print("Errors seen this run:") + EcosystemController.error_report() diff --git a/src/tools/interop/idt/scripts/compilers.sh b/src/tools/interop/idt/scripts/compilers.sh new file mode 100644 index 00000000000000..ee50284fd6f489 --- /dev/null +++ b/src/tools/interop/idt/scripts/compilers.sh @@ -0,0 +1,21 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +sudo apt-get install gcc-arm-linux-gnueabi +sudo apt-get install gcc-aarch64-linux-gnu +sudo apt-get install byacc +sudo apt-get install flex