blob: 0002c796cda62f3de30e9429541b301e85c49db3 [file] [log] [blame]
#!/usr/bin/env python3
#
# Copyright 2022 The Fuchsia Authors
#
# 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 itertools
import logging
import os
import time
from dataclasses import dataclass
from enum import Enum, StrEnum, auto, unique
from mobly import asserts, signals, test_runner
from mobly.records import TestResultRecord
from antlion import utils
from antlion.controllers.ap_lib.hostapd_ap_preset import create_ap_preset
from antlion.controllers.ap_lib.hostapd_constants import (
AP_SSID_LENGTH_2G,
BandType,
)
from antlion.controllers.ap_lib.hostapd_security import Security, SecurityMode
from antlion.controllers.ap_lib.hostapd_utils import generate_random_password
from antlion.controllers.ap_lib.radvd_config import RadvdConfig
from antlion.controllers.fuchsia_device import FuchsiaDevice
from antlion.test_utils.abstract_devices.wlan_device import AssociationMode
from antlion.test_utils.wifi import base_test
DUT_NETWORK_CONNECTION_TIMEOUT = 60
@unique
class DeviceType(StrEnum):
AP = auto()
DUT = auto()
@unique
class RebootType(StrEnum):
SOFT = auto()
HARD = auto()
@unique
class IpVersionType(Enum):
IPV4 = auto()
IPV6 = auto()
DUAL_IPV4_IPV6 = auto()
def ipv4(self) -> bool:
match self:
case IpVersionType.IPV4:
return True
case IpVersionType.IPV6:
return False
case IpVersionType.DUAL_IPV4_IPV6:
return True
def ipv6(self) -> bool:
match self:
case IpVersionType.IPV4:
return False
case IpVersionType.IPV6:
return True
case IpVersionType.DUAL_IPV4_IPV6:
return True
@staticmethod
def all() -> list["IpVersionType"]:
return [
IpVersionType.IPV4,
IpVersionType.IPV6,
IpVersionType.DUAL_IPV4_IPV6,
]
@dataclass
class TestParams:
reboot_device: DeviceType
reboot_type: RebootType
band: BandType
security_mode: SecurityMode
ip_version: IpVersionType
class WlanRebootTest(base_test.WifiBaseTest):
"""Tests wlan reconnects in different reboot scenarios.
Testbed Requirement:
* One ACTS compatible device (dut)
* One Whirlwind Access Point
* One PduDevice
"""
def pre_run(self) -> None:
test_params: list[tuple[TestParams]] = []
for (
device_type,
reboot_type,
band,
security_mode,
ip_version,
) in itertools.product(
# DeviceType,
# RebootType,
# BandType,
# SecurityMode,
# IpVersionType,
#
# TODO(https://github.com/python/mypy/issues/14688): Replace the code below
# with the commented code above once the bug affecting StrEnum resolves.
[e for e in DeviceType],
[e for e in RebootType],
[e for e in BandType],
[SecurityMode.OPEN, SecurityMode.WPA2, SecurityMode.WPA3],
[e for e in IpVersionType],
):
test_params.append(
(
TestParams(
device_type,
reboot_type,
band,
security_mode,
ip_version,
),
)
)
def generate_test_name(t: TestParams) -> str:
test_name = (
"test"
f"_{t.reboot_type}_reboot"
f"_{t.reboot_device}"
f"_{t.band}"
f"_{t.security_mode}"
)
if t.ip_version.ipv4():
test_name += "_ipv4"
if t.ip_version.ipv6():
test_name += "_ipv6"
return test_name
self.generate_tests(
test_logic=self.run_reboot_test,
name_func=generate_test_name,
arg_sets=test_params,
)
def setup_class(self) -> None:
super().setup_class()
self.log = logging.getLogger()
if len(self.access_points) == 0:
raise signals.TestAbortClass("Requires at least one access point")
self.access_point = self.access_points[0]
self.fuchsia_device, self.dut = self.get_dut_type(
FuchsiaDevice, AssociationMode.POLICY
)
def setup_test(self) -> None:
super().setup_test()
self.access_point.stop_all_aps()
self.dut.wifi_toggle_state(True)
for ad in self.android_devices:
ad.droid.wakeLockAcquireBright()
ad.droid.wakeUpNow()
self.dut.disconnect()
if self.fuchsia_device:
self.fuchsia_device.configure_wlan()
def on_fail(self, record: TestResultRecord) -> None:
super().on_fail(record)
self.access_point.download_ap_logs(self.current_test_info.output_path)
def teardown_test(self) -> None:
# TODO(b/273923552): We take a snapshot here and before rebooting the
# DUT for every test because the persistence component does not make the
# inspect logs available for 120 seconds. This helps for debugging
# issues where we need previous state.
self.dut.take_bug_report(self.current_test_info.record)
self.download_logs()
self.access_point.stop_all_aps()
self.dut.disconnect()
for ad in self.android_devices:
ad.droid.wakeLockRelease()
ad.droid.goToSleepNow()
self.dut.turn_location_off_and_scan_toggle_off()
self.dut.reset_wifi()
if self.fuchsia_device:
self.fuchsia_device.deconfigure_wlan()
super().teardown_test()
def setup_ap(
self,
ssid: str,
band: BandType,
ip_version: IpVersionType,
security_mode: SecurityMode,
password: str | None = None,
) -> None:
"""Setup ap with basic config.
Args:
ssid: The ssid to setup on ap
band: The type of band to set up the ap with ('2g' or '5g').
ip_version: The type of ip to use (ipv4 or ipv6)
security_mode: The type of security mode.
password: The PSK or passphase.
"""
# TODO(fxb/63719): Add varying AP parameters
security_profile = Security(
security_mode=security_mode, password=password
)
self.access_point.start_ap(
hostapd_config=create_ap_preset(
iface_wlan_2g=self.access_point.wlan_2g,
iface_wlan_5g=self.access_point.wlan_5g,
profile_name="whirlwind",
channel=band.default_channel(),
ssid=ssid,
security=security_profile,
# TODO(http://b/271628778): Remove ap_max_inactivity once
# Fuchsia respects 802.11w (PMF) comeback-time.
ap_max_inactivity=100 if band is BandType.BAND_5G else None,
),
radvd_config=RadvdConfig() if ip_version.ipv6() else None,
)
if not ip_version.ipv4():
self.access_point.stop_dhcp()
self.log.info(f"Network (SSID: {ssid}) is up.")
def ping_dut_to_ap(
self,
band: BandType,
ip_version: IpVersionType,
) -> None:
"""Validate the DUT is pingable."""
if band is BandType.BAND_2G:
test_interface = self.access_point.wlan_2g
elif band is BandType.BAND_5G:
test_interface = self.access_point.wlan_5g
if ip_version == IpVersionType.IPV4:
ap_address = utils.get_addr(self.access_point.ssh, test_interface)
elif ip_version == IpVersionType.IPV6:
ap_address = utils.get_addr(
self.access_point.ssh,
test_interface,
addr_type="ipv6_link_local",
)
else:
raise TypeError(f"Invalid IP type: {ip_version}")
if ap_address:
if ip_version == IpVersionType.IPV4:
ping_result = self.dut.ping(ap_address)
else:
ap_address = (
f"{ap_address}%{self.dut.get_default_wlan_test_interface()}"
)
ping_result = self.dut.ping(ap_address)
if ping_result.success:
self.log.info("Ping was successful.")
else:
raise signals.TestFailure(
f"Ping was unsuccessful: {ping_result}"
)
else:
raise ConnectionError("Failed to retrieve APs ping address.")
def prepare_dut_for_reconnection(self) -> None:
"""Perform any actions to ready DUT for reconnection.
These actions will vary depending on the DUT. eg. android devices may
need to be woken up, ambient devices should not require any interaction,
etc.
"""
self.dut.wifi_toggle_state(True)
for ad in self.android_devices:
ad.droid.wakeUpNow()
def wait_for_dut_network_connection(self, ssid: str) -> None:
"""Checks if device is connected to given network. Sleeps 1 second
between retries.
Args:
ssid: ssid to check connection to.
Raises:
ConnectionError, if DUT is not connected after all timeout.
"""
self.log.info(
f"Checking if DUT is connected to {ssid} network. Will retry for "
f"{DUT_NETWORK_CONNECTION_TIMEOUT} seconds."
)
timeout = time.time() + DUT_NETWORK_CONNECTION_TIMEOUT
while time.time() < timeout:
try:
is_connected = self.dut.is_connected(ssid=ssid)
except Exception as err:
self.log.debug(
f"SL4* call failed. Retrying in 1 second. Error: {err}"
)
is_connected = False
finally:
if is_connected:
self.log.info("Success: DUT has connected.")
break
else:
self.log.debug(
f"DUT not connected to network {ssid}...retrying in 1 second."
)
time.sleep(1)
else:
raise ConnectionError("DUT failed to connect to the network.")
def write_csv_time_to_reconnect(
self,
test_name: str,
reconnect_success: bool,
time_to_reconnect: float = 0.0,
) -> None:
"""Writes the time to reconnect to a csv file.
Args:
test_name: the name of the test case
reconnect_success: whether the test successfully reconnected or not
time_to_reconnect: the time from when the rebooted device came back
up to when it reassociated (or 'FAIL'), if it failed to
reconnect.
"""
csv_file_name = os.path.join(self.log_path, "time_to_reconnect.csv")
self.log.info(f"Writing to {csv_file_name}")
with open(csv_file_name, "a") as csv_file:
if reconnect_success:
csv_file.write(f"{test_name},{time_to_reconnect}\n")
else:
csv_file.write(f"{test_name},'FAIL'\n")
def log_and_continue(
self,
ssid: str,
time_to_reconnect: float = 0.0,
error: Exception | None = None,
) -> None:
"""Writes the time to reconnect to the csv file before continuing, used
in stress tests runs.
Args:
time_to_reconnect: the time from when the rebooted device came back
ip to when reassociation occurred.
error: error message to log before continuing with the test
"""
if error:
self.log.info(
f"Device failed to reconnect to network {ssid}. Error: {error}"
)
self.write_csv_time_to_reconnect(
f"{self.current_test_info.name}", False
)
else:
self.log.info(
f"Device successfully reconnected to network {ssid} after "
f"{time_to_reconnect} seconds."
)
self.write_csv_time_to_reconnect(
f"{self.current_test_info.name}", True, time_to_reconnect
)
def run_reboot_test(self, settings: TestParams) -> None:
"""Runs a reboot test based on a given config.
1. Setups up a network, associates the dut, and saves the network.
2. Verifies the dut receives ip address(es).
3. Verifies traffic between DUT and AP (ping)
4. Reboots (hard or soft) the device (dut or ap).
- If the ap was rebooted, setup the same network again.
5. Wait for reassociation or timeout.
6. If reassocation occurs:
- Verifies the dut receives ip address(es).
- Verifies traffic between DUT and AP (ping).
7. Logs time to reconnect (or failure to reconnect)
Args:
settings: TestParams dataclass containing the following values:
reboot_device: the device to reboot either DUT or AP.
reboot_type: how to reboot the reboot_device either hard or soft.
band: band to setup either 2g or 5g
security_mode: security mode to set up either OPEN, WPA2, or WPA3.
ip_version: the ip version (ipv4 or ipv6)
"""
# TODO(b/286443517): Properly support WLAN on android devices.
assert (
self.fuchsia_device is not None
), "Fuchsia device not found, test currently does not support android devices."
ssid = utils.rand_ascii_str(AP_SSID_LENGTH_2G)
reboot_device: DeviceType = settings.reboot_device
reboot_type: RebootType = settings.reboot_type
band: BandType = settings.band
ip_version: IpVersionType = settings.ip_version
security_mode: SecurityMode = settings.security_mode
password: str | None = None
if security_mode is not SecurityMode.OPEN:
password = generate_random_password(security_mode=security_mode)
# Skip hard reboots if no PDU present
asserts.skip_if(
reboot_type is RebootType.HARD and len(self.pdu_devices) == 0,
"Hard reboots require a PDU device.",
)
self.setup_ap(
ssid,
band,
ip_version,
security_mode,
password,
)
if not self.dut.associate(
ssid,
target_security=security_mode,
target_pwd=password,
):
raise EnvironmentError("Initial network connection failed.")
test_interface = self.dut.get_default_wlan_test_interface()
if ip_version.ipv4():
self.fuchsia_device.wait_for_ipv4_addr(test_interface)
self.ping_dut_to_ap(band, IpVersionType.IPV4)
if ip_version.ipv6():
self.fuchsia_device.wait_for_ipv6_addr(test_interface)
self.ping_dut_to_ap(band, IpVersionType.IPV6)
# TODO(b/273923552): We take a snapshot here and during test
# teardown for every test because the persistence component does not
# make the inspect logs available for 120 seconds. This helps for
# debugging issues where we need previous state.
self.dut.take_bug_report(self.current_test_info.record)
# DUT reboots
if reboot_device is DeviceType.DUT:
if reboot_type is RebootType.SOFT:
self.fuchsia_device.reboot()
elif reboot_type is RebootType.HARD:
self.dut.hard_power_cycle(self.pdu_devices)
# AP reboots
elif reboot_device is DeviceType.AP:
if reboot_type is RebootType.SOFT:
self.log.info("Cleanly stopping ap.")
self.access_point.stop_all_aps()
elif reboot_type is RebootType.HARD:
self.access_point.hard_power_cycle(self.pdu_devices)
self.setup_ap(ssid, band, ip_version, security_mode, password)
self.prepare_dut_for_reconnection()
uptime = time.time()
try:
try:
self.wait_for_dut_network_connection(ssid)
except ConnectionError as e:
if (
reboot_device is DeviceType.DUT
and security_mode is SecurityMode.WPA3
):
# TODO(http://b/271628778): Remove this try/except statement
# once Fuchsia respects 802.11w (PMF) comeback-time.
raise signals.TestSkip(
f"Received expected ConnectionError due to http://b/271628778: {e}"
)
raise e
time_to_reconnect = time.time() - uptime
if ip_version.ipv4():
self.fuchsia_device.wait_for_ipv4_addr(test_interface)
self.ping_dut_to_ap(band, IpVersionType.IPV4)
if ip_version.ipv6():
self.fuchsia_device.wait_for_ipv6_addr(test_interface)
self.ping_dut_to_ap(band, IpVersionType.IPV6)
except ConnectionError as err:
self.log_and_continue(ssid, error=err)
raise signals.TestFailure(
f"Failed to reconnect to {ssid} after reboot."
)
else:
self.log_and_continue(ssid, time_to_reconnect=time_to_reconnect)
if __name__ == "__main__":
test_runner.main()