Radio Measurement and Wireless Network Management libraries.

Upcoming changes will introduce tests that verify proper handling of
BSS Transition Management requests (WNM 802.11v). These libraries make
it easy to represent the BSS Transition Management request in enough
detail for the BTM fields supported by hostapd's BTM command. This
change also includes a new Radio Measurement (802.11k) library,
because BSS Transition Management requests use the neighbor report.

A future change will use these libraries to add BTM functionality
to the hostapd controller.

https://fxbug.dev/103440

Bug: None
Test: Unit tests added; manually verified using forthcoming BTM ACTS in
      https://r.android.com/2216605
Change-Id: I23f7ec50d748e93f9f6982ea4d1b22cc1f52ad9d
diff --git a/acts/framework/acts/controllers/ap_lib/radio_measurement.py b/acts/framework/acts/controllers/ap_lib/radio_measurement.py
new file mode 100644
index 0000000..dc009e9
--- /dev/null
+++ b/acts/framework/acts/controllers/ap_lib/radio_measurement.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+#
+#   Copyright 2022 - The Android Open Source Project
+#
+#   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 enum import IntEnum, unique
+
+
+@unique
+class ApReachability(IntEnum):
+    """Neighbor Report AP Reachability values.
+
+    See IEEE 802.11-2020 Figure 9-172.
+    """
+    NOT_REACHABLE = 1
+    UNKNOWN = 2
+    REACHABLE = 3
+
+
+class BssidInformationCapabilities:
+    """Representation of Neighbor Report BSSID Information Capabilities.
+
+    See IEEE 802.11-2020 Figure 9-338 and 9.4.1.4.
+    """
+
+    def __init__(self,
+                 spectrum_management: bool = False,
+                 qos: bool = False,
+                 apsd: bool = False,
+                 radio_measurement: bool = False):
+        """Create a capabilities object.
+
+        Args:
+            spectrum_management: whether spectrum management is required.
+            qos: whether QoS is implemented.
+            apsd: whether APSD is implemented.
+            radio_measurement: whether radio measurement is activated.
+        """
+        self._spectrum_management = spectrum_management
+        self._qos = qos
+        self._apsd = apsd
+        self._radio_measurement = radio_measurement
+
+    def __index__(self) -> int:
+        """Convert to numeric representation of the field's bits."""
+        return self.spectrum_management << 5 \
+            | self.qos << 4 \
+            | self.apsd << 3 \
+            | self.radio_measurement << 2
+
+    @property
+    def spectrum_management(self) -> bool:
+        return self._spectrum_management
+
+    @property
+    def qos(self) -> bool:
+        return self._qos
+
+    @property
+    def apsd(self) -> bool:
+        return self._apsd
+
+    @property
+    def radio_measurement(self) -> bool:
+        return self._radio_measurement
+
+
+class BssidInformation:
+    """Representation of Neighbor Report BSSID Information field.
+
+    BssidInformation contains info about a neighboring AP, to be included in a
+    neighbor report element. See IEEE 802.11-2020 Figure 9-337.
+    """
+
+    def __init__(self,
+                 ap_reachability: ApReachability = ApReachability.UNKNOWN,
+                 security: bool = False,
+                 key_scope: bool = False,
+                 capabilities:
+                 BssidInformationCapabilities = BssidInformationCapabilities(),
+                 mobility_domain: bool = False,
+                 high_throughput: bool = False,
+                 very_high_throughput: bool = False,
+                 ftm: bool = False):
+        """Create a BSSID Information object for a neighboring AP.
+
+        Args:
+            ap_reachability: whether this AP is reachable by the STA that
+                requested the neighbor report.
+            security: whether this AP is known to support the same security
+                provisioning as used by the STA in its current association.
+            key_scope: whether this AP is known to have the same
+                authenticator as the AP sending the report.
+            capabilities: selected capabilities of this AP.
+            mobility_domain: whether the AP is including an MDE in its beacon
+                frames and the contents of that MDE are identical to the MDE
+                advertised by the AP sending the report.
+            high_throughput: whether the AP is an HT AP including the HT
+                Capabilities element in its Beacons, and that the contents of
+                that HT capabilities element are identical to the HT
+                capabilities element advertised by the AP sending the report.
+            very_high_throughput: whether the AP is a VHT AP and the VHT
+                capabilities element, if included as a subelement, is
+                identical in content to the VHT capabilities element included
+                in the AP’s beacon.
+            ftm: whether the AP is known to have the Fine Timing Measurement
+                Responder extended capability.
+        """
+        self._ap_reachability = ap_reachability
+        self._security = security
+        self._key_scope = key_scope
+        self._capabilities = capabilities
+        self._mobility_domain = mobility_domain
+        self._high_throughput = high_throughput
+        self._very_high_throughput = very_high_throughput
+        self._ftm = ftm
+
+    def __index__(self) -> int:
+        """Convert to numeric representation of the field's bits."""
+        return self._ap_reachability << 30 \
+            | self.security << 29 \
+            | self.key_scope << 28 \
+            | int(self.capabilities) << 22 \
+            | self.mobility_domain << 21 \
+            | self.high_throughput << 20 \
+            | self.very_high_throughput << 19 \
+            | self.ftm << 18
+
+    @property
+    def security(self) -> bool:
+        return self._security
+
+    @property
+    def key_scope(self) -> bool:
+        return self._key_scope
+
+    @property
+    def capabilities(self) -> BssidInformationCapabilities:
+        return self._capabilities
+
+    @property
+    def mobility_domain(self) -> bool:
+        return self._mobility_domain
+
+    @property
+    def high_throughput(self) -> bool:
+        return self._high_throughput
+
+    @property
+    def very_high_throughput(self) -> bool:
+        return self._very_high_throughput
+
+    @property
+    def ftm(self) -> bool:
+        return self._ftm
+
+
+@unique
+class PhyType(IntEnum):
+    """PHY type values, see dot11PhyType in 802.11-2020 Annex C."""
+    DSSS = 2
+    OFDM = 4
+    HRDSS = 5
+    ERP = 6
+    HT = 7
+    DMG = 8
+    VHT = 9
+    TVHT = 10
+    S1G = 11
+    CDMG = 12
+    CMMG = 13
+
+
+class NeighborReportElement:
+    """Representation of Neighbor Report element.
+
+    See IEEE 802.11-2020 9.4.2.36.
+    """
+
+    def __init__(self, bssid: str, bssid_information: BssidInformation,
+                 operating_class: int, channel_number: int, phy_type: PhyType):
+        """Create a neighbor report element.
+
+        Args:
+            bssid: MAC address of the neighbor.
+            bssid_information: BSSID Information of the neigbor.
+            operating_class: operating class of the neighbor.
+            channel_number: channel number of the neighbor.
+            phy_type: dot11PhyType of the neighbor.
+        """
+        self._bssid = bssid
+        self._bssid_information = bssid_information
+
+        # Operating Class, IEEE 802.11-2020 Annex E.
+        self._operating_class = operating_class
+
+        self._channel_number = channel_number
+
+        # PHY Type, IEEE 802.11-2020 Annex C.
+        self._phy_type = phy_type
+
+    @property
+    def bssid(self) -> str:
+        return self._bssid
+
+    @property
+    def bssid_information(self) -> BssidInformation:
+        return self._bssid_information
+
+    @property
+    def operating_class(self) -> int:
+        return self._operating_class
+
+    @property
+    def channel_number(self) -> int:
+        return self._channel_number
+
+    @property
+    def phy_type(self) -> PhyType:
+        return self._phy_type
diff --git a/acts/framework/acts/controllers/ap_lib/wireless_network_management.py b/acts/framework/acts/controllers/ap_lib/wireless_network_management.py
new file mode 100644
index 0000000..f99cd0f
--- /dev/null
+++ b/acts/framework/acts/controllers/ap_lib/wireless_network_management.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+#
+#   Copyright 2022 - The Android Open Source Project
+#
+#   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 typing import List, NewType, Optional
+
+from acts.controllers.ap_lib.radio_measurement import NeighborReportElement
+
+BssTransitionCandidateList = NewType('BssTransitionCandidateList',
+                                     List[NeighborReportElement])
+
+
+class BssTerminationDuration:
+    """Representation of BSS Termination Duration subelement.
+
+    See IEEE 802.11-2020 Figure 9-341.
+    """
+
+    def __init__(self, duration: int):
+        """Create a BSS Termination Duration subelement.
+
+        Args:
+            duration: number of minutes the BSS will be offline.
+        """
+        # Note: hostapd does not currently support setting BSS Termination TSF,
+        # which is the other value held in this subelement.
+        self._duration = duration
+
+    @property
+    def duration(self) -> int:
+        return self._duration
+
+
+class BssTransitionManagementRequest:
+    """Representation of BSS Transition Management request.
+
+    See IEEE 802.11-2020 9.6.13.9.
+    """
+
+    def __init__(
+            self,
+            preferred_candidate_list_included: bool = False,
+            abridged: bool = False,
+            disassociation_imminent: bool = False,
+            ess_disassociation_imminent: bool = False,
+            disassociation_timer: int = 0,
+            validity_interval: int = 1,
+            bss_termination_duration: Optional[BssTerminationDuration] = None,
+            session_information_url: Optional[str] = None,
+            candidate_list: Optional[BssTransitionCandidateList] = None):
+        """Create a BSS Transition Management request.
+
+        Args:
+            preferred_candidate_list_included: whether the candidate list is a
+                preferred candidate list, or (if False) a list of known
+                candidates.
+            abridged: whether a preference value of 0 is assigned to all BSSIDs
+                that do not appear in the candidate list, or (if False) AP has
+                no recommendation for/against anything not in the candidate
+                list.
+            disassociation_imminent: whether the STA is about to be
+                disassociated by the AP.
+            ess_disassociation_imminent: whether the STA will be disassociated
+                from the ESS.
+            disassociation_timer: the number of beacon transmission times
+                (TBTTs) until the AP disassociates this STA (default 0, meaning
+                AP has not determined when it will disassociate this STA).
+            validity_interval: number of TBTTs until the candidate list is no
+                longer valid (default 1).
+            bss_termination_duration: BSS Termination Duration subelement.
+            session_information_url: this URL is included if ESS disassociation
+                is immiment.
+            candidate_list: zero or more neighbor report elements.
+        """
+        # Request mode field, see IEEE 802.11-2020 Figure 9-924.
+        self._preferred_candidate_list_included = preferred_candidate_list_included
+        self._abridged = abridged
+        self._disassociation_imminent = disassociation_imminent
+        self._ess_disassociation_imminent = ess_disassociation_imminent
+
+        # Disassociation Timer, see IEEE 802.11-2020 Figure 9-925
+        self._disassociation_timer = disassociation_timer
+
+        # Validity Interval, see IEEE 802.11-2020 9.6.13.9
+        self._validity_interval = validity_interval
+
+        # BSS Termination Duration, see IEEE 802.11-2020 9.6.13.9 and Figure 9-341
+        self._bss_termination_duration = bss_termination_duration
+
+        # Session Information URL, see IEEE 802.11-2020 Figure 9-926
+        self._session_information_url = session_information_url
+
+        # BSS Transition Candidate List Entries, IEEE 802.11-2020 9.6.13.9.
+        self._candidate_list = candidate_list
+
+    @property
+    def preferred_candidate_list_included(self) -> bool:
+        return self._preferred_candidate_list_included
+
+    @property
+    def abridged(self) -> bool:
+        return self._abridged
+
+    @property
+    def disassociation_imminent(self) -> bool:
+        return self._disassociation_imminent
+
+    @property
+    def bss_termination_included(self) -> bool:
+        return self._bss_termination_duration is not None
+
+    @property
+    def ess_disassociation_imminent(self) -> bool:
+        return self._ess_disassociation_imminent
+
+    @property
+    def disassociation_timer(self) -> Optional[int]:
+        if self.disassociation_imminent:
+            return self._disassociation_timer
+        # Otherwise, field is reserved.
+        return None
+
+    @property
+    def validity_interval(self) -> int:
+        return self._validity_interval
+
+    @property
+    def bss_termination_duration(self) -> Optional[BssTerminationDuration]:
+        return self._bss_termination_duration
+
+    @property
+    def session_information_url(self) -> Optional[str]:
+        return self._session_information_url
+
+    @property
+    def candidate_list(self) -> Optional[BssTransitionCandidateList]:
+        return self._candidate_list
diff --git a/acts/framework/tests/controllers/ap_lib/radio_measurement_test.py b/acts/framework/tests/controllers/ap_lib/radio_measurement_test.py
new file mode 100644
index 0000000..d72840d
--- /dev/null
+++ b/acts/framework/tests/controllers/ap_lib/radio_measurement_test.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+#
+#   Copyright 2022 - The Android Open Source Project
+#
+#   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 unittest
+
+from acts.controllers.ap_lib.radio_measurement import BssidInformation, BssidInformationCapabilities, NeighborReportElement, PhyType
+
+EXPECTED_BSSID = '01:23:45:ab:cd:ef'
+EXPECTED_BSSID_INFO_CAP = BssidInformationCapabilities(
+    spectrum_management=True, qos=True, apsd=True, radio_measurement=True)
+EXPECTED_OP_CLASS = 81
+EXPECTED_CHAN = 11
+EXPECTED_PHY = PhyType.HT
+EXPECTED_BSSID_INFO = BssidInformation(capabilities=EXPECTED_BSSID_INFO_CAP,
+                                       high_throughput=True)
+
+
+class RadioMeasurementTest(unittest.TestCase):
+    def test_bssid_information_capabilities(self):
+        self.assertTrue(EXPECTED_BSSID_INFO_CAP.spectrum_management)
+        self.assertTrue(EXPECTED_BSSID_INFO_CAP.qos)
+        self.assertTrue(EXPECTED_BSSID_INFO_CAP.apsd)
+        self.assertTrue(EXPECTED_BSSID_INFO_CAP.radio_measurement)
+        # Must also test the numeric representation.
+        self.assertEqual(int(EXPECTED_BSSID_INFO_CAP), 0b111100)
+
+    def test_bssid_information(self):
+        self.assertEqual(EXPECTED_BSSID_INFO.capabilities,
+                         EXPECTED_BSSID_INFO_CAP)
+        self.assertEqual(EXPECTED_BSSID_INFO.high_throughput, True)
+        # Must also test the numeric representation.
+        self.assertEqual(int(EXPECTED_BSSID_INFO),
+                         0b10001111000100000000000000000000)
+
+    def test_neighbor_report_element(self):
+        element = NeighborReportElement(bssid=EXPECTED_BSSID,
+                                        bssid_information=EXPECTED_BSSID_INFO,
+                                        operating_class=EXPECTED_OP_CLASS,
+                                        channel_number=EXPECTED_CHAN,
+                                        phy_type=EXPECTED_PHY)
+        self.assertEqual(element.bssid, EXPECTED_BSSID)
+        self.assertEqual(element.bssid_information, EXPECTED_BSSID_INFO)
+        self.assertEqual(element.operating_class, EXPECTED_OP_CLASS)
+        self.assertEqual(element.channel_number, EXPECTED_CHAN)
+        self.assertEqual(element.phy_type, EXPECTED_PHY)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/acts/framework/tests/controllers/ap_lib/wireless_network_management_test.py b/acts/framework/tests/controllers/ap_lib/wireless_network_management_test.py
new file mode 100644
index 0000000..45dfedb
--- /dev/null
+++ b/acts/framework/tests/controllers/ap_lib/wireless_network_management_test.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+#
+#   Copyright 2022 - The Android Open Source Project
+#
+#   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 unittest
+
+from acts.controllers.ap_lib.radio_measurement import BssidInformation, NeighborReportElement, PhyType
+from acts.controllers.ap_lib.wireless_network_management import BssTransitionCandidateList, BssTransitionManagementRequest
+
+EXPECTED_NEIGHBOR_1 = NeighborReportElement(
+    bssid='01:23:45:ab:cd:ef',
+    bssid_information=BssidInformation(),
+    operating_class=81,
+    channel_number=1,
+    phy_type=PhyType.HT)
+EXPECTED_NEIGHBOR_2 = NeighborReportElement(
+    bssid='cd:ef:ab:45:67:89',
+    bssid_information=BssidInformation(),
+    operating_class=121,
+    channel_number=149,
+    phy_type=PhyType.VHT)
+EXPECTED_NEIGHBORS = [EXPECTED_NEIGHBOR_1, EXPECTED_NEIGHBOR_2]
+EXPECTED_CANDIDATE_LIST = BssTransitionCandidateList(EXPECTED_NEIGHBORS)
+
+
+class WirelessNetworkManagementTest(unittest.TestCase):
+    def test_bss_transition_management_request(self):
+        request = BssTransitionManagementRequest(
+            disassociation_imminent=True,
+            abridged=True,
+            candidate_list=EXPECTED_NEIGHBORS)
+        self.assertTrue(request.disassociation_imminent)
+        self.assertTrue(request.abridged)
+        self.assertIn(EXPECTED_NEIGHBOR_1, request.candidate_list)
+        self.assertIn(EXPECTED_NEIGHBOR_2, request.candidate_list)
+
+
+if __name__ == '__main__':
+    unittest.main()