Merge "Refactor: clean up unused and replicated code."
diff --git a/acts/framework/acts/controllers/access_point.py b/acts/framework/acts/controllers/access_point.py
index 4f3f3b2..687ac94 100755
--- a/acts/framework/acts/controllers/access_point.py
+++ b/acts/framework/acts/controllers/access_point.py
@@ -36,6 +36,7 @@
 from acts.controllers.ap_lib import radvd
 from acts.controllers.ap_lib import radvd_config
 from acts.controllers.ap_lib.extended_capabilities import ExtendedCapabilities
+from acts.controllers.ap_lib.wireless_network_management import BssTransitionManagementRequest
 from acts.controllers.utils_lib.commands import ip
 from acts.controllers.utils_lib.commands import route
 from acts.controllers.utils_lib.commands import shell
@@ -907,3 +908,13 @@
             raise ValueError(f'Invalid identifier {identifier} given')
         instance = self._aps.get(identifier)
         return instance.hostapd.get_sta_extended_capabilities(sta_mac)
+
+    def send_bss_transition_management_req(
+            self, identifier, sta_mac: str,
+            request: BssTransitionManagementRequest):
+        """Send a BSS Transition Management request to an associated STA."""
+        if identifier not in list(self._aps.keys()):
+            raise ValueError('Invalid identifier {identifier} given')
+        instance = self._aps.get(identifier)
+        return instance.hostapd.send_bss_transition_management_req(
+            sta_mac, request)
diff --git a/acts/framework/acts/controllers/ap_lib/hostapd.py b/acts/framework/acts/controllers/ap_lib/hostapd.py
index 995d3e2..c2cfc54 100644
--- a/acts/framework/acts/controllers/ap_lib/hostapd.py
+++ b/acts/framework/acts/controllers/ap_lib/hostapd.py
@@ -22,6 +22,7 @@
 from acts.controllers.ap_lib import hostapd_config
 from acts.controllers.ap_lib import hostapd_constants
 from acts.controllers.ap_lib.extended_capabilities import ExtendedCapabilities
+from acts.controllers.ap_lib.wireless_network_management import BssTransitionManagementRequest
 from acts.controllers.utils_lib.commands import shell
 from acts.libs.proc.job import Result
 
@@ -153,7 +154,7 @@
         """Return MAC addresses of all associated STAs."""
         list_sta_result = self._list_sta()
         stas = set()
-        for line in list_sta_result.stdout:
+        for line in list_sta_result.stdout.splitlines():
             # Each line must be a valid MAC address. Capture it.
             m = re.match(r'((?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2})', line)
             if m:
@@ -196,6 +197,59 @@
             raise Error(
                 f'ext_capab contains invalid hex string repr {raw_ext_capab}')
 
+    def _bss_tm_req(self, client_mac: str,
+                    request: BssTransitionManagementRequest) -> Result:
+        """Send a hostapd BSS Transition Management request command to a STA.
+
+        Args:
+            client_mac: MAC address that will receive the request.
+            request: BSS Transition Management request that will be sent.
+        Returns:
+            acts.libs.proc.job.Result containing the results of the command.
+        Raises: See _run_hostapd_cli_cmd
+        """
+        bss_tm_req_cmd = f'bss_tm_req {client_mac}'
+
+        if request.abridged:
+            bss_tm_req_cmd += ' abridged=1'
+        if request.bss_termination_included and request.bss_termination_duration:
+            bss_tm_req_cmd += f' bss_term={request.bss_termination_duration.duration}'
+        if request.disassociation_imminent:
+            bss_tm_req_cmd += ' disassoc_imminent=1'
+        if request.disassociation_timer is not None:
+            bss_tm_req_cmd += f' disassoc_timer={request.disassociation_timer}'
+        if request.preferred_candidate_list_included:
+            bss_tm_req_cmd += ' pref=1'
+        if request.session_information_url:
+            bss_tm_req_cmd += f' url={request.session_information_url}'
+        if request.validity_interval:
+            bss_tm_req_cmd += f' valid_int={request.validity_interval}'
+
+        # neighbor= can appear multiple times, so it requires special handling.
+        for neighbor in request.candidate_list:
+            bssid = neighbor.bssid
+            bssid_info = hex(neighbor.bssid_information)
+            op_class = neighbor.operating_class
+            chan_num = neighbor.channel_number
+            phy_type = int(neighbor.phy_type)
+            bss_tm_req_cmd += f' neighbor={bssid},{bssid_info},{op_class},{chan_num},{phy_type}'
+
+        return self._run_hostapd_cli_cmd(bss_tm_req_cmd)
+
+    def send_bss_transition_management_req(
+            self, sta_mac: str,
+            request: BssTransitionManagementRequest) -> Result:
+        """Send a BSS Transition Management request to an associated STA.
+
+        Args:
+            sta_mac: MAC address of the STA in question.
+            request: BSS Transition Management request that will be sent.
+        Returns:
+            acts.libs.proc.job.Result containing the results of the command.
+        Raises: See _run_hostapd_cli_cmd
+        """
+        return self._bss_tm_req(sta_mac, request)
+
     def is_alive(self):
         """
         Returns:
diff --git a/acts/framework/acts/controllers/fuchsia_device.py b/acts/framework/acts/controllers/fuchsia_device.py
index 2bc64d1..8a36c85 100644
--- a/acts/framework/acts/controllers/fuchsia_device.py
+++ b/acts/framework/acts/controllers/fuchsia_device.py
@@ -14,6 +14,7 @@
 #   See the License for the specific language governing permissions and
 #   limitations under the License.
 
+from typing import Optional
 import backoff
 import json
 import logging
@@ -198,25 +199,26 @@
         self.conf_data = fd_conf_data
         if "ip" not in fd_conf_data:
             raise FuchsiaDeviceError(FUCHSIA_DEVICE_NO_IP_MSG)
-        self.ip = fd_conf_data["ip"]
-        self.orig_ip = fd_conf_data["ip"]
-        self.sl4f_port = fd_conf_data.get("sl4f_port", 80)
-        self.ssh_port = fd_conf_data.get("ssh_port", DEFAULT_SSH_PORT)
-        self.ssh_config = fd_conf_data.get("ssh_config", None)
-        self.ssh_priv_key = fd_conf_data.get("ssh_priv_key", None)
-        self.authorized_file = fd_conf_data.get("authorized_file_loc", None)
-        self.serial_number = fd_conf_data.get("serial_number", None)
-        self.device_type = fd_conf_data.get("device_type", None)
-        self.product_type = fd_conf_data.get("product_type", None)
-        self.board_type = fd_conf_data.get("board_type", None)
-        self.build_number = fd_conf_data.get("build_number", None)
-        self.build_type = fd_conf_data.get("build_type", None)
-        self.server_path = fd_conf_data.get("server_path", None)
-        self.specific_image = fd_conf_data.get("specific_image", None)
-        self.ffx_binary_path = fd_conf_data.get("ffx_binary_path", None)
-        self.pm_binary_path = fd_conf_data.get("pm_binary_path", None)
-        self.packages_path = fd_conf_data.get("packages_path", None)
-        self.mdns_name = fd_conf_data.get("mdns_name", None)
+        self.ip: str = fd_conf_data["ip"]
+        self.orig_ip: str = fd_conf_data["ip"]
+        self.sl4f_port: int = fd_conf_data.get("sl4f_port", 80)
+        self.ssh_port: int = fd_conf_data.get("ssh_port", DEFAULT_SSH_PORT)
+        self.ssh_config: Optional[str] = fd_conf_data.get("ssh_config", None)
+        self.ssh_priv_key: Optional[str] = fd_conf_data.get("ssh_priv_key", None)
+        self.authorized_file: Optional[str] = fd_conf_data.get("authorized_file_loc", None)
+        self.serial_number: Optional[str] = fd_conf_data.get("serial_number", None)
+        self.device_type: Optional[str] = fd_conf_data.get("device_type", None)
+        self.product_type: Optional[str] = fd_conf_data.get("product_type", None)
+        self.board_type: Optional[str] = fd_conf_data.get("board_type", None)
+        self.build_number: Optional[str] = fd_conf_data.get("build_number", None)
+        self.build_type: Optional[str] = fd_conf_data.get("build_type", None)
+        self.server_path: Optional[str] = fd_conf_data.get("server_path", None)
+        self.specific_image: Optional[str] = fd_conf_data.get("specific_image", None)
+        self.ffx_binary_path: Optional[str] = fd_conf_data.get("ffx_binary_path", None)
+        # Path to a tar.gz archive with pm and amber-files, as necessary for
+        # starting a package server.
+        self.packages_archive_path: Optional[str] = fd_conf_data.get("packages_archive_path", None)
+        self.mdns_name: Optional[str] = fd_conf_data.get("mdns_name", None)
 
         # Instead of the input ssh_config, a new config is generated with proper
         # ControlPath to the test output directory.
@@ -429,9 +431,9 @@
         self.wlan_policy_controller = WlanPolicyController(self)
 
     def start_package_server(self):
-        if not self.pm_binary_path or not self.packages_path:
+        if not self.packages_archive_path:
             self.log.warn(
-                "Either pm_binary_path or packages_path is not specified. "
+                "packages_archive_path is not specified. "
                 "Assuming a package server is already running and configured on "
                 "the DUT. If this is not the case, either run your own package "
                 "server, or configure these fields appropriately. "
@@ -444,8 +446,7 @@
             )
             return
 
-        self.package_server = PackageServer(self.pm_binary_path,
-                                            self.packages_path)
+        self.package_server = PackageServer(self.packages_archive_path)
         self.package_server.start()
         self.package_server.configure_device(self.ssh)
 
diff --git a/acts/framework/acts/controllers/fuchsia_lib/package_server.py b/acts/framework/acts/controllers/fuchsia_lib/package_server.py
index 96fe901..7c763e6 100644
--- a/acts/framework/acts/controllers/fuchsia_lib/package_server.py
+++ b/acts/framework/acts/controllers/fuchsia_lib/package_server.py
@@ -16,14 +16,16 @@
 
 import json
 import os
+import shutil
 import socket
 import subprocess
+import tarfile
+import tempfile
 import time
 
 from dataclasses import dataclass
 from datetime import datetime
-from io import FileIO
-from typing import List, Optional
+from typing import TextIO, List, Optional
 
 from acts import context
 from acts import logger
@@ -112,44 +114,49 @@
 
 
 class PackageServer:
-    """Package manager for Fuchsia; an interface to the "pm" CLI tool.
+    """Package manager for Fuchsia; an interface to the "pm" CLI tool."""
 
-    Attributes:
-        log: Logger for the device-specific instance of ffx.
-        binary_path: Path to the pm binary.
-        packages_path: Path to amber-files.
-        port: Port to listen on for package serving.
-    """
-
-    def __init__(self, binary_path: str, packages_path: str) -> None:
+    def __init__(self, packages_archive_path: str) -> None:
         """
         Args:
-            binary_path: Path to ffx binary.
-            packages_path: Path to amber-files.
+            packages_archive_path: Path to an archive containing the pm binary
+                and amber-files.
         """
-        self.log: TraceLogger = logger.create_tagged_trace_logger(f"pm")
-        self.binary_path = binary_path
-        self.packages_path = packages_path
-        self.port = random_port()
+        self.log: TraceLogger = logger.create_tagged_trace_logger("pm")
 
-        self._server_log: Optional[FileIO] = None
+        self._server_log: Optional[TextIO] = None
         self._server_proc: Optional[subprocess.Popen] = None
+        self._log_path: Optional[str] = None
+
+        self._tmp_dir = tempfile.mkdtemp(prefix="packages-")
+        tar = tarfile.open(packages_archive_path, "r:gz")
+        tar.extractall(self._tmp_dir)
+
+        self._binary_path = os.path.join(self._tmp_dir, "pm")
+        self._packages_path = os.path.join(self._tmp_dir, "amber-files")
+        self._port = random_port()
 
         self._assert_repo_has_not_expired()
 
+    def clean_up(self) -> None:
+        if self._server_proc:
+            self.stop_server()
+        if self._tmp_dir:
+            shutil.rmtree(self._tmp_dir)
+
     def _assert_repo_has_not_expired(self) -> None:
         """Abort if the repository metadata has expired.
 
         Raises:
             TestAbortClass: when the timestamp.json file has expired
         """
-        with open(f'{self.packages_path}/repository/timestamp.json', 'r') as f:
+        with open(f'{self._packages_path}/repository/timestamp.json', 'r') as f:
             data = json.load(f)
             expiresAtRaw = data["signed"]["expires"]
             expiresAt = datetime.strptime(expiresAtRaw, '%Y-%m-%dT%H:%M:%SZ')
             if expiresAt <= datetime.now():
                 raise signals.TestAbortClass(
-                    f'{self.packages_path}/repository/timestamp.json has expired on {expiresAtRaw}'
+                    f'{self._packages_path}/repository/timestamp.json has expired on {expiresAtRaw}'
                 )
 
     def start(self) -> None:
@@ -163,7 +170,7 @@
             )
             return
 
-        pm_command = f'{self.binary_path} serve -c 2 -repo {self.packages_path} -l :{self.port}'
+        pm_command = f'{self._binary_path} serve -c 2 -repo {self._packages_path} -l :{self._port}'
 
         root_dir = context.get_current_context().get_full_output_path()
         epoch = utils.get_current_epoch_time()
@@ -177,7 +184,7 @@
                                              stdout=self._server_log,
                                              stderr=subprocess.STDOUT)
         self._wait_for_server()
-        self.log.info(f'Serving packages on port {self.port}')
+        self.log.info(f'Serving packages on port {self._port}')
 
     def configure_device(self,
                          device_ssh: SSHProvider,
@@ -197,7 +204,7 @@
 
         # Configure the device with the new repository.
         host_ip = find_host_ip(device_ssh.ip)
-        repo_url = f"http://{host_ip}:{self.port}"
+        repo_url = f"http://{host_ip}:{self._port}"
         device_ssh.run(
             f"pkgctl repo add url -f 2 -n {repo_name} {repo_url}/config.json")
         self.log.info(
@@ -220,18 +227,20 @@
         timeout = time.perf_counter() + timeout_sec
         while True:
             try:
-                socket.create_connection(('127.0.0.1', self.port),
+                socket.create_connection(('127.0.0.1', self._port),
                                          timeout=timeout)
                 return
             except ConnectionRefusedError:
                 continue
             finally:
                 if time.perf_counter() > timeout:
-                    self._server_log.close()
-                    with open(self._log_path, 'r') as f:
-                        logs = f.read()
+                    if self._server_log:
+                        self._server_log.close()
+                    if self._log_path:
+                        with open(self._log_path, 'r') as f:
+                            logs = f.read()
                     raise TimeoutError(
-                        f"pm serve failed to expose port {self.port} after {timeout_sec}s. Logs:\n{logs}"
+                        f"pm serve failed to expose port {self._port} after {timeout_sec}s. Logs:\n{logs}"
                     )
 
     def stop_server(self) -> None:
@@ -251,12 +260,9 @@
             self._server_proc.kill()
             self._server_proc.wait(timeout=PM_SERVE_STOP_TIMEOUT_SEC)
         finally:
-            self._server_log.close()
+            if self._server_log:
+                self._server_log.close()
 
         self._server_proc = None
         self._log_path = None
         self._server_log = None
-
-    def clean_up(self) -> None:
-        if self._server_proc:
-            self.stop_server()
diff --git a/acts/framework/setup.py b/acts/framework/setup.py
index cc09235..81eb251 100755
--- a/acts/framework/setup.py
+++ b/acts/framework/setup.py
@@ -26,7 +26,6 @@
     'backoff',
     # Future needs to have a newer version that contains urllib.
     'future>=0.16.0',
-    'grpcio',
     'mobly==1.12.0',
     # Latest version of mock (4.0.0b) causes a number of compatibility issues with ACTS unit tests
     # b/148695846, b/148814743
@@ -46,7 +45,8 @@
 versioned_deps = {
     'numpy': 'numpy',
     'scipy': 'scipy',
-    'protobuf': 'protobuf==4.21.5'
+    'protobuf': 'protobuf==4.21.5',
+    'grpcio': 'grpcio',
 }
 
 # numpy and scipy version matrix per:
@@ -58,6 +58,7 @@
     versioned_deps['numpy'] = 'numpy<1.20'
     versioned_deps['scipy'] = 'scipy<1.6'
     versioned_deps['protobuf'] = 'protobuf==3.20.1'
+    versioned_deps['grpcio'] = 'grpcio==1.48.2'
     versioned_deps['typing_extensions'] = 'typing_extensions==4.1.1'
 if (sys.version_info.major, sys.version_info.minor) == (3, 6):
     versioned_deps['dataclasses'] = 'dataclasses==0.8'