1#!/usr/bin/env python3
2#
3#   Copyright 2018 - The Android Open Source Project
4#
5#   Licensed under the Apache License, Version 2.0 (the "License");
6#   you may not use this file except in compliance with the License.
7#   You may obtain a copy of the License at
8#
9#       http://www.apache.org/licenses/LICENSE-2.0
10#
11#   Unless required by applicable law or agreed to in writing, software
12#   distributed under the License is distributed on an "AS IS" BASIS,
13#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14#   See the License for the specific language governing permissions and
15#   limitations under the License.
16
17from typing import Optional
18import json
19import logging
20import os
21import re
22import subprocess
23import time
24
25from acts import context
26from acts import logger as acts_logger
27from acts import signals
28from acts import utils
29from acts.controllers import pdu
30from acts.libs.proc import job
31from acts.utils import get_fuchsia_mdns_ipv6_address, get_interface_ip_addresses
32
33from acts.controllers.fuchsia_lib.ffx import FFX
34from acts.controllers.fuchsia_lib.sl4f import SL4F
35from acts.controllers.fuchsia_lib.lib_controllers.netstack_controller import NetstackController
36from acts.controllers.fuchsia_lib.lib_controllers.wlan_controller import WlanController
37from acts.controllers.fuchsia_lib.lib_controllers.wlan_policy_controller import WlanPolicyController
38from acts.controllers.fuchsia_lib.package_server import PackageServer
39from acts.controllers.fuchsia_lib.ssh import DEFAULT_SSH_PORT, DEFAULT_SSH_USER, SSHConfig, SSHProvider, FuchsiaSSHError
40from acts.controllers.fuchsia_lib.utils_lib import flash
41
42MOBLY_CONTROLLER_CONFIG_NAME = "FuchsiaDevice"
43ACTS_CONTROLLER_REFERENCE_NAME = "fuchsia_devices"
44
45CONTROL_PATH_REPLACE_VALUE = " ControlPath /tmp/fuchsia--%r@%h:%p"
46
47FUCHSIA_DEVICE_EMPTY_CONFIG_MSG = "Configuration is empty, abort!"
48FUCHSIA_DEVICE_NOT_LIST_CONFIG_MSG = "Configuration should be a list, abort!"
49FUCHSIA_DEVICE_INVALID_CONFIG = ("Fuchsia device config must be either a str "
50                                 "or dict. abort! Invalid element %i in %r")
51FUCHSIA_DEVICE_NO_IP_MSG = "No IP address specified, abort!"
52FUCHSIA_COULD_NOT_GET_DESIRED_STATE = "Could not %s %s."
53FUCHSIA_INVALID_CONTROL_STATE = "Invalid control state (%s). abort!"
54
55FUCHSIA_TIME_IN_NANOSECONDS = 1000000000
56
57SL4F_APK_NAME = "com.googlecode.android_scripting"
58DAEMON_INIT_TIMEOUT_SEC = 1
59
60DAEMON_ACTIVATED_STATES = ["running", "start"]
61DAEMON_DEACTIVATED_STATES = ["stop", "stopped"]
62
63FUCHSIA_RECONNECT_AFTER_REBOOT_TIME = 5
64
65CHANNEL_OPEN_TIMEOUT = 5
66
67FUCHSIA_REBOOT_TYPE_SOFT = 'soft'
68FUCHSIA_REBOOT_TYPE_SOFT_AND_FLASH = 'flash'
69FUCHSIA_REBOOT_TYPE_HARD = 'hard'
70
71FUCHSIA_DEFAULT_CONNECT_TIMEOUT = 90
72FUCHSIA_DEFAULT_COMMAND_TIMEOUT = 60
73
74FUCHSIA_DEFAULT_CLEAN_UP_COMMAND_TIMEOUT = 15
75
76FUCHSIA_COUNTRY_CODE_TIMEOUT = 15
77FUCHSIA_DEFAULT_COUNTRY_CODE_US = 'US'
78
79MDNS_LOOKUP_RETRY_MAX = 3
80
81VALID_ASSOCIATION_MECHANISMS = {None, 'policy', 'drivers'}
82IP_ADDRESS_TIMEOUT = 15
83
84
85class FuchsiaDeviceError(signals.ControllerError):
86    pass
87
88
89class FuchsiaConfigError(signals.ControllerError):
90    """Incorrect FuchsiaDevice configuration."""
91
92
93def create(configs):
94    if not configs:
95        raise FuchsiaDeviceError(FUCHSIA_DEVICE_EMPTY_CONFIG_MSG)
96    elif not isinstance(configs, list):
97        raise FuchsiaDeviceError(FUCHSIA_DEVICE_NOT_LIST_CONFIG_MSG)
98    for index, config in enumerate(configs):
99        if isinstance(config, str):
100            configs[index] = {"ip": config}
101        elif not isinstance(config, dict):
102            raise FuchsiaDeviceError(FUCHSIA_DEVICE_INVALID_CONFIG %
103                                     (index, configs))
104    return get_instances(configs)
105
106
107def destroy(fds):
108    for fd in fds:
109        fd.clean_up()
110        del fd
111
112
113def get_info(fds):
114    """Get information on a list of FuchsiaDevice objects.
115
116    Args:
117        fds: A list of FuchsiaDevice objects.
118
119    Returns:
120        A list of dict, each representing info for FuchsiaDevice objects.
121    """
122    device_info = []
123    for fd in fds:
124        info = {"ip": fd.ip}
125        device_info.append(info)
126    return device_info
127
128
129def get_instances(fds_conf_data):
130    """Create FuchsiaDevice instances from a list of Fuchsia ips.
131
132    Args:
133        fds_conf_data: A list of dicts that contain Fuchsia device info.
134
135    Returns:
136        A list of FuchsiaDevice objects.
137    """
138
139    return [FuchsiaDevice(fd_conf_data) for fd_conf_data in fds_conf_data]
140
141
142class FuchsiaDevice:
143    """Class representing a Fuchsia device.
144
145    Each object of this class represents one Fuchsia device in ACTS.
146
147    Attributes:
148        ip: The full address or Fuchsia abstract name to contact the Fuchsia
149            device at
150        log: A logger object.
151        ssh_port: The SSH TCP port number of the Fuchsia device.
152        sl4f_port: The SL4F HTTP port number of the Fuchsia device.
153        ssh_config: The ssh_config for connecting to the Fuchsia device.
154    """
155
156    def __init__(self, fd_conf_data):
157        """
158        Args:
159            fd_conf_data: A dict of a fuchsia device configuration data
160                Required keys:
161                    ip: IP address of fuchsia device
162                optional key:
163                    sl4_port: Port for the sl4f web server on the fuchsia device
164                              (Default: 80)
165                    ssh_config: Location of the ssh_config file to connect to
166                        the fuchsia device
167                        (Default: None)
168                    ssh_port: Port for the ssh server on the fuchsia device
169                              (Default: 22)
170        """
171        self.conf_data = fd_conf_data
172        if "ip" not in fd_conf_data:
173            raise FuchsiaDeviceError(FUCHSIA_DEVICE_NO_IP_MSG)
174        self.ip: str = fd_conf_data["ip"]
175        self.orig_ip: str = fd_conf_data["ip"]
176        self.sl4f_port: int = fd_conf_data.get("sl4f_port", 80)
177        self.ssh_port: int = fd_conf_data.get("ssh_port", DEFAULT_SSH_PORT)
178        self.ssh_config: Optional[str] = fd_conf_data.get("ssh_config", None)
179        self.ssh_priv_key: Optional[str] = fd_conf_data.get(
180            "ssh_priv_key", None)
181        self.authorized_file: Optional[str] = fd_conf_data.get(
182            "authorized_file_loc", None)
183        self.serial_number: Optional[str] = fd_conf_data.get(
184            "serial_number", None)
185        self.device_type: Optional[str] = fd_conf_data.get("device_type", None)
186        self.product_type: Optional[str] = fd_conf_data.get(
187            "product_type", None)
188        self.board_type: Optional[str] = fd_conf_data.get("board_type", None)
189        self.build_number: Optional[str] = fd_conf_data.get(
190            "build_number", None)
191        self.build_type: Optional[str] = fd_conf_data.get("build_type", None)
192        self.server_path: Optional[str] = fd_conf_data.get("server_path", None)
193        self.specific_image: Optional[str] = fd_conf_data.get(
194            "specific_image", None)
195        self.ffx_binary_path: Optional[str] = fd_conf_data.get(
196            "ffx_binary_path", None)
197        # Path to a tar.gz archive with pm and amber-files, as necessary for
198        # starting a package server.
199        self.packages_archive_path: Optional[str] = fd_conf_data.get(
200            "packages_archive_path", None)
201        self.mdns_name: Optional[str] = fd_conf_data.get("mdns_name", None)
202
203        # Instead of the input ssh_config, a new config is generated with proper
204        # ControlPath to the test output directory.
205        output_path = context.get_current_context().get_base_output_path()
206        generated_ssh_config = os.path.join(output_path,
207                                            "ssh_config_{}".format(self.ip))
208        self._set_control_path_config(self.ssh_config, generated_ssh_config)
209        self.ssh_config = generated_ssh_config
210
211        self.ssh_username = fd_conf_data.get("ssh_username", DEFAULT_SSH_USER)
212        self.hard_reboot_on_fail = fd_conf_data.get("hard_reboot_on_fail",
213                                                    False)
214        self.take_bug_report_on_fail = fd_conf_data.get(
215            "take_bug_report_on_fail", False)
216        self.device_pdu_config = fd_conf_data.get("PduDevice", None)
217        self.config_country_code = fd_conf_data.get(
218            'country_code', FUCHSIA_DEFAULT_COUNTRY_CODE_US).upper()
219
220        # WLAN interface info is populated inside configure_wlan
221        self.wlan_client_interfaces = {}
222        self.wlan_ap_interfaces = {}
223        self.wlan_client_test_interface_name = fd_conf_data.get(
224            'wlan_client_test_interface', None)
225        self.wlan_ap_test_interface_name = fd_conf_data.get(
226            'wlan_ap_test_interface', None)
227
228        # Whether to use 'policy' or 'drivers' for WLAN connect/disconnect calls
229        # If set to None, wlan is not configured.
230        self.association_mechanism = None
231        # Defaults to policy layer, unless otherwise specified in the config
232        self.default_association_mechanism = fd_conf_data.get(
233            'association_mechanism', 'policy')
234
235        # Whether to clear and preserve existing saved networks and client
236        # connections state, to be restored at device teardown.
237        self.default_preserve_saved_networks = fd_conf_data.get(
238            'preserve_saved_networks', True)
239
240        if not utils.is_valid_ipv4_address(
241                self.ip) and not utils.is_valid_ipv6_address(self.ip):
242            mdns_ip = None
243            for retry_counter in range(MDNS_LOOKUP_RETRY_MAX):
244                mdns_ip = get_fuchsia_mdns_ipv6_address(self.ip)
245                if mdns_ip:
246                    break
247                else:
248                    time.sleep(1)
249            if mdns_ip and utils.is_valid_ipv6_address(mdns_ip):
250                # self.ip was actually an mdns name. Use it for self.mdns_name
251                # unless one was explicitly provided.
252                self.mdns_name = self.mdns_name or self.ip
253                self.ip = mdns_ip
254            else:
255                raise ValueError('Invalid IP: %s' % self.ip)
256
257        self.log = acts_logger.create_tagged_trace_logger(
258            "FuchsiaDevice | %s" % self.orig_ip)
259
260        self.ping_rtt_match = re.compile(r'RTT Min/Max/Avg '
261                                         r'= \[ (.*?) / (.*?) / (.*?) \] ms')
262        self.serial = re.sub('[.:%]', '_', self.ip)
263        log_path_base = getattr(logging, 'log_path', '/tmp/logs')
264        self.log_path = os.path.join(log_path_base,
265                                     'FuchsiaDevice%s' % self.serial)
266        self.fuchsia_log_file_path = os.path.join(
267            self.log_path, "fuchsialog_%s_debug.txt" % self.serial)
268        self.log_process = None
269        self.package_server = None
270
271        self.init_controllers()
272
273    @property
274    def sl4f(self):
275        """Get the sl4f module configured for this device.
276
277        The sl4f module uses lazy-initialization; it will initialize an sl4f
278        server on the host device when it is required.
279        """
280        if not hasattr(self, '_sl4f'):
281            self._sl4f = SL4F(self.ssh, self.sl4f_port)
282            self.log.info('Started SL4F server')
283        return self._sl4f
284
285    @sl4f.deleter
286    def sl4f(self):
287        if not hasattr(self, '_sl4f'):
288            return
289        del self._sl4f
290
291    @property
292    def ssh(self):
293        """Get the SSH provider module configured for this device."""
294        if not hasattr(self, '_ssh'):
295            if not self.ssh_port:
296                raise FuchsiaConfigError(
297                    'Must provide "ssh_port: <int>" in the device config')
298            if not self.ssh_priv_key:
299                raise FuchsiaConfigError(
300                    'Must provide "ssh_priv_key: <file path>" in the device config'
301                )
302            self._ssh = SSHProvider(
303                SSHConfig(self.ip, self.ssh_priv_key, port=self.ssh_port))
304        return self._ssh
305
306    @ssh.deleter
307    def ssh(self):
308        if not hasattr(self, '_ssh'):
309            return
310        del self._ssh
311
312    @property
313    def ffx(self):
314        """Get the ffx module configured for this device.
315
316        The ffx module uses lazy-initialization; it will initialize an ffx
317        connection to the device when it is required.
318
319        If ffx needs to be reinitialized, delete the "ffx" property and attempt
320        access again. Note re-initialization will interrupt any running ffx
321        calls.
322        """
323        if not hasattr(self, '_ffx'):
324            if not self.ffx_binary_path:
325                raise FuchsiaConfigError(
326                    'Must provide "ffx_binary_path: <path to FFX binary>" in the device config'
327                )
328            if not self.mdns_name:
329                raise FuchsiaConfigError(
330                    'Must provide "mdns_name: <device mDNS name>" in the device config'
331                )
332            self._ffx = FFX(self.ffx_binary_path, self.mdns_name, self.ip,
333                            self.ssh_priv_key)
334        return self._ffx
335
336    @ffx.deleter
337    def ffx(self):
338        if not hasattr(self, '_ffx'):
339            return
340        self._ffx.clean_up()
341        del self._ffx
342
343    def _set_control_path_config(self, old_config, new_config):
344        """Given an input ssh_config, write to a new config with proper
345        ControlPath values in place, if it doesn't exist already.
346
347        Args:
348            old_config: string, path to the input config
349            new_config: string, path to store the new config
350        """
351        if os.path.isfile(new_config):
352            return
353
354        ssh_config_copy = ""
355
356        with open(old_config, 'r') as file:
357            ssh_config_copy = re.sub('(\sControlPath\s.*)',
358                                     CONTROL_PATH_REPLACE_VALUE,
359                                     file.read(),
360                                     flags=re.M)
361        with open(new_config, 'w') as file:
362            file.write(ssh_config_copy)
363
364    def init_controllers(self):
365        # Contains Netstack functions
366        self.netstack_controller = NetstackController(self)
367
368        # Contains WLAN core functions
369        self.wlan_controller = WlanController(self)
370
371        # Contains WLAN policy functions like save_network, remove_network, etc
372        self.wlan_policy_controller = WlanPolicyController(self.sl4f, self.ffx)
373
374    def start_package_server(self):
375        if not self.packages_archive_path:
376            self.log.warn(
377                "packages_archive_path is not specified. "
378                "Assuming a package server is already running and configured on "
379                "the DUT. If this is not the case, either run your own package "
380                "server, or configure these fields appropriately. "
381                "This is usually required for the Fuchsia iPerf3 client or "
382                "other testing utilities not on device cache.")
383            return
384        if self.package_server:
385            self.log.warn(
386                "Skipping to start the package server since is already running"
387            )
388            return
389
390        self.package_server = PackageServer(self.packages_archive_path)
391        self.package_server.start()
392        self.package_server.configure_device(self.ssh)
393
394    def run_commands_from_config(self, cmd_dicts):
395        """Runs commands on the Fuchsia device from the config file. Useful for
396        device and/or Fuchsia specific configuration.
397
398        Args:
399            cmd_dicts: list of dictionaries containing the following
400                'cmd': string, command to run on device
401                'timeout': int, seconds to wait for command to run (optional)
402                'skip_status_code_check': bool, disregard errors if true
403
404        Raises:
405            FuchsiaDeviceError: if any of the commands return a non-zero status
406                code and skip_status_code_check is false or undefined.
407        """
408        for cmd_dict in cmd_dicts:
409            try:
410                cmd = cmd_dict['cmd']
411            except KeyError:
412                raise FuchsiaDeviceError(
413                    'To run a command via config, you must provide key "cmd" '
414                    'containing the command string.')
415
416            timeout = cmd_dict.get('timeout', FUCHSIA_DEFAULT_COMMAND_TIMEOUT)
417            # Catch both boolean and string values from JSON
418            skip_status_code_check = 'true' == str(
419                cmd_dict.get('skip_status_code_check', False)).lower()
420
421            if skip_status_code_check:
422                self.log.info(f'Running command "{cmd}" and ignoring result.')
423            else:
424                self.log.info(f'Running command "{cmd}".')
425
426            try:
427                result = self.ssh.run(cmd, timeout_sec=timeout)
428                self.log.debug(result)
429            except FuchsiaSSHError as e:
430                if not skip_status_code_check:
431                    raise FuchsiaDeviceError(
432                        'Failed device specific commands for initial configuration'
433                    ) from e
434
435    def configure_wlan(self,
436                       association_mechanism=None,
437                       preserve_saved_networks=None):
438        """
439        Readies device for WLAN functionality. If applicable, connects to the
440        policy layer and clears/saves preexisting saved networks.
441
442        Args:
443            association_mechanism: string, 'policy' or 'drivers'. If None, uses
444                the default value from init (can be set by ACTS config)
445            preserve_saved_networks: bool, whether to clear existing saved
446                networks, and preserve them for restoration later. If None, uses
447                the default value from init (can be set by ACTS config)
448
449        Raises:
450            FuchsiaDeviceError, if configuration fails
451        """
452
453        # Set the country code US by default, or country code provided
454        # in ACTS config
455        self.configure_regulatory_domain(self.config_country_code)
456
457        # If args aren't provided, use the defaults, which can be set in the
458        # config.
459        if association_mechanism is None:
460            association_mechanism = self.default_association_mechanism
461        if preserve_saved_networks is None:
462            preserve_saved_networks = self.default_preserve_saved_networks
463
464        if association_mechanism not in VALID_ASSOCIATION_MECHANISMS:
465            raise FuchsiaDeviceError(
466                'Invalid FuchsiaDevice association_mechanism: %s' %
467                association_mechanism)
468
469        # Allows for wlan to be set up differently in different tests
470        if self.association_mechanism:
471            self.log.info('Deconfiguring WLAN')
472            self.deconfigure_wlan()
473
474        self.association_mechanism = association_mechanism
475
476        self.log.info('Configuring WLAN w/ association mechanism: %s' %
477                      association_mechanism)
478        if association_mechanism == 'drivers':
479            self.log.warn(
480                'You may encounter unusual device behavior when using the '
481                'drivers directly for WLAN. This should be reserved for '
482                'debugging specific issues. Normal test runs should use the '
483                'policy layer.')
484            if preserve_saved_networks:
485                self.log.warn(
486                    'Unable to preserve saved networks when using drivers '
487                    'association mechanism (requires policy layer control).')
488        else:
489            # This requires SL4F calls, so it can only happen with actual
490            # devices, not with unit tests.
491            self.wlan_policy_controller.configure_wlan(preserve_saved_networks)
492
493        # Retrieve WLAN client and AP interfaces
494        self.wlan_controller.update_wlan_interfaces()
495
496    def deconfigure_wlan(self):
497        """
498        Stops WLAN functionality (if it has been started). Used to allow
499        different tests to use WLAN differently (e.g. some tests require using
500        wlan policy, while the abstract wlan_device can be setup to use policy
501        or drivers)
502
503        Raises:
504            FuchsiaDeviveError, if deconfigure fails.
505        """
506        if not self.association_mechanism:
507            self.log.debug(
508                'WLAN not configured before deconfigure was called.')
509            return
510        # If using policy, stop client connections. Otherwise, just clear
511        # variables.
512        if self.association_mechanism != 'drivers':
513            self.wlan_policy_controller._deconfigure_wlan()
514        self.association_mechanism = None
515
516    def reboot(self,
517               use_ssh: bool = False,
518               unreachable_timeout: int = FUCHSIA_DEFAULT_CONNECT_TIMEOUT,
519               ping_timeout: int = FUCHSIA_DEFAULT_CONNECT_TIMEOUT,
520               ssh_timeout: int = FUCHSIA_DEFAULT_CONNECT_TIMEOUT,
521               reboot_type: int = FUCHSIA_REBOOT_TYPE_SOFT,
522               testbed_pdus: list[pdu.PduDevice] = None) -> None:
523        """Reboot a FuchsiaDevice.
524
525        Soft reboots the device, verifies it becomes unreachable, then verifies
526        it comes back online. Re-initializes services so the tests can continue.
527
528        Args:
529            use_ssh: if True, use fuchsia shell command via ssh to reboot
530                instead of SL4F.
531            unreachable_timeout: time to wait for device to become unreachable.
532            ping_timeout:time to wait for device to respond to pings.
533            ssh_timeout: time to wait for device to be reachable via ssh.
534            reboot_type: 'soft', 'hard' or 'flash'.
535            testbed_pdus: all testbed PDUs.
536
537        Raises:
538            ConnectionError, if device fails to become unreachable or fails to
539                come back up.
540        """
541        if reboot_type == FUCHSIA_REBOOT_TYPE_SOFT:
542            if use_ssh:
543                self.log.info('Soft rebooting via SSH')
544                try:
545                    self.ssh.run(
546                        'dm reboot',
547                        timeout_sec=FUCHSIA_RECONNECT_AFTER_REBOOT_TIME)
548                except FuchsiaSSHError as e:
549                    if 'closed by remote host' not in e.result.stderr:
550                        raise e
551            else:
552                self.log.info('Soft rebooting via SL4F')
553                self.sl4f.hardware_power_statecontrol_lib.suspendReboot(
554                    timeout=3)
555            self._check_unreachable(timeout_sec=unreachable_timeout)
556
557        elif reboot_type == FUCHSIA_REBOOT_TYPE_HARD:
558            self.log.info('Hard rebooting via PDU')
559            if not testbed_pdus:
560                raise AttributeError('Testbed PDUs must be supplied '
561                                     'to hard reboot a fuchsia_device.')
562            device_pdu, device_pdu_port = pdu.get_pdu_port_for_device(
563                self.device_pdu_config, testbed_pdus)
564            self.log.info('Killing power to FuchsiaDevice')
565            device_pdu.off(str(device_pdu_port))
566            self._check_unreachable(timeout_sec=unreachable_timeout)
567            self.log.info('Restoring power to FuchsiaDevice')
568            device_pdu.on(str(device_pdu_port))
569
570        elif reboot_type == FUCHSIA_REBOOT_TYPE_SOFT_AND_FLASH:
571            flash(self, use_ssh, FUCHSIA_RECONNECT_AFTER_REBOOT_TIME)
572
573        else:
574            raise ValueError('Invalid reboot type: %s' % reboot_type)
575
576        self._check_reachable(timeout_sec=ping_timeout)
577
578        # Cleanup services
579        self.stop_services()
580
581        self.log.info('Waiting for device to allow ssh connection.')
582        end_time = time.time() + ssh_timeout
583        while time.time() < end_time:
584            try:
585                self.ssh.run('echo')
586            except Exception as e:
587                self.log.debug(f'Retrying SSH to device. Details: {e}')
588            else:
589                break
590        else:
591            raise ConnectionError('Failed to connect to device via SSH.')
592        self.log.info('Device now available via ssh.')
593
594        # TODO (b/246852449): Move configure_wlan to other controllers.
595        # If wlan was configured before reboot, it must be configured again
596        # after rebooting, as it was before reboot. No preserving should occur.
597        if self.association_mechanism:
598            pre_reboot_association_mechanism = self.association_mechanism
599            # Prevent configure_wlan from thinking it needs to deconfigure first
600            self.association_mechanism = None
601            self.configure_wlan(
602                association_mechanism=pre_reboot_association_mechanism,
603                preserve_saved_networks=False)
604
605        self.log.info('Device has rebooted')
606
607    def version(self):
608        """Returns the version of Fuchsia running on the device.
609
610        Returns:
611            A string containing the Fuchsia version number or nothing if there
612            is no version information attached during the build.
613            For example, "5.20210713.2.1" or "".
614
615        Raises:
616            FFXTimeout: when the command times out.
617            FFXError: when the command returns non-zero and skip_status_code_check is False.
618        """
619        target_info_json = self.ffx.run("target show --json").stdout
620        target_info = json.loads(target_info_json)
621        build_info = [
622            entry for entry in target_info if entry["label"] == "build"
623        ]
624        if len(build_info) != 1:
625            self.log.warning(
626                f'Expected one entry with label "build", found {build_info}')
627            return ""
628        version_info = [
629            child for child in build_info[0]["child"]
630            if child["label"] == "version"
631        ]
632        if len(version_info) != 1:
633            self.log.warning(
634                f'Expected one entry child with label "version", found {build_info}'
635            )
636            return ""
637        return version_info[0]["value"]
638
639    def ping(self,
640             dest_ip,
641             count=3,
642             interval=1000,
643             timeout=1000,
644             size=25,
645             additional_ping_params=None):
646        """Pings from a Fuchsia device to an IPv4 address or hostname
647
648        Args:
649            dest_ip: (str) The ip or hostname to ping.
650            count: (int) How many icmp packets to send.
651            interval: (int) How long to wait between pings (ms)
652            timeout: (int) How long to wait before having the icmp packet
653                timeout (ms).
654            size: (int) Size of the icmp packet.
655            additional_ping_params: (str) command option flags to
656                append to the command string
657
658        Returns:
659            A dictionary for the results of the ping.  The dictionary contains
660            the following items:
661                status: Whether the ping was successful.
662                rtt_min: The minimum round trip time of the ping.
663                rtt_max: The minimum round trip time of the ping.
664                rtt_avg: The avg round trip time of the ping.
665                stdout: The standard out of the ping command.
666                stderr: The standard error of the ping command.
667        """
668        rtt_min = None
669        rtt_max = None
670        rtt_avg = None
671        self.log.debug("Pinging %s..." % dest_ip)
672        if not additional_ping_params:
673            additional_ping_params = ''
674
675        try:
676            ping_result = self.ssh.run(
677                f'ping -c {count} -i {interval} -t {timeout} -s {size} '
678                f'{additional_ping_params} {dest_ip}')
679        except FuchsiaSSHError as e:
680            ping_result = e.result
681
682        if ping_result.stderr:
683            status = False
684        else:
685            status = True
686            rtt_line = ping_result.stdout.split('\n')[:-1]
687            rtt_line = rtt_line[-1]
688            rtt_stats = re.search(self.ping_rtt_match, rtt_line)
689            rtt_min = rtt_stats.group(1)
690            rtt_max = rtt_stats.group(2)
691            rtt_avg = rtt_stats.group(3)
692        return {
693            'status': status,
694            'rtt_min': rtt_min,
695            'rtt_max': rtt_max,
696            'rtt_avg': rtt_avg,
697            'stdout': ping_result.stdout,
698            'stderr': ping_result.stderr
699        }
700
701    def can_ping(self,
702                 dest_ip,
703                 count=1,
704                 interval=1000,
705                 timeout=1000,
706                 size=25,
707                 additional_ping_params=None):
708        """Returns whether fuchsia device can ping a given dest address"""
709        ping_result = self.ping(dest_ip,
710                                count=count,
711                                interval=interval,
712                                timeout=timeout,
713                                size=size,
714                                additional_ping_params=additional_ping_params)
715        return ping_result['status']
716
717    def clean_up(self):
718        """Cleans up the FuchsiaDevice object, releases any resources it
719        claimed, and restores saved networks if applicable. For reboots, use
720        clean_up_services only.
721
722        Note: Any exceptions thrown in this method must be caught and handled,
723        ensuring that clean_up_services is run. Otherwise, the syslog listening
724        thread will never join and will leave tests hanging.
725        """
726        # If and only if wlan is configured, and using the policy layer
727        if self.association_mechanism == 'policy':
728            try:
729                self.wlan_policy_controller.clean_up()
730            except Exception as err:
731                self.log.warning('Unable to clean up WLAN Policy layer: %s' %
732                                 err)
733
734        self.stop_services()
735
736        if self.package_server:
737            self.package_server.clean_up()
738
739    def get_interface_ip_addresses(self, interface):
740        return get_interface_ip_addresses(self, interface)
741
742    def wait_for_ipv4_addr(self, interface: str) -> None:
743        """Checks if device has an ipv4 private address. Sleeps 1 second between
744        retries.
745
746        Args:
747            interface: name of interface from which to get ipv4 address.
748
749        Raises:
750            ConnectionError, if device does not have an ipv4 address after all
751            timeout.
752        """
753        self.log.info(
754            f'Checking for valid ipv4 addr. Retry {IP_ADDRESS_TIMEOUT} seconds.'
755        )
756        timeout = time.time() + IP_ADDRESS_TIMEOUT
757        while time.time() < timeout:
758            ip_addrs = self.get_interface_ip_addresses(interface)
759
760            if len(ip_addrs['ipv4_private']) > 0:
761                self.log.info("Device has an ipv4 address: "
762                              f"{ip_addrs['ipv4_private'][0]}")
763                break
764            else:
765                self.log.debug(
766                    'Device does not yet have an ipv4 address...retrying in 1 '
767                    'second.')
768                time.sleep(1)
769        else:
770            raise ConnectionError('Device failed to get an ipv4 address.')
771
772    def wait_for_ipv6_addr(self, interface: str) -> None:
773        """Checks if device has an ipv6 private local address. Sleeps 1 second
774        between retries.
775
776        Args:
777            interface: name of interface from which to get ipv6 address.
778
779        Raises:
780            ConnectionError, if device does not have an ipv6 address after all
781            timeout.
782        """
783        self.log.info(
784            f'Checking for valid ipv6 addr. Retry {IP_ADDRESS_TIMEOUT} seconds.'
785        )
786        timeout = time.time() + IP_ADDRESS_TIMEOUT
787        while time.time() < timeout:
788            ip_addrs = self.get_interface_ip_addresses(interface)
789            if len(ip_addrs['ipv6_private_local']) > 0:
790                self.log.info("Device has an ipv6 private local address: "
791                              f"{ip_addrs['ipv6_private_local'][0]}")
792                break
793            else:
794                self.log.debug(
795                    'Device does not yet have an ipv6 address...retrying in 1 '
796                    'second.')
797                time.sleep(1)
798        else:
799            raise ConnectionError('Device failed to get an ipv6 address.')
800
801    def _check_reachable(self,
802                         timeout_sec: int = FUCHSIA_DEFAULT_CONNECT_TIMEOUT
803                         ) -> None:
804        """Checks the reachability of the Fuchsia device."""
805        end_time = time.time() + timeout_sec
806        self.log.info('Verifying device is reachable.')
807        while time.time() < end_time:
808            # TODO (b/249343632): Consolidate ping commands and fix timeout in
809            # utils.can_ping.
810            if utils.can_ping(job, self.ip):
811                self.log.info('Device is reachable.')
812                break
813            else:
814                self.log.debug(
815                    'Device is not reachable. Retrying in 1 second.')
816                time.sleep(1)
817        else:
818            raise ConnectionError('Device is unreachable.')
819
820    def _check_unreachable(self,
821                           timeout_sec: int = FUCHSIA_DEFAULT_CONNECT_TIMEOUT
822                           ) -> None:
823        """Checks the Fuchsia device becomes unreachable."""
824        end_time = time.time() + timeout_sec
825        self.log.info('Verifying device is unreachable.')
826        while (time.time() < end_time):
827            if utils.can_ping(job, self.ip):
828                self.log.debug(
829                    'Device is still reachable. Retrying in 1 second.')
830                time.sleep(1)
831            else:
832                self.log.info('Device is not reachable.')
833                break
834        else:
835            raise ConnectionError('Device failed to become unreachable.')
836
837    def check_connect_response(self, connect_response):
838        if connect_response.get("error") is None:
839            # Checks the response from SL4F and if there is no error, check
840            # the result.
841            connection_result = connect_response.get("result")
842            if not connection_result:
843                # Ideally the error would be present but just outputting a log
844                # message until available.
845                self.log.debug("Connect call failed, aborting!")
846                return False
847            else:
848                # Returns True if connection was successful.
849                return True
850        else:
851            # the response indicates an error - log and raise failure
852            self.log.debug("Aborting! - Connect call failed with error: %s" %
853                           connect_response.get("error"))
854            return False
855
856    def check_disconnect_response(self, disconnect_response):
857        if disconnect_response.get("error") is None:
858            # Returns True if disconnect was successful.
859            return True
860        else:
861            # the response indicates an error - log and raise failure
862            self.log.debug("Disconnect call failed with error: %s" %
863                           disconnect_response.get("error"))
864            return False
865
866    # TODO(fxb/64657): Determine more stable solution to country code config on
867    # device bring up.
868    def configure_regulatory_domain(self, desired_country_code):
869        """Allows the user to set the device country code via ACTS config
870
871        Usage:
872            In FuchsiaDevice config, add "country_code": "<CC>"
873        """
874        if self.ssh_config:
875            # Country code can be None, from ACTS config.
876            if desired_country_code:
877                desired_country_code = desired_country_code.upper()
878                response = self.sl4f.regulatory_region_lib.setRegion(
879                    desired_country_code)
880                if response.get('error'):
881                    raise FuchsiaDeviceError(
882                        'Failed to set regulatory domain. Err: %s' %
883                        response['error'])
884                end_time = time.time() + FUCHSIA_COUNTRY_CODE_TIMEOUT
885                while time.time() < end_time:
886                    ascii_cc = self.sl4f.wlan_lib.wlanGetCountry(0).get(
887                        'result')
888                    # Convert ascii_cc to string, then compare
889                    if ascii_cc and (''.join(chr(c) for c in ascii_cc).upper()
890                                     == desired_country_code):
891                        self.log.debug('Country code successfully set to %s.' %
892                                       desired_country_code)
893                        return
894                    self.log.debug('Country code not yet updated. Retrying.')
895                    time.sleep(1)
896                raise FuchsiaDeviceError('Country code never updated to %s' %
897                                         desired_country_code)
898
899    def stop_services(self):
900        """Stops the ffx daemon and deletes SL4F property."""
901        self.log.info('Stopping host device services.')
902        del self.sl4f
903        del self.ffx
904
905    def load_config(self, config):
906        pass
907
908    def take_bug_report(self, test_name=None, begin_time=None):
909        """Takes a bug report on the device and stores it in a file.
910
911        Args:
912            test_name: DEPRECATED. Do not specify this argument; it is only used
913                for logging. Name of the test case that triggered this bug
914                report.
915            begin_time: DEPRECATED. Do not specify this argument; it allows
916                overwriting of bug reports when this function is called several
917                times in one test. Epoch time when the test started. If not
918                specified, the current time will be used.
919        """
920        if not self.ssh_config:
921            self.log.warn(
922                'Skipping take_bug_report because ssh_config is not specified')
923            return
924
925        if test_name:
926            self.log.info(
927                f"Taking snapshot of {self.mdns_name} for {test_name}")
928        else:
929            self.log.info(f"Taking snapshot of {self.mdns_name}")
930
931        epoch = begin_time if begin_time else utils.get_current_epoch_time()
932        time_stamp = acts_logger.normalize_log_line_timestamp(
933            acts_logger.epoch_to_log_line_timestamp(epoch))
934        out_dir = context.get_current_context().get_full_output_path()
935        out_path = os.path.join(out_dir, f'{self.mdns_name}_{time_stamp}.zip')
936
937        try:
938            subprocess.run(
939                [f"ssh -F {self.ssh_config} {self.ip} snapshot > {out_path}"],
940                shell=True)
941            self.log.info(f'Snapshot saved to {out_path}')
942        except Exception as err:
943            self.log.error(f'Failed to take snapshot: {err}')
944
945    def take_bt_snoop_log(self, custom_name=None):
946        """Takes a the bt-snoop log from the device and stores it in a file
947        in a pcap format.
948        """
949        bt_snoop_path = context.get_current_context().get_full_output_path()
950        time_stamp = acts_logger.normalize_log_line_timestamp(
951            acts_logger.epoch_to_log_line_timestamp(time.time()))
952        out_name = "FuchsiaDevice%s_%s" % (
953            self.serial, time_stamp.replace(" ", "_").replace(":", "-"))
954        out_name = "%s.pcap" % out_name
955        if custom_name:
956            out_name = "%s_%s.pcap" % (self.serial, custom_name)
957        else:
958            out_name = "%s.pcap" % out_name
959        full_out_path = os.path.join(bt_snoop_path, out_name)
960        bt_snoop_data = self.ssh.run('bt-snoop-cli -d -f pcap').raw_stdout
961        bt_snoop_file = open(full_out_path, 'wb')
962        bt_snoop_file.write(bt_snoop_data)
963        bt_snoop_file.close()
964